Skip to content

Docker Guide

This guide covers the Docker setup for QAuth: production (prod build, no watch) and development (dev image, Compose Watch + nx serve --watch).

QAuth uses Docker Compose to orchestrate the following services:

ServiceImagePurpose
postgrespostgres:18-alpinePrimary database (PostgreSQL 18 with uuidv7() support)
redisredis:7-alpineSession cache and rate limiting
migration-runnerCustomRuns database migrations via Nx
auth-serverCustomMain authentication API server
developer-portalCustomTanStack Start web UI for registration/login/consents
Use caseCompose file(s)Service imageWatch
Productiondocker-compose.ymlDockerfile (multi-stage prod build)No
Developmentdocker-compose.yml + docker-compose.dev.ymlDockerfile.dev (deps + source, dev server)Yes (sync + rebuild)

Both auth-server and developer-portal follow this convention: a multi-stage Dockerfile for production and a Dockerfile.dev for the watch-based dev flow.

  • Docker 23.0+ (BuildKit on by default) or earlier Docker with DOCKER_BUILDKIT=1 set. The auth-server and migration-runner Dockerfiles use # syntax=docker/dockerfile:1.7 and a --mount=type=cache pnpm-store mount, both of which require BuildKit.
  • Docker Compose 2.0+
  • Docker Compose 2.22+ for development watch (docker-compose.dev.yml + --watch)
  • OpenSSL (for generating JWT keys)

QAuth uses EdDSA (Ed25519) for JWT signing. Generate a key pair:

Terminal window
# Generate private key
openssl genpkey -algorithm Ed25519 -out private.pem
# Extract public key
openssl pkey -in private.pem -pubout -out public.pem

See the Keys guide for RS256 and ML-DSA key generation, and a pitfall (openssl genrsa emits the wrong private-key format) that wastes an afternoon if you hit it.

Terminal window
# Copy the example environment file
cp .env.docker.example .env
# Edit .env and add your JWT keys
# The keys should include the BEGIN/END lines

Example .env content:

Terminal window
DB_PASSWORD=your_secure_password
JWT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIKx...
-----END PRIVATE KEY-----"
JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEA...
-----END PUBLIC KEY-----"
JWT_ISSUER=http://localhost:3000

Set JWT_PUBLIC_KEY explicitly. .env.docker.example marks it optional, but omitting it currently fails EdDSA key setup at boot — see the Keys guide before you skip it.

Production (prod build, no watch):

Terminal window
docker compose up -d
docker compose logs -f

Development (dev image, Compose Watch, nx serve --watch; requires Docker Compose 2.22+):

Terminal window
# In .env: NODE_ENV=development, LOG_LEVEL=debug
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --watch
  • Sync: changes in apps/auth-server/ and libs/ are synced into the container; nx serve --watch rebuilds and restarts.
  • Rebuild: changes to package.json, lockfile, nx.json, etc. trigger a full image rebuild.
  • See Compose file watch.
Terminal window
# Check all services are healthy
docker-compose ps
# Test the auth-server health endpoint
curl http://localhost:3000/health

Expected response:

{
"status": "ok",
"timestamp": "2026-01-15T05:15:47.887Z",
"services": {
"database": "connected",
"redis": "connected"
}
}

The developer-portal has no dedicated health endpoint (see Developer Portal below); confirm it’s up by opening http://localhost:3001 in a browser, or checking that Compose reports its container healthy:

Terminal window
docker compose ps developer-portal
┌─────────────────────────────────────────────────────────────┐
│ Docker Network │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ postgres │ │ redis │ │ migration-runner │ │
│ │ :5432 │ │ :6379 │ │ (runs once) │ │
│ └──────┬───────┘ └──────┬───────┘ └────────┬─────────┘ │
│ │ │ │ │
│ │ health check │ │ waits for │
│ │ dependency │ │ postgres │
│ ▼ ▼ ▼ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ auth-server │ │
│ │ :3000 │ │
│ │ Waits for: postgres (healthy), redis (healthy), │ │
│ │ migration-runner (completed) │ │
│ └───────────────────────────┬────────────────────────────┘ │
│ │ health check dependency │
│ ▼ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ developer-portal │ │
│ │ :3001 │ │
│ │ Waits for: auth-server (healthy). Calls it server- │ │
│ │ side at http://auth-server:3000 over the network. │ │
│ └────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
  • Image: postgres:18-alpine
  • Port: 5432 (mapped to host)
  • Database: qauth
  • User: qauth
  • Features: uuidv7() native support (PostgreSQL 18+)

