Live demo — shared accounts, sample data, reset regularly. Don't enter anything real.Get the source

Setup

Setup

What it does

Gets the app running locally against a real Postgres database, with or without real Google/Stripe credentials.

How to configure it — environment variables

Copy .env.example to .env and fill in what you need. All parsing and enforcement is centralized in lib/env.ts — don't read process.env.* directly in new server code, import { env } from there instead (two files are deliberately excluded — see its comments).

VarRequired to build/boot?Required for
DATABASE_URLYes at runtime (build-tolerant — see below)Any DB query
BETTER_AUTH_SECRETYes at runtime (build-tolerant — see below)Signing sessions. Generate: openssl rand -base64 32
BETTER_AUTH_URL / NEXT_PUBLIC_APP_URLNoCorrect OAuth callback / redirect URLs
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRETNo"Sign in with Google" only — email/password works without these
STRIPE_SECRET_KEYNo (falls back to a placeholder key in lib/auth.ts)Any Stripe call
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEYNoClient-side Stripe.js, if you add it
STRIPE_WEBHOOK_SECRETNoVerifying webhook signatures from stripe listen/production
STRIPE_PRO_MONTHLY_PRICE_ID, STRIPE_PRO_ANNUAL_PRICE_ID, STRIPE_BUSINESS_MONTHLY_PRICE_ID, STRIPE_BUSINESS_ANNUAL_PRICE_IDNoActually completing checkout for a paid tier (see billing.md)

Nothing is required to npm run build — lib/env.ts detects Next's build phase (NEXT_PHASE=phase-production-build, set while it imports every route module to "collect page data") and skips enforcement entirely then, so CI and a fresh clone never fail to build on missing secrets.

At actual runtime (npm run dev / npm run start), lib/env.ts checks for DATABASE_URL and BETTER_AUTH_SECRET — the two vars nothing works without:

  • npm run dev — missing either just logs a clear one-line warning (⚠️ [env] Missing required environment variable(s): ...); the dev server still comes up so you can work on unrelated pages while filling in .env.
  • npm run start (NODE_ENV=production) — missing either throws, surfacing as a clean 500 on the first request that imports lib/auth.ts or lib/db/index.ts, instead of a confusing raw pg/OAuth error deeper in the stack.

Everything else (Google, Stripe) is optional in every mode — those are features that degrade gracefully, not app-breaking requirements.

How it works — clone to running app in under 2 minutes

Needs Docker + Node — nothing else.

npm install
npm run setup    # interactive: prompts for DB/app URL/Google/Stripe,
                  # writes .env, generates BETTER_AUTH_SECRET, brings up
                  # Postgres+MinIO, migrates, seeds
npm run dev       # http://localhost:3000

scripts/setup.ts is the one-command bootstrap — see its own file comment for exactly what it does and in what order. It's interactive by default; npm run setup -- --defaults runs the same steps non-interactively (every prompt takes its default, blank for anything without one), for CI or scripted first-run. It's also safe to re-run: an existing .env is kept unless you explicitly choose to overwrite it, and every step past that (db:migrate, db:seed) is itself idempotent.

If you'd rather do it by hand instead of the prompts:

cp .env.example .env
# generate a real secret and paste it in as BETTER_AUTH_SECRET:
openssl rand -base64 32
npm run dev:db && npm run db:migrate && npm run db:seed
npm run dev

Log in with a seeded account (see scripts/seed.ts for the full list):

EmailPasswordRole
admin@example.compassword123admin (visit /admin)
user@example.compassword123user, has an active "pro" subscription seeded

db:seed also seeds 2 contact submissions (visible at /admin/contacts) and the demo user's subscription (visible at /admin/subscriptions and /billing).

Re-running npm run setup / npm run db:seed is safe — it's idempotent, skipping any row that already exists by email/reference instead of erroring or duplicating.

To promote a real signed-up user to admin instead of using the seeded one:

npm run promote-admin you@example.com

Postgres via Docker Compose

docker-compose.yml runs postgres:17-alpine on host port 5434 (not the default 5432 — chosen to avoid clashing with a locally-installed Postgres or another project's compose stack already using 5432), with a named volume so data survives restarts. DATABASE_URL in .env.example already points at it.

npm run dev:db          # brings up postgres + minio, blocks until healthy
npm run dev:db:down     # docker compose down

If you'd rather point at a Postgres you already have running elsewhere, just skip npm run dev:db and set DATABASE_URL to that instance instead — everything past that (db:migrate, db:seed) works the same either way.

Why dev:db is two docker compose calls, not one. Compose's --wait fails the whole command (exit 1) if any service in the file isn't running/healthy once the wait resolves — including minio-init, a one-shot container that is supposed to exit 0 after creating the upload bucket (see docker-compose.yml). A single up -d --wait therefore reported failure at random even when every container came up fine, taking down everything that chains it (test:integration, test:e2e, npm run setup). So dev:db waits on postgres+minio only, then runs minio-init in its own call, whose exit code is that one container's real exit code. Keep that split if you add services.

Scripts reference

ScriptWhat it does
npm run dev / build / startNext.js dev / production build / production serve
npm run lintESLint
npm run setupInteractive: prompts, writes .env, dev:db → db:migrate → db:seed — the one-command bootstrap. -- --defaults for non-interactive
npm run dev:db / dev:db:downStart/stop the Docker Compose Postgres + MinIO
npm run db:generateDiff lib/db/schema/* against the last migration and write a new one into drizzle/
npm run db:migrateApply pending migrations to DATABASE_URL
npm run db:pushPush schema directly, skipping migration files (dev-only shortcut, don't use in prod)
npm run db:studioDrizzle Studio (visual table browser)
npm run db:seedIdempotently seed demo users, a subscription, and contact submissions — see scripts/seed.ts
npm run promote-admin <email>Sets user.role = 'admin' for an existing user

Extend

  • Add more seed data — edit scripts/seed.ts. It's self-contained (raw pg, no @/ path aliases — same reasoning as scripts/promote-admin.ts: Node's --experimental-strip-types doesn't resolve tsconfig path aliases) and idempotent by design — check-then-insert per row, keyed on something stable (email, reference_id, etc.) — follow that pattern for new rows so re-running npm run setup stays safe. Passwords are hashed via hashPassword from the better-auth/crypto subpath export — that's a real npm package export (unlike @/lib/auth, which pulls in path-aliased imports Node can't resolve here) and produces hashes that verify correctly against the normal login form.
  • Different Postgres image/version — edit docker-compose.yml directly; update .env.example's DATABASE_URL port if you change the host port mapping.
  • Add new required env vars to .env.example with a comment explaining what they're for and whether they're required to boot vs. only for a specific flow — follow the table above as the pattern.

Remove

Setup itself (scripts/setup.ts, db:migrate) isn't something you remove — it's the flow for the app itself. What's optional:

  • Delete docker-compose.yml, scripts/seed.ts, and the dev:db*/setup/ db:seed entries from package.json's scripts if you don't want the Docker-based flow — point DATABASE_URL at any Postgres instance and run npm run db:migrate directly instead. scripts/setup.ts would need trimming too (drop the dev:db/db:seed steps it runs).