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

Deployment

Deployment

What it does

Two supported production targets — Vercel (zero-config) and a plain Docker/VPS target via output: "standalone" — plus scripts/setup.ts, the interactive CLI that takes a fresh clone to a running, seeded local app in one command (see setup.md for the local-dev side of that; this doc covers what changes for a real deployment).

How to configure it

Every env var this app reads is documented in setup.md's table and .env.example — nothing deployment-specific beyond that, except:

VarPurpose
NEXT_PUBLIC_APP_URL / BETTER_AUTH_URLSet to your real production origin (https://yourapp.com) — used for OAuth callback URLs, email links, and metadataBase.
SENTRY_ORG / SENTRY_PROJECT / SENTRY_AUTH_TOKENOptional, CI/build-only — see observability.md. Only needed to upload source maps; the app runs fine without them.

Set real values for DATABASE_URL, BETTER_AUTH_SECRET, and whichever optional integrations you're using (Google, Stripe, Resend, S3, ...) in whatever secret-management mechanism your target provides (Vercel's project env vars, a VPS's .env file, your container platform's secrets). None of this app's own code branches on "am I on Vercel vs. Docker" — the same build output runs identically either way.

How it works

  • Vercel — connect the repo, no extra config. Vercel's own build/ serverless-function handling covers everything next build + next start need; after() (used by webhooks.md's delivery retries) works out of the box via Vercel's waitUntil integration — see node_modules/next/dist/docs/01-app/03-api-reference/04-functions/after.md's "Platform Support" table.
  • Docker / VPS — next.config.ts sets output: "standalone", which makes next build emit .next/standalone/ — a self-contained folder with its own minimal server.js and only the node_modules actually reachable from the app's import graph traced in, not the full dependency tree. The Dockerfile (repo root) is a 3-stage build — deps (npm ci), builder (npm run build), runner (copies just public/, .next/standalone/, and .next/static/ into a fresh, non-root node:22-alpine layer) — producing a small final image with no source code or full node_modules in it.
    docker build -t launchpad .
    docker run -p 3000:3000 --env-file .env launchpad
    No secrets are needed to build the image — like a local npm run build, lib/env.ts skips its required-var check during Next's NEXT_PHASE=phase-production-build (see setup.md). Pass real config at docker run time via --env-file/--env, never as build args (those end up baked into image layers/history).
    • npm ci in the deps stage runs with --legacy-peer-deps — this starter pins better-auth/better-call/@better-auth/api-key to versions whose peer ranges don't line up exactly; without the flag npm ci fails with an ERESOLVE conflict, exactly as a plain npm install would.
    • You still need Postgres reachable from wherever the container runs — docker-compose.yml's postgres/minio services are for local dev only (named volumes, non-default ports to avoid clashing with other local stacks); point DATABASE_URL/S3_* at your real managed Postgres/S3 bucket in production instead of trying to run docker-compose.yml itself in prod.
  • scripts/setup.ts — the local-dev bootstrap (see setup.md). Not meant to run against a production database; it's specifically the "clone → running local app" path, including bringing up the Docker Compose Postgres/MinIO that only exist for local dev.

Build-time vs runtime environment variables

This is the easiest way to get a container deploy subtly wrong, so it's worth stating plainly:

  • NEXT_PUBLIC_* are inlined by next build. They are not read at runtime. Setting them only in your host's runtime environment leaves the built app carrying whatever was present during the build — usually nothing.
  • Everything else is read at runtime through lib/env.ts, which parses process.env dynamically. Those belong in runtime config only; secrets should never be build args, since those persist in image layers and history.

Getting the first one wrong fails quietly: the site comes up and looks fine, while error tracking never initializes, robots.txt / sitemap / OpenGraph metadata all point at localhost:3000, and the captcha and analytics scripts never render. The Dockerfile's builder stage declares an ARG for each one so your platform can pass them through.

Coolify

Coolify builds the image itself, so both of the above apply. Using the Dockerfile build pack:

  1. Environment variables. Add the runtime ones as normal (DATABASE_URL, BETTER_AUTH_SECRET, BETTER_AUTH_URL, STRIPE_*, RESEND_API_KEY, S3_*, ...). Then add every NEXT_PUBLIC_* you actually use and mark each as a build variable so Coolify passes it as a --build-arg — NEXT_PUBLIC_APP_URL at minimum, plus NEXT_PUBLIC_SENTRY_DSN if you use Sentry or a self-hosted alternative (see observability.md).

  2. Set TRUSTED_PROXIES. Coolify fronts your app with Traefik, which means a multi-hop x-forwarded-for and — left unconfigured — a single shared rate-limit bucket for every visitor. See security.md's reverse-proxy section; the Docker network range (10.0.0.0/8,172.16.0.0/12) is the usual value. Add IP_ADDRESS_HEADERS=cf-connecting-ip as well if Cloudflare proxies the domain.

  3. Migrations. Set the post-deployment command to:

    node --experimental-strip-types scripts/migrate.ts
    

    Not the pre-deployment command. Coolify runs that one inside the container that is already running — the previous image — before it builds the new one. Migrations that ship with the new release aren't in there, and neither is anything else you just added: the deploy fails with Cannot find module, which aborts it, so the image that would have contained the file never gets built. You can watch it happen in the deploy log — the pre-deployment command runs before the Importing… and Building docker image lines, and on a first deploy it says "No running containers found. Skipping."

    The post-deployment command runs in the new container. It exits non-zero on failure, so a bad migration still fails the deploy loudly.

    npm run db:migrate (drizzle-kit) will not work here — drizzle-kit is a devDependency and isn't in the production image. Both paths share drizzle's __drizzle_migrations table, so they can't double-apply each other's work.

  4. Health check. The Dockerfile ships a HEALTHCHECK against /api/health, which returns 503 (not just a connection error) when the database is unreachable — so Coolify can tell "container booted" from "actually serving".

  5. Postgres. Using Coolify's own Postgres is fine; connect over the internal network rather than a published port, and set up its backups — nothing in this app does that for you.

Storage is the one piece Coolify doesn't cover: docker-compose.yml's MinIO is local-dev only, so point S3_* at a real bucket (Cloudflare R2, S3, or a MinIO you run separately) — see uploads.md. Without it, release ZIPs have nowhere to live and the upload UI won't render.

How to extend it

  • A different platform (Fly.io, Railway, Render, Google Cloud Run, a bare VPS with systemd) — all of these can run the same Dockerfile directly, or next build && next start on a plain Node.js host without Docker at all (output: "standalone" is irrelevant there — it only matters for the Docker image's size). See node_modules/next/dist/docs/01-app/01-getting-started/17-deploying.md for platform-specific templates Next.js itself links to.
  • CI-driven deploys — .github/workflows/ci.yml already runs lint/typecheck/tests on every push; add a deploy job/step after it that builds and pushes the Docker image (or triggers your platform's deploy hook) once CI is green.
  • Source maps in Sentry — set SENTRY_ORG/SENTRY_PROJECT/ SENTRY_AUTH_TOKEN as CI secrets (not in .env) — see observability.md.

How to remove it

Nothing here is removable in the usual sense — a Next.js app has to be deployed somewhere. To drop the Docker path specifically and rely on Vercel (or another platform's own build pipeline) only: delete Dockerfile, .dockerignore, and output: "standalone" from next.config.ts (harmless to leave in either way — it only affects .next/standalone's existence, which nothing else depends on).