Connect via psql:

Terminal window
docker exec -it qauth-postgres psql -U qauth -d qauth
  • Image: redis:7-alpine (Redis 7)
  • Port: 6379 (mapped to host)

Connect via redis-cli:

Terminal window
docker exec -it qauth-redis redis-cli

A dedicated service that runs database migrations before auth-server starts.

  • Executes pnpm nx run infra-db:db:migrate
  • Exits after completion (restart: “no”)
  • Auth-server waits for this to complete successfully

Run migrations manually:

Terminal window
docker-compose run --rm migration-runner

Upgrading an existing deployment past the ADR-002 identity migration (0011)? Read the Upgrades runbook before running this — that migration is destructive and has a required backfill precondition.

The main authentication API server.

  • Port: 3000 (mapped to host)
  • Health Check: GET /health
  • Production: Dockerfile → a pnpm deploy --prod bundle, started by docker-entrypoint.sh via exec tsx src/main.ts (apps/auth-server/docker-entrypoint.sh:5).
  • Development: Dockerfile.devpnpm nx serve auth-server --watch; use with docker-compose.dev.yml and --watch.

The TanStack Start web UI for user registration, email verification, login, and OAuth consent management. It renders server-side and calls the auth-server only from its server functions — tokens never reach the browser.

  • Port: 3001 (mapped to host)
  • Health Check: a raw TCP connect check on port 3001 (docker-compose.yml, developer-portal.healthcheck) — liveness only, and does not depend on the auth-server being reachable. The Nitro build has no dedicated /healthz route, so there is nothing to curl.
  • Production: Dockerfile → runs node server/index.mjs, the self-contained server the build emits (see below).
  • Development: Dockerfile.devpnpm nx dev developer-portal (Vite dev server); use with docker-compose.dev.yml and --watch.
  • Depends on: auth-server (healthy).

The portal is built with TanStack Start’s Nitro v2 Vite plugin, which emits a self-contained, self-listening Node server at dist/apps/developer-portal/server/index.mjs (Nitro bundles its runtime dependencies into server/node_modules) plus static assets under public/. The production image’s runner stage just copies server/ and public/ and runs node server/index.mjs — no custom adapter and no separate pnpm deploy step are needed (apps/developer-portal/Dockerfile).

Build context note: the portal source is excluded from the auth-server / migration-runner build contexts by the root .dockerignore (see the build note below). The portal image lifts that exclusion for its own build via a sibling apps/developer-portal/Dockerfile.dockerignore, which BuildKit prefers over the root file when present. No action is needed — this is wired up already.

Open the portal at http://localhost:3001 once the stack is up. Set a PORTAL_SESSION_SECRET in .env first (see Environment Variables).

Production:

Terminal window
docker compose up -d --build
# Or rebuild a single service
docker compose build auth-server && docker compose up -d auth-server
docker compose build developer-portal && docker compose up -d developer-portal

Build the portal image directly (from the repo root, BuildKit on):

Terminal window
DOCKER_BUILDKIT=1 docker build -f apps/developer-portal/Dockerfile -t qauth-developer-portal .

Development: use docker compose -f docker-compose.yml -f docker-compose.dev.yml up --watch; sync/rebuild is automatic. For dependency or config changes, the dev setup will rebuild the auth-server image when those files change.

Terminal window
# All services
docker-compose logs -f
# Specific service
docker-compose logs -f auth-server
# Last 100 lines
docker-compose logs --tail=100 auth-server
Terminal window
# Stop all services (keeps data)
docker-compose stop
# Stop and remove containers (keeps volumes)
docker-compose down
# Stop and remove everything including volumes
docker-compose down -v
Terminal window
# Remove postgres volume and restart
docker-compose down -v
docker-compose up -d
Terminal window
# Auth server
docker exec -it qauth-auth-server sh
# PostgreSQL
docker exec -it qauth-postgres sh
# Redis
docker exec -it qauth-redis sh

The Compose environment: map is an allowlist. The auth-server service in docker-compose.yml configures itself through an explicit environment: map. There is no env_file:, no bind mount of the repo into the container, and the root .dockerignore excludes .env from the build context — so the container never reads .env itself. Compose reads .env only to resolve the entries the map already contains. A variable the map does not name resolves to its schema default inside the container no matter what .env says, silently.

