Features · Infrastructure

Your own database. Your own domain. Your own deploy lane.

bookingapp doesn’t put your bookings in someone else’s shared SaaS table. Each operator gets their own Postgres database, their own DB role, their own domain, and their own deploy pipeline — isolated by construction.

The model

One Next.js codebase is built and deployed per tenant, not globally. Each tenant gets:

Its own Postgres database

A dedicated database, accessed through PgBouncer for connection pooling. No row-level tenancy, no tenant_id column on every table. Cross-tenant queries are impossible by construction.

Its own DB role

A dedicated Postgres role with ownership over every table in its database. The app connects as the tenant role, never as postgres. This is the foundation that makes “a customer wants their own VPS” a config change rather than a rebuild.

Its own .env.local

Per-tenant environment file with database URL, auth secrets, app URL, cron secret, plus any tenant-specific public configuration. Pushed from the tenant manifest as part of every deploy.

Its own service process

A dedicated Node process under PM2, on its own upstream port. nginx terminates TLS on the public domain and reverse-proxies to it. One tenant’s restart never affects another.

Its own nginx config

A dedicated nginx site config rendered from a per-tenant template, so domains, redirects, security headers, and gateway rules stay isolated per operator.

Its own marketing clone

A separate static-site repo for the public-facing marketing pages, independent of the booking app. Updates to the brochure ship in seconds without touching the booking service.

Why per-database isolation matters

  • Compliance optionality. A customer can insist their data sits on their own server, in their own country. The host adapter abstraction means “our shared infrastructure” is just one option — a future on-prem adapter plugs in without touching app code.
  • Blast radius is zero. A bug that leaks one tenant’s bookings into another tenant’s admin UI is literally impossible when queries run against different databases. No SQL where-clause to forget.
  • No tenant-id pollution. No tenantId foreign key on every table, no row-level security policies to maintain, no “did we add the tenant filter to that query” bug class. Schema stays clean.
  • Per-tenant restores. Restoring one operator’s database to a previous backup is a single pg_restore — no untangling rows from a shared table.

The deploy CLI

One command does everything:

# Deploy your marketing site
tenant deploy yourbrand marketing

# Deploy the booking app
tenant deploy yourbrand app

# Deploy both in one shot
tenant deploy yourbrand all

# Detect drift between local config and live state
tenant doctor yourbrand

# Tail live logs
tenant logs yourbrand

Behind the scenes, marketing deploys rsync your static or built marketing site to the server, stage the booking widget into your clone, reload nginx if anything changed, and report the file diff from rsync so you can tell at a glance whether anything actually shipped — a no-op deploy reports zero changes.

App deploys rsync the codebase, run npm install + npm run build on the server, restart the PM2 process, and health-check the upstream port to confirm the new build came up.

Drift detection — tenant doctor

tenant doctor yourbrand doesn’t deploy. It compares the expected state from your tenant manifest against what’s actually live on the server, and reports anything out of sync:

  • nginx config matches the rendered template
  • PM2 service is running
  • upstream port is responsive
  • public domain returns 200
  • known-static asset hashes match (logos, etc.)

The drift detector was built specifically to catch the kinds of silent regressions that creep in over time — a missing CORS header that breaks the widget, a stale calendar variant, a 404 on a product image. All caught by doctor before a customer ever sees them.

The stack

Boring where possible, opinionated where it matters.

  • Next.js on the booking app side, with React for the UI and server components for admin pages. TypeScript end to end.
  • Postgres + PgBouncer for data. Hand-written SQL migrations so we control ownership and apply as the tenant role.
  • Prisma as the ORM, connecting through PgBouncer.
  • Stripe Checkout (hosted) for payments — no PCI scope on our servers, Apple Pay / Google Pay / 3DS handled by Stripe.
  • Resend + React Email for transactional email, with operator-editable copy.
  • Twilio for SMS confirmations (optional, per tenant).
  • PM2 on bare Node for the runtime — no Docker, no Kubernetes, no microservices. One service per tenant, one place to look when something’s wrong.
  • nginx for TLS termination and reverse proxying.
  • Python (stdlib only) for the deploy CLI — deliberately dependency-free, runs anywhere with SSH and rsync.