| title | Docker |
|---|---|
| description | Build and run the production multi-stage container from Next.js standalone output. |
| group | General |
| order | 65 |
The repository ships a production Dockerfile (multi-stage) and a
.dockerignore that build a small, non-root container from Next.js'
standalone output (output: "standalone" in next.config.mjs).
The final image is essentially node server.js plus only the runtime
dependencies Next traced — not the full repo, dev dependencies, or build
toolchain. A typical image is a few hundred MB rather than the ~1 GB a
naïve COPY . . image would produce.
TL;DR
docker build -t devresponsekit . docker run --rm -p 3000:3000 --env-file .env.docker devresponsekit…after running the database migrations as a separate step (below).
| Included | Not included |
|---|---|
The standalone Next.js server (server.js) |
Dev dependencies / build toolchain |
Traced runtime node_modules |
The Postgres database (run it separately) |
.next/static, public/ |
Migration / seed scripts (tsx, src/db/**) |
docs/ (source for the in-app /docs viewer) |
Your .env / secrets (passed at runtime) |
help/ (source for the in-app help viewer) |
The npm / npx / corepack / yarn CLIs (deleted from the runner stage, see §7) |
Because the migration and seed scripts (pnpm db:*, which use tsx) are
not in the runtime image, this container never runs migrations on
start. Migrations are a deliberate, separate step — see §4.
Your .env files and Vercel's pulled secrets never reach the build stage
either: .dockerignore keeps every .env* file but .env.example out of
the build context at any depth, along with .vercel/ (where vercel pull
writes production's secrets in plain text) and the deploy CLI in
vercel-cli/ (F-140). The Dockerfile's COPY . . would otherwise put them
in the builder stage's layer and the build cache, even though the runner
stage copies none of them.
Prerequisites: Docker with BuildKit (Docker 23+ enables it by default).
docker build -t devresponsekit:latest .No secrets are needed at build time. next build runs with
NEXT_PHASE=phase-production-build, so src/lib/env.ts substitutes
placeholder values and nothing real is baked into the image.
next build copies every NEXT_PUBLIC_* variable set while it runs into the
JavaScript it emits, and the browser has no other way to read one. So in an
image built without them, Sentry captures on the server only (with
SENTRY_DSN at run time, §8): no browser errors, Web Vitals or session
replay, however the container is configured (F-108). Pass the public Sentry
values to the build instead:
docker build \
--build-arg NEXT_PUBLIC_SENTRY_DSN=https://<key>@<org>.ingest.sentry.io/<project> \
--build-arg NEXT_PUBLIC_SENTRY_ENVIRONMENT=production \
--build-arg NEXT_PUBLIC_SENTRY_RELEASE="$GIT_SHA" \
-t devresponsekit:$GIT_SHA .The builder stage also takes NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE,
NEXT_PUBLIC_SENTRY_REPLAYS_SESSION_SAMPLE_RATE and
NEXT_PUBLIC_SENTRY_REPLAYS_ERROR_SAMPLE_RATE, which default to 0.1, 0
and 1: a tenth of browser traces sampled, no clean session replayed,
every session that errors replayed (the other Sentry settings are in
configuration.md). A value passed this
way is fixed in the image, in the server bundle as well as the browser's, so
changing one means rebuilding. An argument passed empty counts as not passed.
The other public variables (NEXT_PUBLIC_APP_URL, NEXT_PUBLIC_APP_NAME,
NEXT_PUBLIC_PRODUCTION_HOST) are deliberately not build arguments: the
server reads them at run time (§3), so one image serves every environment.
Source-map upload needs SENTRY_ORG, SENTRY_PROJECT and
SENTRY_AUTH_TOKEN, and runs only when NEXT_PUBLIC_SENTRY_DSN is passed
too. The token is a secret, so pass it as a BuildKit secret, never a
--build-arg: build arguments are recorded in the image history, and the
Dockerfile declares none for the token.
SENTRY_AUTH_TOKEN=... docker build \
--secret id=sentry_auth_token,env=SENTRY_AUTH_TOKEN \
--build-arg SENTRY_ORG=acme --build-arg SENTRY_PROJECT=devresponsekit \
--build-arg NEXT_PUBLIC_SENTRY_DSN=https://<key>@<org>.ingest.sentry.io/<project> \
-t devresponsekit:$GIT_SHA .The server validates its environment at boot (src/lib/env.ts, parsed
by register() in src/instrumentation.ts before the first request) and
exits with code 1 if a required variable is missing or invalid, naming each
variable and the rule it broke — so a misconfigured container fails fast
instead of serving broken auth. Until F-26 that parse waited for the first
request that needed a value, and a container with, say, a 20-character
BETTER_AUTH_SECRET stayed up and reported healthy while every page that
touched auth answered 500.
Minimum required to start:
| Variable | Notes |
|---|---|
BETTER_AUTH_SECRET |
≥ 32 chars; signs sessions. Use a unique random value per environment. |
BETTER_AUTH_URL |
The app's public base URL, e.g. https://app.example.com. |
DATABASE_URL |
Postgres connection string. |
SSO_HANDOFF_ISSUER |
SSO handoff issuer — the primary's origin URL (consumers fetch its public keys from ${SSO_HANDOFF_ISSUER}/api/sso/jwks.json). |
SSO_HANDOFF_AUDIENCE_PREFIX |
SSO handoff audience prefix. |
SSO_HANDOFF_APPLICATION_ID |
This deployment's application id (required at boot even if SSO is unused). |
Optional: SSO_HANDOFF_PRIVATE_KEY — the Ed25519 private JWK that signs handoffs; set it only on the issuer (satellites verify against the issuer's JWKS and hold no key). Without it the instance still consumes handoffs but /api/sso/launch answers 503. Generate one with the command in configuration.md → SSO handoff.
Common optional variables: DB_SCHEMA (default auth),
NEXT_PUBLIC_APP_URL, ADMIN_TRUSTED_ORIGINS, the EMAIL_* provider keys,
the API_* machine-credential switches, and
DOCS_*. The full, authoritative list with defaults and purpose is in
.env.example and configuration.md.
Put the runtime values in a file (do not bake them into the image):
# .env.docker (NEVER commit this)
BETTER_AUTH_SECRET=...
BETTER_AUTH_URL=https://app.example.com
DATABASE_URL=postgres://app:***@db:5432/app
SSO_HANDOFF_ISSUER=https://app.example.com
SSO_HANDOFF_AUDIENCE_PREFIX=devresponse-app
SSO_HANDOFF_APPLICATION_ID=main
# issuer only — Ed25519 private JWK (JSON); omit on satellites
SSO_HANDOFF_PRIVATE_KEY={"kty":"OKP","crv":"Ed25519","x":"...","d":"..."}In production, prefer your orchestrator's secret mechanism (Kubernetes
Secrets, ECS task secrets, Fly secrets, etc.) over an --env-file.
Do not set
SKIP_ENV_VALIDATIONorAUTH_RATE_LIMIT_DISABLEDin a production container — the env schema refuses both whenNODE_ENV=production(outside CI), since they would weaken auth.
Run migrations once per deploy, from a source checkout (or a dedicated
init job / "migrator" container that has the dev toolchain), pointed at the
production DATABASE_URL — never from the web container:
DATABASE_URL=postgres://app:***@db:5432/app DB_SCHEMA=auth \
pnpm db:auth:migrate # Better Auth identity tables
DATABASE_URL=... DB_SCHEMA=auth pnpm db:app:migrate # application schema
DATABASE_URL=... DB_SCHEMA=auth pnpm db:seed # first deploy only: baseline org/roles/adminApply migrations before routing traffic to a new image. In an
orchestrator this is a pre-deploy job / init container that must succeed
before the web Deployment rolls out. Run both migrators on every deploy,
not only the first: a better-auth upgrade or a new plugin changes the Better
Auth tables (src/db/migrations/better-auth-schema.sql), and the readiness
probe answers 503 schema_behind for a gap in either half (§7).
db:auth:migrate loads the app's auth configuration, so it needs the
required variables of §3 in the shell or the checkout's .env; the Better
Auth schema does not depend on their values, which is why
.github/workflows/migrate-production.yml runs it with CI placeholders.
Point both at the direct endpoint, as the role that owns the schema:
they refuse a pooled or re-pointed DATABASE_URL, a session whose
search_path does not resolve to DB_SCHEMA, and a role that does not own
the existing ledger, each with exit code 1 and the reason (Deployment
§5).
No source checkout where the migration runs? The Dockerfile's builder
stage has the whole toolchain. Build it with the same --build-args as the
runtime image, so it comes from that build's cache, and run the migrators
from it (this is how CI migrates the database it boots the image against,
§7):
docker build --target builder -t devresponsekit-migrate .
docker run --rm --env-file .env.docker devresponsekit-migrate \
sh -c "pnpm db:auth:migrate && pnpm db:app:migrate"It holds the full source and dev dependencies, so use it for this one-off step only, never as the web container.
docker run --rm -p 3000:3000 --env-file .env.docker devresponsekit:latestThe standalone server reads PORT (default 3000) and HOSTNAME
(set to 0.0.0.0 in the image so it accepts external connections). It runs
as the unprivileged nextjs user.
Terminate TLS at a reverse proxy / load balancer in front of the container
and set BETTER_AUTH_URL / NEXT_PUBLIC_APP_URL to the public https://
origin.
Do not publish port 3000 straight to clients. The per-IP limits (Better
Auth's sign-in limiter included) and the IPs on audit rows and sessions come from
a request header, and with nothing in front of the container every such header is
one the client sends: a random X-Forwarded-For per request is a fresh sign-in
bucket, which means unlimited password guessing (F-17). The proxy in front must
either overwrite or append to the client's X-Forwarded-For (keep the default
CLIENT_IP_SOURCE=xff), or set a header of its own and name it, for example
CLIENT_IP_SOURCE=x-real-ip behind nginx proxy_set_header X-Real-IP $remote_addr;.
Set CLIENT_IP_SOURCE explicitly either way: a production container without it logs
a warning at boot. See
Choosing the client-IP source.
services:
db:
image: pgvector/pgvector:pg17
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app
volumes: ["dbdata:/var/lib/postgresql/data"]
migrate:
build: .
# The runtime image has no migration tooling, so run migrations from a
# source checkout / CI step instead, OR from the Dockerfile's `builder`
# stage (section 4) with `pnpm db:auth:migrate && pnpm db:app:migrate`.
# Shown here as a reminder that this is a distinct, run-once step gating
# `app`.
profiles: ["tools"]
app:
build: .
depends_on: [db]
# Local use only. On a real host, publish a reverse proxy instead and let
# only it reach this port: with nothing in front, a client-sent
# X-Forwarded-For picks its own rate-limit bucket (see section 5).
ports: ["3000:3000"]
environment:
DATABASE_URL: postgres://app:app@db:5432/app
BETTER_AUTH_SECRET: change-me-to-a-random-32char-min-secret
BETTER_AUTH_URL: http://localhost:3000
SSO_HANDOFF_ISSUER: http://localhost:3000
SSO_HANDOFF_AUDIENCE_PREFIX: devresponse-app
SSO_HANDOFF_APPLICATION_ID: main
# Issuer only: an Ed25519 private JWK (JSON string) — generate one with the
# command in configuration.md; leave unset on a consumer-only satellite.
# SSO_HANDOFF_PRIVATE_KEY: '{"kty":"OKP","crv":"Ed25519","x":"...","d":"..."}'
volumes:
dbdata:- Build and tag with an immutable tag (a commit SHA, not
latest):docker build -t REGISTRY/devresponsekit:$GIT_SHA . - Push:
docker push REGISTRY/devresponsekit:$GIT_SHA. - Run the migration step (§4) against the target database.
- Roll out the new image; route traffic once it is healthy.
- Base image is pinned by digest — keep it current. Both stages use
FROM node:24-bookworm-slim@sha256:<digest>(the multi-arch index digest, identical in bothFROMlines) for byte-for-byte reproducibility. Dependabot'sdockerecosystem (.github/dependabot.yml) opens a PR when the tag moves to a new digest; to bump by hand, take thedigestfield fromhttps://hub.docker.com/v2/repositories/library/node/tags/22-bookworm-slimand update both lines together. The weekly Trivy scan (.github/workflows/docker-scan.yml, a required check) goes red when a stale base image accumulates fixable HIGH/CRITICAL CVEs, so a digest bump is the usual fix for a base-OS finding. - CI boots the image it scans. A module or native file the standalone
trace drops still builds and scans clean, then answers 500 at run time. So
the same
trivyjob migrates and seeds a throwaway Postgres from thebuilderstage (§4), starts the scanned image with runtime configuration only, and fails unless/api/health/ready,/api/healthand/en/sign-inanswer, a wrong password gets Better Auth's401, the seeded admin can open a docs page whose code blocks Shiki highlighted, and aNEXT_PUBLIC_*build argument (§2) reached the browser bundle (F-108). - No package-manager CLIs in the runtime image. The runner stage deletes
the
npm,npx,corepack, andyarnbinaries (and npm's vendorednode_modules) that the Node base image bundles. The container only ever runsnode server.js, so nothing is lost — and npm's own dependency tree (tar, pacote, sigstore, minimatch, …) can no longer produce "fixable HIGH/CRITICAL" Trivy findings that are unreachable in production. If youdocker execinto a container, expectnpm/npxto be absent by design. - Read-only root filesystem:
docker run --read-only --tmpfs /tmp …(the standalone server does not write to its own directory). - Drop capabilities / no new privileges:
--cap-drop ALL --security-opt no-new-privileges. - Resource limits: set CPU/memory limits and a restart policy.
- Secrets: inject via the orchestrator's secret store, not image layers or committed env files.
- Health probes: wire the orchestrator's
livenessProbetoGET /api/health(200, no DB) and itsreadinessProbetoGET /api/health/ready(200 when the environment passes its schema, the database is reachable, theapp_schema_migrationsledger holds every core migration the image was built with and Better Auth's own schema check finds every table and column its configuration writes; 503config_invalid,database_unreachableorschema_behindotherwise, with the detail in the log and never in the body). Because readiness covers both schemas, a rolling update of an image whose migration has not been applied stalls on unready pods instead of routing traffic to code that would 500 — run the migrate init step and the pods become ready. One exception, from Better Auth (F-26): a pod that started while a Better Auth table or column was missing keeps that verdict, and keeps refusing every auth request, until it restarts; migrating fixes new pods, so restart the ones that already reportschema_behind. The image's ownHEALTHCHECKuses the same readiness probe. Both probes are unauthenticated andno-store. - Graceful shutdown: on
SIGTERM/SIGINTtwo things run, in this order (review #24). (1) Next's own signal cleanup (node server.js→start-server.js) stops accepting connections, waits for every in-flight request — and therefore every query it issues — to finish, then exits with the signal code (143for SIGTERM,130for SIGINT); the PostgreSQL pool is deliberately not ended during that window, because ending it fails any request between two queries (Cannot use a pool after calling end) — the idle sockets are closed by the OS at exit, which Postgres treats as an ordinary disconnect. (2) The app's watchdog, armed by the same signal, ends the pool and exits with the same signal code only if that drain overrunsSHUTDOWN_TIMEOUT_MS(default 10s), so a wedged request can never hang the shutdown. Set the orchestrator's termination grace period aboveSHUTDOWN_TIMEOUT_MS, treat exit code 143 as a normal signal stop, and do not setNEXT_MANUAL_SIG_HANDLE(it removes step 1, so the process would exit after the budget without draining HTTP).
- Rate limiting across replicas: the security floors are shared, the
per-actor guards are not. The pre-auth floors — the token endpoint,
the MCP endpoint and MCP registration, the CSP report sink, SSO consume and
a signed-out SSO launch, invitation acceptance — and Better Auth's own sign-in /
password-reset limiter keep their buckets in Postgres (
app_rate_limitsfrom migration0006, and Better Auth'srateLimittable frompnpm db:auth:migrate), so they enforce one budget across every container, and so do the budgets on the admin actions that send mail (the test email, invitations, an admin's reset email; F-64). The admin per-actor abuse guard on the other mutations, bulk, export and a signed-in SSO launch is still per process: under horizontal scaling its budget multiplies by the number of containers and resets on restart. That guard layers on top of authorization, so multi-instance is supported; only the per-actor UX limit is best-effort there. See Deployment → Operations & gotchas and troubleshooting.md. - Set a request body limit at the reverse proxy. Nothing in front of the
container caps a body the way Vercel does (4.5 MB). The public endpoints that
read a body before authenticating cap it themselves and answer
413(F-78, see API security §9), and Better Auth's routes pass through the proxy, which buffers at most 10 MB of a body; every other route reads its body only after authentication, but with no limit of its own. A proxy limit (nginxclient_max_body_size 1m;, or your load balancer's equivalent) bounds all of them. - Observability is opt-in, and half of it is build-time. Server-side
Sentry initializes when
SENTRY_DSN(orNEXT_PUBLIC_SENTRY_DSN) is set at run time. Browser-side Sentry exists only in an image built with--build-arg NEXT_PUBLIC_SENTRY_DSN=…(§2); setting it on the container does not reach the browser. Without either, the image captures nothing. See configuration.md. - In-app docs + help viewers.
docs/andhelp/are copied into the image so both viewers work out of the box (they default to<cwd>/docsand<cwd>/help). The build's trace for those routes holds that content and nothing else of the working tree (F-88; CI checks it after the build). Override withDOCS_ROOT/HELP_ROOTto serve a different directory (e.g. a mounted volume). The screenshot capture tool beside the help content (help/capture.mjs) is operator tooling and is excluded from the build context by.dockerignore, so the image holds only servable content. - Email retries need a scheduled drainer.
sendAppEmailattempts delivery inline, retrying a transient provider failure up to three attempts within about 20 seconds (F-99); a failure that outlasts those leaves the row retryable inapp_outbox. Runpnpm outbox:drainperiodically (cron / K8s CronJob / scheduled task) to re-attempt those rows with backoff until they succeed or hit the cap. Like migrations, it needs the dev toolchain (tsx,src/db), so run it from a source checkout or a "tools" image — not the runtime container.OUTBOX_DRAIN_LIMIT(default 100) bounds rows per run; concurrent runs are safe (FOR UPDATE SKIP LOCKED), and a row an inline send is still delivering is leased to it for two minutes, so even a drain every minute does not send it twice (F-101). Because the drainer is its own process, its outcomes never reach the server's/api/metrics: they are in itsemail_deliverylog lines and its[outbox] … expired=…summary line. To have them counted indevresponsekit_outbox_delivery_total, have the scheduler callGET /api/internal/outbox-drainon the app instead, withCRON_SECRETset on the app and sent asAuthorization: Bearer …(50 rows per call, no dev toolchain needed). See observability.md §5. That route also runs the data-retention prune after each drain (F-96); withpnpm outbox:draininstead, schedulepnpm db:prunetoo, or the audit and outbox tables grow without bound. - MCP self-registration needs a scheduled reaper (only if
MCP_REGISTRATION_ENABLED). Runpnpm mcp:reapperiodically the same way to expire registrations still pending afterMCP_REGISTRATION_PENDING_TTL_DAYS(default 7), so junk registrations do not pile up in the Agents console. Same toolchain caveat as the drainer.