Every variable documented on this page is on that map, so .env works for all of them. Entries take one of two forms: VAR: ${VAR:-default} restates a default in the file, while a bare VAR: is a null value that Compose resolves from .env (or the shell) and omits entirely when unset, letting the schema default apply without this file having to track it. Prefer the bare form when adding a variable — restating defaults in two places is what let them drift apart before.

See .env.docker.example for all available variables. Key variables:

VariableRequiredDescription
DB_PASSWORDYesPostgreSQL password
JWT_PRIVATE_KEYYesEdDSA private key (PEM format)
JWT_PUBLIC_KEYYesEdDSA public key (PEM format)
JWT_ISSUERNoJWT issuer URL (default: http://localhost:3000)
EMAIL_PROVIDERNoEmail provider: mock, resend, smtp (default: mock)
NODE_ENVNoproduction (default) or development for dev
LOG_LEVELNoinfo (default); use debug for development

JWT_PUBLIC_KEY is listed as Yes here, not because the schema requires it, but because omitting it fails at boot today — see the Keys guide.

VariableRequiredDefaultDescription
PORTAL_SESSION_SECRETYes32+ char secret signing the portal session cookie
PORTAL_SESSION_TTLNo900Session cookie lifetime in seconds
PORTAL_AUTH_SERVER_URLNohttp://auth-server:3000Base URL the portal uses (server-side) to reach auth-server

Generate a secret with openssl rand -hex 32. The portal will not start without PORTAL_SESSION_SECRET.

For development with docker-compose.dev.yml, set NODE_ENV=development and LOG_LEVEL=debug in .env.

CIMD is the recommended MCP client-registration mechanism (see ADR-007) — MCP Authorization 2026-07-28 says authorization servers and MCP clients SHOULD support it, and deprecates RFC 7591 dynamic registration in its favour. When a client_id is an HTTPS URL, the auth-server fetches and validates the client’s metadata document on demand instead of persisting a registration record. All settings have safe defaults — none are required to run.

VariableRequiredDefaultDescription
CIMD_ENABLEDNotrueMaster switch. When false, URL-formatted client_ids are rejected with invalid_client.
CIMD_TRUST_POLICYNoaccept-any-httpsaccept-any-https (any validating HTTPS document) or allowlist (only hosts in CIMD_TRUSTED_DOMAINS).
CIMD_TRUSTED_DOMAINSNo(empty)Comma/space-separated host allowlist for allowlist policy. A leading *. permits subdomains.
CIMD_CACHE_DEFAULT_TTLNo300Cache TTL (seconds) when the document carries no usable Cache-Control/Expires.
CIMD_CACHE_MAX_TTLNo3600Hard upper bound (seconds) on any cached document, regardless of upstream max-age.
CIMD_MAX_DOCUMENT_BYTESNo65536Maximum document size in bytes.
CIMD_FETCH_TIMEOUT_MSNo5000Per-fetch timeout in milliseconds.
CIMD_ALLOW_PRIVATE_ADDRESSESNofalseAllow fetches to non-public IPs (loopback/private/link-local). Keep false in production — it disables the SSRF guard; for dev/integration harnesses only.

Note: the CIMD_* variables are optional and default-safe, so the stack runs without them, and .env.docker.example does not list them. The auth-server environment: map does, so setting them in .env takes effect. Be deliberate about CIMD_TRUST_POLICY: it defaults to accept-any-https, which returns before CIMD_TRUSTED_DOMAINS is consulted — so populating the domain list alone changes nothing until you also set CIMD_TRUST_POLICY=allowlist.

ID-JAG / enterprise-managed authorization (ADR-011)

Section titled “ID-JAG / enterprise-managed authorization (ADR-011)”

Off by default, and doubly fail-closed: with ID_JAG_ENABLED off the jwt-bearer grant is neither advertised nor accepted, and even with it on an empty ID_JAG_TRUSTED_ISSUERS rejects every assertion. See ID-JAG.

VariableRequiredDefaultDescription
ID_JAG_ENABLEDNofalseMaster switch. Gates both the jwt-bearer grant and the discovery members that advertise it.
ID_JAG_TRUSTED_ISSUERSYes, when enabled(empty)Allowlist of enterprise IdP issuers whose assertions may be redeemed. Empty rejects everything — an assertion never nominates its own trust.
ID_JAG_MAX_ASSERTION_LIFETIMENo300Seconds. Bounds the replay window a jti is tracked for.
ID_JAG_ISSUED_LIFETIMENo300Seconds. Lifetime stamped on assertions QAuth mints.
ID_JAG_CLOCK_SKEW_LEEWAYNo60Seconds of tolerance on exp / nbf / iat.
ID_JAG_JWKS_CACHE_TTLNo300Seconds to cache a trusted issuer’s JWKS.
ID_JAG_FETCH_TIMEOUT_MSNo5000Timeout for issuer discovery / JWKS fetches.
ID_JAG_MAX_DOCUMENT_BYTESNo65536Size cap on a fetched discovery or JWKS document.
ID_JAG_ALLOW_PRIVATE_ADDRESSESNofalseSSRF guard. Leave off outside local development.

Note: none of the ID_JAG_* variables are forwarded by the auth-server environment: map, so under Compose they stay at the defaults above whatever .env says — including ID_JAG_ENABLED, which means the jwt-bearer grant cannot be switched on from .env. Add the ones you need to that map first.

Wallet sign-in is off by default. With WALLET_FEDERATION_ENABLED unset or false the wallet routes are never registered and the paths 404 — nothing below has any effect. See the wallet sign-in guide for the full flow and ADR-009 for the resolution model.

VariableRequiredDefaultDescription
WALLET_FEDERATION_ENABLEDNofalseMaster switch. When off, the wallet-login UI and the direct_post endpoint are not registered.
OID4VP_VERIFIER_PROFILEYes, when enabled(unset)oid4vp-1.0-base or haip-1.0. There is no fallback — an unset or unprovisioned profile refuses every flow. haip-1.0 awaits #377.
OID4VP_REQUESTED_VCTYes, when enabled(unset)The credential type to request. Unset means a DCQL query with no type constraint, which is refused.
OID4VP_TRUSTED_ISSUERSYes, when enabled(unset)Per-realm issuer allowlist. An issuer absent here is refused before any claim is read.
OID4VP_ISSUER_JWKSYes, when enabled(unset)The key each trusted issuer signs with.
OID4VP_SUBJECT_RESOLUTIONNo(unset)asserted-lookup (the ADR-009 default), issuer-scoped-claim, session-binding, key-thumbprint, or rp-pseudonym.
OID4VP_SUBJECT_BINDING_CLAIMSYes, when enabled(unset)Comma-separated claims the wallet binding is derived from — the entitlement check of asserted-lookup. Missing means every refusal.
OID4VP_SUBJECT_CLAIMConditional(unset)The claim carrying the subject, for issuer-scoped-claim.
OID4VP_SUBJECT_CLAIM_ISSUERSConditional(unset)Which issuers may assert OID4VP_SUBJECT_CLAIM.
OID4VP_ISSUER_ASSURANCENo(unset)Per-issuer eIDAS level of assurance, mapped to the OIDC acr claim (ADR-010). Listing an issuer here does not make it trusted.
OID4VP_STATUS_LIST_TRUST_ANCHORSNo(empty)Trust anchors for Token Status List revocation checking (or _PATH to read them from a file).
OID4VP_STATUS_LIST_URI_ALLOWLISTNo(empty)Permitted status-list URIs.
OID4VP_WALLET_INVOCATION_ENDPOINTNoopenid4vp://The wallet deep-link / QR target. Script-capable schemes (javascript:, data:) are refused at boot.

Also off by default — tokens are Ed25519-only and the JWKS is EdDSA-only unless configured. See the verifier guide.

VariableRequiredDefaultDescription
SIGNING_ALGORITHM_MODENoed25519ed25519 or ed25519+ml-dsa-65. Must be the latter for hybrid.
HYBRID_SIGNING_ENABLEDNofalseTurns on live hybrid token issuance. Enabling it without an ML-DSA key throws at startup — a half-configured deployment fails fast rather than degrading to classical-only.
JWT_MLDSA_PRIVATE_KEYYes, when hybrid is on(unset)ML-DSA-65 private key as a base64url 32-byte seed (or JWT_MLDSA_PRIVATE_KEY_PATH).
JWT_MLDSA_KIDNo(unset)Stable kid for the ML-DSA key published in the AKP JWK.
PQC_TOKEN_DELIVERYNoreferencereference keeps the bearer a small Ed25519 JWS and serves the PQC signature via introspection. self-contained requires PQC_SELF_CONTAINED_ACK.
PQC_SELF_CONTAINED_ACKConditionalfalseExplicit acknowledgement that self-contained ships a ~4.4 KB detached signature exceeding cookie and URL budgets.

Setting JWT_MLDSA_PRIVATE_KEY alone publishes the ML-DSA public key in the JWKS without issuing any hybrid token — deliberate, so verifiers can fetch the key before issuance is switched on.

Note: .env.docker.example does not list the wallet-federation or post-quantum variables. Both features are off by default, so the stack runs without them. The auth-server environment: map does list them — every wallet variable above including the four OID4VP_SUBJECT_*, and every post-quantum one (SIGNING_ALGORITHM_MODE, HYBRID_SIGNING_ENABLED, JWT_MLDSA_*, PQC_*) — so all of them can be set from .env. Note that OID4VP_SUBJECT_BINDING_CLAIMS is required once the wallet flag is on: leaving it unset refuses every sign-in rather than falling back to a default.

If ports 3000, 5432, or 6379 are in use:

Terminal window
# Check what's using the port
lsof -i :3000
# Modify port mappings in docker-compose.yml
ports:
- '3001:3000' # Map to different host port
Terminal window
# Check migration-runner logs
docker-compose logs migration-runner
# Check postgres is ready
docker-compose ps postgres
# Re-run migrations
docker-compose run --rm migration-runner

If migration-runner fails specifically on the ADR-002 migration (0011), that is very likely a required backfill step you skipped — see the Upgrades runbook, not this section.

Ensure your JWT keys in .env:

  • Include the -----BEGIN/END----- lines
  • Are properly quoted with double quotes
  • Have no extra whitespace
  • Are PKCS#8 for private keys (-----BEGIN PRIVATE KEY-----), not PKCS#1 (-----BEGIN RSA PRIVATE KEY-----) — see the Keys guide if you generated an RS256 key with openssl genrsa.
Terminal window
# Clean Docker build cache
docker builder prune
# Rebuild without cache
docker-compose build --no-cache

If the build fails on Cannot find module '@tailwindcss/vite' or a similar dev-portal-scoped import while building auth-server or migration-runner: apps/developer-portal is intentionally excluded from those build contexts via the root .dockerignore. The exclusion keeps Nx’s project-graph processor from trying to parse the portal’s configs (which import portal-scoped dev deps) during an auth-server build. This exclusion is load-bearing and should not be removed.

The developer-portal image needs its own source, so it ships a sibling apps/developer-portal/Dockerfile.dockerignore. BuildKit prefers a <dockerfile>.dockerignore over the root .dockerignore, so that file applies to the portal build only and deliberately does not exclude apps/developer-portal. Build the portal image with BuildKit enabled (default on Docker 23+) so this per-Dockerfile ignore is honored.

When running docker compose ... up --watch, Compose uses inotify. The error often means inotify limits (not disk space):

  1. Close other watchers (Nx graph, IDE, etc.):
    Terminal window
    pkill -f 'nx graph.*watch'
  2. Increase inotify instance limit:
    Terminal window
    sudo sysctl fs.inotify.max_user_instances=4096
    echo "fs.inotify.max_user_instances=4096" | sudo tee -a /etc/sysctl.d/99-inotify.conf
  3. Use watch-free dev if it still fails:
    Terminal window
    docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
    # After code changes:
    docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build auth-server
Terminal window
# Check container logs
docker logs qauth-auth-server
# Check container status
docker inspect qauth-auth-server | jq '.[0].State'

This Docker setup is designed for local development. For production:

  1. Use secrets management (Vault, AWS Secrets Manager) instead of .env files
  2. Use managed databases (RDS, Cloud SQL) instead of containerized PostgreSQL
  3. Use managed Redis (ElastiCache, Memorystore) for high availability
  4. Add reverse proxy (nginx, Traefik) with TLS termination
  5. Configure resource limits in Docker/Kubernetes
  6. Set up monitoring (Prometheus, Grafana)
  7. Enable logging aggregation (ELK, Loki)

See ADR-001: JWT Key Management for production key management strategy, and the Keys guide for how to generate each key type today.

A comprehensive test script is available:

Terminal window
./scripts/test-docker.sh

This verifies:

  • Environment configuration
  • Docker image builds
  • Service startup and health
  • Database migrations
  • API endpoint functionality
  • Data persistence
  • Keys — generating EdDSA, RS256, and ML-DSA key material, and the PKCS#8 pitfall.
  • Upgrades — the ADR-002 destructive-migration runbook; read it before running migration-runner against an existing database.
  • Observability — logging, metrics, and alerting once the stack is up.
  • Environment-Aware Authorization — the environment policy dimension referenced throughout this stack.