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:
- Non-empty runtime
window.FOODTRUCK_ORDER_API_BASE_URL, written to/config/app-config.jsfrom containerAPI_BASE_URLat startup. - Build-time
VITE_API_BASE_URLfor a host Vite build. - Relative
/apipaths 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
postgres:postgres:17.5-alpine, namedpostgres-datavolume, healthcheck.migrateandseed: shared database tooling image, one-off commands.backend: production Node server, no host port publication.frontend: nginx/static SPA, no host port publication.gateway: local nginx proxy and browser entry point.
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.