A self-hosted, multi-tenant community club platform. One Rails app serves multiple invitation-only clubs — each with its own domain, branding, members, events, and Stripe account. BAASH-B (Bay Area All-Star Has-Beens) is the reference club.
v0.1.0.0 — Rails 8.1 · Ruby 4.0.1 · PostgreSQL · Kamal
- Club operator signs up, sets a custom domain, uploads branding (logo, accent color, font), and adds Stripe keys.
- Members are invited via magic link. They sign in with email/password or Google OAuth.
- Organizers create events (draft → published → cancelled), set capacity and ticket price, and send announcements.
- Members RSVP — free events via atomic capacity check; paid events via Stripe Checkout. Confirmed attendees get an iCal file.
- Receipts — 501(c)3 donation receipts issued for voluntary donation line items (separate from ticket price).
Each club is isolated: members, events, RSVPs, and Stripe keys are all scoped to a club. A member of Club A cannot access Club B data.
| Layer | Technology |
|---|---|
| Framework | Rails 8.1.3 |
| Language | Ruby 4.0.1 (RVM) |
| Database | PostgreSQL |
| Background jobs | Solid Queue (runs in Puma in v1) |
| Caching | Solid Cache |
| WebSockets | Solid Cable |
| Frontend | Hotwire (Turbo + Stimulus) |
| Asset pipeline | Propshaft |
| Payments | Stripe Checkout (per-club keys, encrypted) |
| Deployment | Kamal |
| Edge proxy | kamal-proxy (TLS via Let's Encrypt) |
| Rate limiting | rack-attack |
| Auth | email/password (bcrypt) + Google OAuth + magic-link invites |
Prerequisites: Ruby 4.0.1 via RVM, PostgreSQL, Node.js (for CSS watcher).
git clone https://github.com/petercip/baashb
cd baashb
# Install dependencies
bundle install
# Create and seed the database
bin/rails db:create db:migrate db:seed
# Start the dev server
bin/devThe server runs on localhost:3000. For multi-tenant testing, use lvh.me subdomains (they resolve to 127.0.0.1):
| URL | Club |
|---|---|
http://baashb.lvh.me:3000 |
BAASH-B (the reference club) |
http://other.lvh.me:3000 |
Any other seeded club |
All *.lvh.me subdomains are allowed in development. ClubMiddleware maps the subdomain to a Club record automatically.
bundle exec rspec # full suite (164 examples)
bundle exec rspec spec/models # models
bundle exec rspec spec/requests # request specs (controllers + routes)
bundle exec rspec spec/system # system/E2E tests (Capybara + Playwright)
bundle exec rspec spec/mailers # mailers
bundle exec rspec spec/jobs # background jobs
bundle exec rspec spec/middleware # ClubMiddlewareCritical paths that must pass before any deploy:
- Stripe webhook idempotency — same
checkout_session_iddelivered twice creates zero duplicate RSVPs - Oversell race — two members claim last spot simultaneously; one confirms, one is refunded
- Cross-club isolation — a member of Club A cannot access Club B data
How it works: ClubMiddleware (a Rack middleware inserted before the session) sets Current.club from the Host header on every request:
- Check
Club.find_by(custom_domain: host)— clubs with a custom domain (e.g.baash-b.org) - Fall back to
Club.find_by(slug: subdomain)— slug-based subdomains (e.g.baashb.yourdomain.com) - Neither matches → 404
Rules for contributors:
- Always scope queries via
current_club:current_club.events.published.order(:starts_at)— neverEvent.all - Background jobs receive
club_idas an argument and doClub.find(club_id)directly — neverCurrent.club - All models that belong to a club include
ClubScopedconcern (belongs_to :club+ presence validation) - Never use
default_scopefor club scoping
- RSVP = payment for paid events. Creating a Stripe Checkout session creates an
Rsvpwithstatus: :pending_payment(capacity hold). The webhook transitions it to:confirmed. - Session security —
resume_sessionchecksmember.active?andmember.club_id == Current.club.idon every request. Removed members are immediately locked out. Password resets revoke all existing sessions. - Slug routing — all resource URLs use human-readable slugs (
/events/spring-dinner-2026), never numeric IDs. Implemented withfriendly_idscoped to club. - Stripe keys — stored encrypted on the
Clubmodel via Active Record Encryption. Never logged or stored in plain text.
baashb deploys with Kamal. kamal-proxy handles TLS via Let's Encrypt.
bin/kamal setup # first deploy — installs Docker, starts proxy, deploys app
bin/kamal deploy # subsequent deploysFirst deploy only — after bin/kamal setup, load the Solid Queue, Cache,
and Cable schemas (these don't run via the normal db:prepare path):
bin/kamal app exec "bin/rails db:schema:load:queue db:schema:load:cache db:schema:load:cable"Secrets required in .kamal/secrets:
RAILS_MASTER_KEY— fromconfig/master.keyPOSTGRES_PASSWORD+BAASHB_DATABASE_PASSWORD=$POSTGRES_PASSWORDKAMAL_REGISTRY_PASSWORD— GitHub PAT withwrite:packagesscopeHETZNER_STORAGE_ACCESS_KEY_ID/HETZNER_STORAGE_SECRET_ACCESS_KEY/HETZNER_STORAGE_BUCKET/HETZNER_STORAGE_ENDPOINT
See infra/README.md for the full provisioning guide.
See DESIGN.md for the full design system: typography (Inter), color tokens (gold #c8a96e accent, warm #fafaf8 background), spacing scale, component specs, motion, and accessibility guidelines.
| File | What it covers |
|---|---|
CLAUDE.md |
AI agent conventions, code style, testing rules |
DESIGN.md |
Typography, color, spacing, components, motion, a11y |
CHANGELOG.md |
Version history |
TODOS.md |
Known gaps and future work |
Private. Not open source.