Docker: every compose file and Dockerfile
What each compose file and Dockerfile in this repo does, and how the containers talk to each other. Runtime failures (Flyway errors, restart loops, stale networks) live in ../troubleshooting.md; this page is the reference for what should happen.
1. How the pieces talk
One request path, in every deployment shape:
-
The browser only ever talks to nginx. The frontend container serves the pre-built React bundle and reverse-proxies
/api/and/wsto the backend over the internal Docker network (frontend/nginx.conf). Nothing else is proxied — Swagger UI and/actuatorare not reachable through nginx. -
VITE_API_URLdefaults to empty, which makes the frontend use relative URLs (/api/...). Every request stays same-origin through the nginx proxy, so CORS never fires in the dockerized stack. CORS only matters when you run the Vite dev server on the host (a different origin) — then your origin must be inCORS_ALLOWED_ORIGINS(../troubleshooting.md). -
Internal network: the dev stack's five containers share one bridge network,
motorph_payroll_network, and address each other by service name (motorph_payroll_backend:8080,motorph_payroll_db:5432,motorph_payroll_mailpit:1025). -
Startup order is healthcheck-gated, not hope-based:
db ──(pg_isready healthy)──┬─▶ pgadmin└─▶ backend ──(HTTP healthy, ~90s on first boot)──▶ frontendmailpit ──(started)─────────────▶ backendThe backend's healthcheck has a
start_periodof 90 seconds because first boot runs all Flyway migrations (V1→V88) before the HTTP port answers. The frontend usesdepends_on: condition: service_healthy, so nginx does not start until the backend is actually serving.
2. docker-compose.yml — local dev
File: docker-compose.yml. The everyday stack:
cp .env.example .env
sed -i "s/^JWT_SECRET=.*/JWT_SECRET=$(openssl rand -hex 64)/" .env
docker compose up -d --build
(The JWT_SECRET step is mandatory — the backend refuses to start with the
placeholder, a blank value, or anything under 32 characters;
JwtProperties.validateSecret.)
| Service | Image | Host port → container | Healthcheck |
|---|---|---|---|
motorph_payroll_db | postgres:16 | ${POSTGRES_PORT:-5434} → 5432 | pg_isready every 10s |
motorph_payroll_pgadmin | dpage/pgadmin4:9.16 | 127.0.0.1:${PGADMIN_PORT:-5050} → 80 | none |
motorph_payroll_mailpit | axllent/mailpit:v1.30 | 127.0.0.1:${MAILPIT_UI_PORT:-8025} → 8025 | built into the image |
motorph_payroll_backend | built from backend/Dockerfile | ${BACKEND_PORT:-8081} → 8080 | wget against :8080 every 15s, start_period: 90s |
motorph_payroll_frontend | built from frontend/Dockerfile | ${FRONTEND_PORT:-5173} → 80 | none (waits for backend healthy) |
pgAdmin (../backend/pgadmin.md) and Mailpit
(../backend/email.md) are development tools, bound to
loopback because neither has any authentication: pgAdmin runs with no login
screen, and every message Mailpit holds contains a one-time password. Neither
appears in docker-compose.client.template.yml.
Key environment (all overridable via .env; the full annotated list is
.env.example):
SWAGGER_ENABLEDdefaults true (dev convenience; production template forces it off).ALTCHA_ENABLEDdefaults false — required for the e2e suite, which posts credentials straight to/api/auth/login.CORS_ALLOWED_ORIGINSdefaults tohttp://localhost:5173— only relevant for host-dev, see above.- Billing defaults to the
stubprovider so a fresh clone boots with no payment secrets.
Postgres data persists in the named volume motorph_payroll_db_data
(docker compose down --volumes wipes it).
3. docker-compose.demo.yml — isolated demo sandbox
File: docker-compose.demo.yml. A second,
fully independent stack for showing the product with rich data — it never
touches the dev stack's database or ports:
docker compose -f docker-compose.demo.yml up -d --build
# app: http://localhost:5174
Differences from dev, service by service:
motorph_demo_db— own container and volume (motorph_demo_db_data), databasemotorph_demo, host port 5435.motorph_demo_backend— host port 8082. Two deliberate changes:ALTCHA_ENABLEDdefaults true with a publicly-known demo HMAC key — the CAPTCHA widget is part of the showcase. (The compose file explains why a public key is acceptable here: forging it only bypasses the demo's own CAPTCHA. Real deployments get per-client keys.)SPRING_FLYWAY_LOCATIONS: classpath:db/migration,filesystem:/demo-seedplus a read-only mount ofdemo/seed/at/demo-seed. Flyway therefore applies the application migrations and the rich-seed migrationsV20__rich_seed_employees.sqlthroughV24__rich_seed_recruitment.sqlin one versioned sequence. Accounts and data this seeds: ../reference/demo-rich-data.md. Because the seed files occupy versions V20–V24, an application migration may never reuse those numbers — the collision failure mode is in ../troubleshooting.md.
motorph_demo_frontend— host port 5174; bind-mountsdemo/nginx.demo.confover the image's default nginx config so/api/proxies tomotorph_demo_backendinstead of the dev backend.
Note the demo stack is looser than dev on purpose: no custom network name
(compose default), no backend healthcheck, and the frontend's depends_on
is not health-gated — first load right after up may briefly error while
Flyway runs.
4. docker-compose.monitoring.yml — observability overlay
File: docker-compose.monitoring.yml.
Not a standalone stack — an overlay merged onto a base compose with a second
-f flag:
docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up -d
Twelve containers covering all four pillars: Prometheus + Alertmanager
(metrics and alerting), Loki + Promtail (logs), Tempo + the OpenTelemetry
Collector (traces), Grafana (dashboards), and five exporters — node, cAdvisor,
PostgreSQL, blackbox, nginx. All join motorph_payroll_network, and every UI
binds to ${MONITORING_BIND:-127.0.0.1} because Docker's published ports bypass
UFW.
It layers cleanly onto dev, staging and production — the overlay declares no
application service, precisely so that a stanza naming the dev/prod backend
can't appear in the staging project as a service with no image. On the VPS,
WITH_MONITORING=1 deploy/deploy.sh <env> deploy <tag> does the layering.
Full walkthrough, dashboards, alert catalogue and metric reference: monitoring.md.
5. docker-compose.client.template.yml — per-client production
File: docker-compose.client.template.yml.
The production shape: one isolated stack per client, instantiated by
scripts/onboard-client.sh with
-p <slug> and a generated .env.<slug> file. Never run it by hand without
an env file — every ${...} in it is required.
What makes it different from the dev compose:
- No host ports at all. The only path in is Traefik on the shared
edgenetwork.dbandbackendsit on a private per-client bridge network (<slug>_internal); onlyfrontendjoins bothinternalandedge. - Traefik labels on the frontend advertise the route:
traefik.enable=true, ruleHost(`<slug>.<domain>`), entrypointweb, service port 80. Traefik discovers new client stacks automatically — no edge-config edits per client. - Hardened switches:
SWAGGER_ENABLED: "false"(the OpenAPI surface stays off client deployments) andALTCHA_ENABLED: "true"with a per-clientALTCHA_HMAC_KEYand no default — the backend fail-fasts at boot if the key is missing rather than silently running without CAPTCHA. - Billing has no defaults — production must state its provider explicitly instead of falling back to the stub.
- Per-client nginx config: the frontend bind-mounts
nginx-confs/<slug>.conf(generated frominfra/nginx-client.conf.template) so/api/proxies to that client's own<slug>_backend. - Same healthchecks as dev, including the 90s backend
start_period; the named volume is<slug>_db_data.
Operating this shape end to end (onboarding, upgrades, backups, removal): vps-guide.md.
6. infra/traefik/docker-compose.yml — the shared edge
File: infra/traefik/docker-compose.yml.
One Traefik v3.3 container, started once per host, stays up forever:
docker compose -f infra/traefik/docker-compose.yml up -d
Line by line:
--providers.docker=true+ a read-only mount of/var/run/docker.sock— Traefik watches the Docker API for labeled containers.--providers.docker.exposedbydefault=false— containers are not routed unless they opt in withtraefik.enable=true. This is the guardrail that keeps databases and backends unreachable even if someone attaches them to the wrong network.--providers.docker.network=edge— Traefik always dials targets via the sharededgenetwork (which this file creates), never a client's internal one.--entrypoints.web.address=:80andports: "80:80"— HTTP only. There is no:443entrypoint; TLS terminates at Cloudflare in front of the VPS (vps-guide.md).DOCKER_API_VERSION=1.44— pins the Docker API version Traefik's client negotiates. Newer Docker Engine releases raised the minimum accepted API version, and Traefik's bundled client could fail withclient version 1.24 is too old; the pin makes the handshake deterministic.
7. backend/Dockerfile — multi-stage layered jar
File: backend/Dockerfile.
- Build stage (
maven:3.9-eclipse-temurin-21):dependency:go-offlineruns againstpom.xmlalone (with a/root/.m2cache mount), so dependency downloads are cached independently of source changes; thenmvn package -DskipTestsandjava -Djarmode=tools -jar target/*.jar extract --layerssplits the boot jar into layers. - Runtime stage (
eclipse-temurin:21-jre-alpine): copies the four layers in stability order —dependencies,spring-boot-loader,snapshot-dependencies,application— so a code-only change invalidates only the last, small layer. Runs as a non-rootspring:springuser. - Entrypoint:
java -XX:MaxRAMPercentage=75 -jar *.jar— the heap is sized as 75% of the container's memory limit rather than a hardcoded-Xmx, which is what lets the same image serve differently-sized deployments. - Note
-DskipTests: tests are never run inside the image build. Runmvn testinbackend/yourself (ci.md).
8. frontend/Dockerfile — static build behind nginx
File: frontend/Dockerfile.
- Build stage (
node:24-alpine):npm ci, thennpm run buildwithARG VITE_API_URL=— empty by default, baked into the bundle at build time, producing relative API URLs (see §1). - Runtime stage (
nginx:1.27-alpine): copiesdist/andfrontend/nginx.conf(the serve + proxy config, including the security headers and ALTCHA-compatible CSP).
Consequence: the frontend image is a frozen snapshot. Any frontend
change — code, or a different VITE_API_URL — requires an image rebuild
(docker compose up -d --build motorph_payroll_frontend). There is no hot
reload in Docker; for iteration use the Vite dev-server loop
(../onboarding.md).
9. dev.sh — hybrid dev loop
File: dev.sh. For working on the backend with instant
restarts instead of image rebuilds:
./dev.sh
It does exactly three things: starts only the database container
(docker compose up -d motorph_payroll_db), polls pg_isready until the DB
answers, then runs cd backend && mvn spring-boot:run — the backend runs
on the host at http://localhost:8080, connecting to the dockerized
Postgres on localhost:5434. Pair it with npm run dev in frontend/ for
a full host-dev loop — and remember this is the one setup where CORS is real
(§1).
10. docker-compose.override.yml — the silent local shim
If compose behaves differently than the files in git suggest, check for a
docker-compose.override.yml in the repo root. It is gitignored, and
Docker Compose merges it automatically into every plain
docker compose ... invocation — no flag, no output, nothing in git status. It's used locally for per-machine tweaks (e.g. Flyway ignore
patterns while parallel branches share the dev DB). To see what the backend
actually receives:
docker compose config | grep -i flyway
Full failure story: ../troubleshooting.md.
11. Common commands
# Bring the dev stack up (build if needed)
docker compose up -d --build
# Stop containers, keep data
docker compose down
# Full reset — containers AND data volumes
docker compose down --volumes
# Status + health of every service
docker compose ps
# Tail logs (all / one service)
docker compose logs -f
docker compose logs -f motorph_payroll_backend
# Rebuild just the frontend after a change
docker compose up -d --build motorph_payroll_frontend
# psql into the dev database
docker compose exec motorph_payroll_db psql -U motorph -d motorph
# Show the fully-merged effective compose config (catches override shims)
docker compose config
More recovery-oriented commands (Flyway history, network resets, health inspection): ../troubleshooting.md.