Skip to content

Latest commit

 

History

History
426 lines (366 loc) · 21.4 KB

docker.md

File metadata and controls

426 lines (366 loc) · 21.4 KB
title Docker
description Build and run the production multi-stage container from Next.js standalone output.
group General
order 65

Running devresponsekit in Docker

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).


1. What the image contains (and does not)

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.


2. Build

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.

Browser-side Sentry values are build arguments

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 .

3. Configure (environment variables)

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_VALIDATION or AUTH_RATE_LIMIT_DISABLED in a production container — the env schema refuses both when NODE_ENV=production (outside CI), since they would weaken auth.


4. Migrate the database (separate step, before traffic)

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/admin

Apply 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.


5. Run

docker run --rm -p 3000:3000 --env-file .env.docker devresponsekit:latest

The 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.

docker compose (app + Postgres) for local or single-host

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:

6. Deploy

  1. Build and tag with an immutable tag (a commit SHA, not latest): docker build -t REGISTRY/devresponsekit:$GIT_SHA .
  2. Push: docker push REGISTRY/devresponsekit:$GIT_SHA.
  3. Run the migration step (§4) against the target database.
  4. Roll out the new image; route traffic once it is healthy.

7. Hardening (recommended for production)

  • 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 both FROM lines) for byte-for-byte reproducibility. Dependabot's docker ecosystem (.github/dependabot.yml) opens a PR when the tag moves to a new digest; to bump by hand, take the digest field from https://hub.docker.com/v2/repositories/library/node/tags/22-bookworm-slim and 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 trivy job migrates and seeds a throwaway Postgres from the builder stage (§4), starts the scanned image with runtime configuration only, and fails unless /api/health/ready, /api/health and /en/sign-in answer, a wrong password gets Better Auth's 401, the seeded admin can open a docs page whose code blocks Shiki highlighted, and a NEXT_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, and yarn binaries (and npm's vendored node_modules) that the Node base image bundles. The container only ever runs node 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 you docker exec into a container, expect npm/npx to 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 livenessProbe to GET /api/health (200, no DB) and its readinessProbe to GET /api/health/ready (200 when the environment passes its schema, the database is reachable, the app_schema_migrations ledger holds every core migration the image was built with and Better Auth's own schema check finds every table and column its configuration writes; 503 config_invalid, database_unreachable or schema_behind otherwise, 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 report schema_behind. The image's own HEALTHCHECK uses the same readiness probe. Both probes are unauthenticated and no-store.
  • Graceful shutdown: on SIGTERM/SIGINT two 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 (143 for SIGTERM, 130 for 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 overruns SHUTDOWN_TIMEOUT_MS (default 10s), so a wedged request can never hang the shutdown. Set the orchestrator's termination grace period above SHUTDOWN_TIMEOUT_MS, treat exit code 143 as a normal signal stop, and do not set NEXT_MANUAL_SIG_HANDLE (it removes step 1, so the process would exit after the budget without draining HTTP).

8. Caveats / known limitations

  • 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_limits from migration 0006, and Better Auth's rateLimit table from pnpm 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 (nginx client_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 (or NEXT_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/ and help/ are copied into the image so both viewers work out of the box (they default to <cwd>/docs and <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 with DOCS_ROOT / HELP_ROOT to 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. sendAppEmail attempts 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 in app_outbox. Run pnpm outbox:drain periodically (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 its email_delivery log lines and its [outbox] … expired=… summary line. To have them counted in devresponsekit_outbox_delivery_total, have the scheduler call GET /api/internal/outbox-drain on the app instead, with CRON_SECRET set on the app and sent as Authorization: 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); with pnpm outbox:drain instead, schedule pnpm db:prune too, or the audit and outbox tables grow without bound.
  • MCP self-registration needs a scheduled reaper (only if MCP_REGISTRATION_ENABLED). Run pnpm mcp:reap periodically the same way to expire registrations still pending after MCP_REGISTRATION_PENDING_TTL_DAYS (default 7), so junk registrations do not pile up in the Agents console. Same toolchain caveat as the drainer.