Skip to main content

Architecture

A short architecture snapshot for sysadmins who need to reason about network paths, blast radius, and what lives where.

Containers

┌──────────────────────────────────────────┐
│ reverse proxy (Caddy / Nginx / CF) │ ← TLS, public 443
│ │ │
│ ▼ │
│ app (Next.js standalone, Node 22) │ ← 127.0.0.1:4210
│ │ │
│ ▼ │
│ postgres:17 (pgdata volume) │
└──────────────────────────────────────────┘

Two containers (app, postgres), one named volume (pgdata). The reverse proxy lives on the host, not inside compose.

Data paths

  • Browser ↔ app — HTTPS via reverse proxy. Sessions are cookie-based (Better Auth).
  • Browser ↔ Bunny — direct video uploads (TUS) and direct video playback (signed embed). The VPS is not in the video path.
  • app ↔ Stripe — outbound HTTPS to create Checkout Sessions.
  • Stripe ↔ app — inbound webhook to /api/webhooks/stripe.
  • app ↔ SMTP — outbound, on save (transactional).

What's encrypted at rest

DataStorageEncryption
User passwordsuser.password_hashbcrypt (Better Auth)
Tenant credentials (Stripe / Bunny / SMTP / Google)app_settings.value_encryptedAES-256-GCM, key = ENCRYPTION_KEY
Sessionssession.tokenrandom; BETTER_AUTH_SECRET signs the cookie
Course content, theme, page-builder JSONvariousplain JSONB

Migrations

Drizzle migrations live in /app/drizzle/*.sql inside the image. On container start, the entrypoint runs the bundled migrator (migrate.js) which applies pending migrations idempotently and records them in __drizzle_migrations__. Skipping is opt-in via SKIP_MIGRATIONS=1.

Idempotency in the Stripe webhook

Every webhook event is recorded in webhook_events keyed by Stripe event ID. A duplicate delivery is detected and ignored — so re-deliveries from Stripe (manual or automatic retries) won't double-enroll a student.