Docker

Purpose and document map

This guide owns container images, Compose services, runtime addressing, initialization, and packaged-system validation. Begin with the numbered sections for the stack model and commands. The security-sensitive configuration notes below explain why the canonical local stack differs from a hosted deployment without turning those local settings into production defaults.

General setup belongs in development.md, architectural responsibilities in architecture.md, and CI scheduling in ci.md.

Security-sensitive local configuration

The backend Compose service forwards only server-side mail settings and the local checkout mode. Mail defaults to disabled; local validation may explicitly set MAIL_MODE=mock, MAIL_FROM, and synthetic MAIL_TEST_RECIPIENTS. Mocking Bird is enabled by default (MOCKING_BIRD_ENABLED defaults to true) and checkout defaults to CHECKOUT_MODE=mocking-bird, so a plain docker compose up runs the local Mocking Bird adapter without any provider credential. No mail key or provider credential is built into an image, and production-mode Node builds retain the explicit local deployment classification in Compose.

Compose supplies browser-visible APP_PUBLIC_URL=http://localhost:${GATEWAY_PORT:-8080} so recovery links never contain backend, frontend, or another Docker-only hostname. Canonical validation adds fixed desktop/mobile recovery recipients to the synthetic preview allowlist. The frontend nginx serves /reset-password with Cache-Control: no-store and Referrer-Policy: no-referrer; the credential fragment is browser-only and never reaches nginx or the gateway.

VITE_DEPLOYMENT_ENV and VITE_ENABLE_DEVELOPER_TOOLS are frontend image build arguments, not runtime nginx settings. The local Compose defaults (local and true) always include the Mocking Bird page; hosted and unclassified production builds omit it. The canonical pnpm validate:docker command also builds the page, but explicitly overrides checkout to the deterministic fake and starts the backend with distinct synthetic desktop/mobile console and recovery fixture recipients. Changing the environment of an already-built frontend container cannot add the page.

The packaged backend still uses NODE_ENV=production, while Compose explicitly sets DEPLOYMENT_ENV=local. This distinction permits the canonical HTTP gateway to receive non-Secure cookies without weakening hosted staging/production. Compose validates mutations against the public gateway origin and narrowly trusts its private Docker network for forwarded client details. An omitted local signing secret is generated at each backend start, so restart logout is expected. The Docker E2E authentication fixtures provision distinct synthetic desktop/mobile users through the guarded user tooling after migration/seed and delete them after each test; the global setup only preflights the gateway. Backend startup and production message seeding never create users.

The backend Compose service also forwards the local-only CHECKOUT_MODE and CHECKOUT_FAKE_SCENARIO settings, defaulting to mocking-bird/succeeded, plus MOCKING_BIRD_ENABLED which defaults to true, and the optional CHECKOUT_FAKE_REJECT_AMOUNT_MINOR / CHECKOUT_FAKE_CANCEL_AMOUNT_MINOR / CHECKOUT_FAKE_UNKNOWN_AMOUNT_MINOR amounts, which default to unset. They change which checkout adapter is composed and nothing else.

1. Purpose

docker-compose.yml is the canonical local full-system environment. Host Node/Vite servers are an optional faster development loop using the same sources and database mechanisms. Images build from repository sources and the frozen lockfile, not host node_modules, dist, or environment files. Prerequisites and host commands belong in development.md.

2. Current state

The stack contains six services on the explicit app bridge network. With the default Compose project name, the network is foodtruck-order_app; an overridden project name changes that prefix. Do not use the obsolete _default network.

