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:
Var
Purpose
NEXT_PUBLIC_APP_URL / BETTER_AUTH_URL
Set to your real production origin (https://yourapp.com) — used for OAuth callback URLs, email links, and metadataBase.
SENTRY_ORG / SENTRY_PROJECT / SENTRY_AUTH_TOKEN
Optional, 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.
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:
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).
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.
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.
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".
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).