Browser -> gateway:80 -> frontend:80 for / and static assets
                     -> backend:3000 for /api/* (path preserved)
Backend -> postgres:5432
Postgres healthy -> migrate completes -> seed completes -> backend starts

Only gateway port 8080 and PostgreSQL debugging port 5432 are published by default. Service names are resolved by server-side containers, never by browser JavaScript. /health is a backend liveness route; the gateway proxies only /api/, so a request to gateway /health reaches the frontend SPA fallback instead of backend health.

Compose sets the local-only authentication throttle maximum to 400 because the complete desktop/mobile browser suite signs in isolated accounts from one host IP and therefore shares a single per-IP authentication/CSRF budget per window. Each authenticated test acquires CSRF twice — before sign-in and again after the post-login session rotation — and reload/recovery flows acquire more, so the complete suite already measures over 200 acquisitions and exhausted the previous 200 budget, failing its last mobile tests with RATE_LIMITED. Backend HTTP tests retain coverage of the production-shaped default of 10; hosted deployments do not inherit the Compose override. The dev-mode E2E stack declares the same value, and tests/ci/docker-validation.test.mjs keeps both maxima in step and above the suite's demand.

3. Backend and database tooling images

3.1 Backend image and configuration

backend/Dockerfile builds db/backend on node:24-alpine, then uses pnpm --filter @foodtruck-order/backend --prod deploy --legacy /deploy to assemble production dependencies, including the compiled db workspace. The runtime starts node dist/server.js as the unprivileged Node user. It excludes application source, tests, development tooling, and .env files.

Configuration comes from the environment: DATABASE_URL is required; NODE_ENV defaults to production in the image; HOST and PORT default to 0.0.0.0 and 3000. Optional CORS_ALLOWED_ORIGINS follows the development guide's exact-origin contract. LOG_LEVEL defaults to info in production and Compose explicitly uses that JSON stdout level. The image starts the backend only; it does not migrate or seed.

3.2 Service addressing

Compose derives container DATABASE_URL from POSTGRES_* with hostname postgres. Host tooling instead connects to the published host port. Changing the Compose PostgreSQL port/credentials requires a matching host DATABASE_URL for tests/tooling.

3.3 Individual image builds

Run from the repository root; all four build contexts are the root:

docker build --file backend/Dockerfile --tag foodtruck-order-backend .
docker build --file frontend/Dockerfile --tag foodtruck-order-frontend .
docker build --file packages/db/Dockerfile --tag foodtruck-order-db .
docker build --file docker/Dockerfile.gateway --tag foodtruck-order-gateway .

Use Compose to run the images together; this avoids manual network and database initialization assumptions. docker compose run --rm --no-deps backend can run a backend command against an already initialized stack with Compose configuration.

3.4 Initialization boundary

Journaled migration and deterministic seed responsibility stays in database tooling. Compose runs them before backend startup. Host tooling uses pnpm db:migrate and pnpm db:seed for its local loop. Reapplying migrations skips applied entries; seeding upserts id 1 and restores the known seed text. Staging migrations run as a separate CI release step; staging seeding is manual, never a startup requirement.

3.5 Database tooling image

packages/db/Dockerfile contains the compiled db package, Drizzle configuration, committed SQL/journal, drizzle-kit, and tsx. Dev dependencies are intentional in this tooling image. The entrypoint waits for the configured database to accept SELECT 1 through Drizzle/postgres.js (one-second polling, 120-second readiness budget), then runs migrate or seed. Connection operations may themselves take time; the budget is not an independent cancellation of each driver connection.

PostgreSQL's TCP pg_isready healthcheck gates migrate. seed waits for successful migration, and backend waits for successful seeding. Both tooling services are one-off containers with restart: "no"; successful completion is Exited (0). To rebuild and run them individually against local data:

docker compose up --build migrate
docker compose up --build seed

4. Frontend image and browser configuration

The frontend multi-stage image builds with Node and serves only static output using nginx:1.29-alpine. The nginx master binds port 80 as root; workers use the nginx user. The entrypoint writes runtime configuration before starting nginx. API proxying belongs to the gateway, not the frontend server.

The build context includes the root manifest so Vite reads the application release version. The optional APP_BUILD_SHA build argument is passed explicitly by Compose and accepted by the Dockerfile; it is public display metadata, not runtime API configuration. Docker builds never inspect .git. With the argument absent, the drawer displays the version alone.

4.1 API URL precedence

The browser resolves its API base URL in this order:

  1. Non-empty runtime window.FOODTRUCK_ORDER_API_BASE_URL, written to /config/app-config.js from container API_BASE_URL at startup.
  2. Build-time VITE_API_BASE_URL for a host Vite build.
  3. Relative /api paths on the page's own origin.

API_BASE_URL must be reachable by the browser, not a Docker service name. The committed config is a no-op; nginx sends Cache-Control: no-cache for it. The current Dockerfile has no build argument for VITE_API_BASE_URL and excludes .env files. Configure an existing Docker image with runtime API_BASE_URL; do not assume a host build variable is passed into the Docker build. APP_BUILD_SHA is unrelated to this precedence and cannot alter the API URL.

4.2 Same-origin Compose model

Compose sets no frontend API override, so gateway routing supplies the default same-origin model without CORS. For different browser/frontend and backend origins, configure a public API URL and backend CORS_ALLOWED_ORIGINS as documented in development §6.1.

4.3 Static serving

frontend/nginx.conf provides SPA fallback for client-side routes, immutable caching for hashed /assets/ files, and no-cache runtime config. A standalone frontend container does not supply the backend API by itself.

6. Compose application stack

6.1 Services

Long-running services use restart: unless-stopped. Frontend/gateway depend on service_started, not an application healthcheck. There are no embedded backend/ frontend image HEALTHCHECK instructions. The E2E workflow verifies actual readiness and communication after Compose startup.

6.2 Readiness

Use Compose dependencies and up --wait, then application test assertions. Do not substitute fixed sleeps. A backend process can start while its database path is failing: backend health is liveness only; /api/message additionally exercises the seeded database path.

6.3 Environment

Defaults in docker-compose.yml are local examples, not production credentials: POSTGRES_DB=foodtruck_order, POSTGRES_USER=foodtruck, POSTGRES_PASSWORD=foodtruck, POSTGRES_PORT=5432, and GATEWAY_PORT=8080. Compose constructs its own container connection URL. The root .env.example supplies matching values for host tooling; images do not contain that file. Compose also starts with defaults without .env.

6.4 Startup and shutdown

pnpm dev:start
# Open http://localhost:8080/
docker compose ps -a
docker compose logs migrate seed backend gateway
pnpm dev:stop

pnpm dev:start and pnpm dev:stop are the canonical way to run the application locally. Both pin COMPOSE_PROJECT_NAME=foodtruck-order, so they always address the same postgres-data volume regardless of the checkout directory name or an inherited COMPOSE_PROJECT_NAME. Neither removes a volume: users, products, and orders created through the local stack survive any number of restarts.

down preserves the database volume in general. pnpm db:reset destroys that volume, rebuilds, and restarts the stack; its name states that consequence. Use it only when intentionally discarding local data. Migrations/seed containers are recreated on subsequent normal startup; do not assume a changed tooling source is rebuilt without --build.

The Node process handles SIGTERM and SIGINT once, closes Fastify, and lets the existing onClose hook release PostgreSQL. Shutdown failures emit only a safe structured event and set a failing exit code. Docker validation stops the backend with Compose's SIGTERM path, requires exit code 0, restarts it, and then runs the browser journey against the retained database.

6.6 Clean-environment verification

The automated workflow below is the normal proof of clean-volume initialization. For an image-cache-specific defect, docker compose build --no-cache is an optional targeted diagnostic. There is no requirement to delete all images manually or repeat schema queries, curls, screenshots, and both E2E modes after a passing baseline.

6.7 Canonical workflow versus host development

Compose is authoritative for complete-system behavior. Host pnpm dev and pnpm dev:backend provide hot reload using standalone Compose PostgreSQL and the same migrations/seed. They do not define another architecture.

6.8 Automated validation

pnpm validate:docker

The root script runs, failing on the first non-zero exit:

docker compose down -v --remove-orphans
docker compose up --build -d --wait --wait-timeout 300
pnpm test:integration
pnpm --filter @foodtruck-order/db build
node tests/docker/backend-lifecycle.mjs
pnpm e2e:docker

It builds images, initializes a fresh local database, verifies repository/API integration on host tooling against PostgreSQL, and drives Chromium through the gateway and real frontend/backend/database. node tests/docker/backend-lifecycle.mjs is a data-bearing process-restart proof: it finalizes real orders through the packaged gateway, stops the backend and requires a clean SIGTERM exit, starts it again as a different process, and re-reads the active queue, today's commercial figures, and every durable fact row to show that a restart neither resets nor duplicates anything. The preceding host-side database workspace build is required on a fresh checkout because that proof provisions its operator through backend tooling which imports the package's dist entry point. Startup/migration/seed failure, missing readiness, database connection failure, or an integration/restart/E2E assertion fails the command. Docker E2E builds the shared database workspace before Playwright because its host-side fixtures import that package; this keeps the command valid after a fresh dependency install with no pre-existing dist. Fast tests are separate; do not repeat integration/E2E immediately afterwards.

Validation never touches the development database. scripts/docker-validation.mjs runs every step in a dedicated Compose project, foodtruck-order-validate, on its own published ports (55432 and 18080 by default), and points the host-side DATABASE_URL and E2E_GATEWAY_URL at that same isolated stack. The clean-volume step therefore deletes only the throwaway validation volume, and the data created by pnpm dev:start is left intact even while validation runs. The script refuses to start if the validation project or either validation port would collide with the development ones. The validated stack is left running for inspection; remove it with:

pnpm validate:docker:stop

Prerequisites: Docker/Compose, Node/pnpm dependencies, Chromium/system dependencies, and free host ports for the isolated stack. Credentials and database name come from the root .env when present, so validation matches the locally configured instance without editing it. The development gateway stays on 8080; validation uses its own E2E_GATEWAY_URL automatically. Do not point reset or validation tooling at staging or production.

6.9 Dockerfile lint (Hadolint)

pnpm containers:lint runs pinned Hadolint v2.12.0 across the backend, frontend, database tooling, and gateway Dockerfiles. The backend consolidates its two workspace builds into one RUN layer (DL3059); base tags stay deliberately mutable (node:24-alpine, nginx:1.29-alpine) and generic image-digest advice is not applied. Packaging behavior — Node 24/pnpm frozen install, workspace deploy, user, entrypoint, and runtime config — is preserved; run pnpm validate:docker when Dockerfiles change.

7. Deferred production infrastructure

The repository contains local Compose and the staging release contract, not a production Compose/VPS deployment. Caddy, HTTPS termination in Compose, registry publishing, automated application deployment, backups/restore, monitoring, and rollback remain separately scoped work. Documentation publishing to Cloudflare and staging migrations are already implemented and are not deferred features.