Skip to main content

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 /ws to the backend over the internal Docker network (frontend/nginx.conf). Nothing else is proxied — Swagger UI and /actuator are not reachable through nginx.

  • VITE_API_URL defaults 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 in CORS_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)──▶ frontend
    mailpit ──(started)─────────────▶ backend

    The backend's healthcheck has a start_period of 90 seconds because first boot runs all Flyway migrations (V1→V88) before the HTTP port answers. The frontend uses depends_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.)

ServiceImageHost port → containerHealthcheck
motorph_payroll_dbpostgres:16${POSTGRES_PORT:-5434} → 5432pg_isready every 10s
motorph_payroll_pgadmindpage/pgadmin4:9.16127.0.0.1:${PGADMIN_PORT:-5050} → 80none
motorph_payroll_mailpitaxllent/mailpit:v1.30127.0.0.1:${MAILPIT_UI_PORT:-8025} → 8025built into the image
motorph_payroll_backendbuilt from backend/Dockerfile${BACKEND_PORT:-8081} → 8080wget against :8080 every 15s, start_period: 90s
motorph_payroll_frontendbuilt from frontend/Dockerfile${FRONTEND_PORT:-5173} → 80none (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_ENABLED defaults true (dev convenience; production template forces it off).
  • ALTCHA_ENABLED defaults false — required for the e2e suite, which posts credentials straight to /api/auth/login.
  • CORS_ALLOWED_ORIGINS defaults to http://localhost:5173 — only relevant for host-dev, see above.
  • Billing defaults to the stub provider 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), database motorph_demo, host port 5435.
  • motorph_demo_backend — host port 8082. Two deliberate changes:
    • ALTCHA_ENABLED defaults 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-seed plus a read-only mount of demo/seed/ at /demo-seed. Flyway therefore applies the application migrations and the rich-seed migrations V20__rich_seed_employees.sql through V24__rich_seed_recruitment.sql in 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-mounts demo/nginx.demo.conf over the image's default nginx config so /api/ proxies to motorph_demo_backend instead 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 edge network. db and backend sit on a private per-client bridge network (<slug>_internal); only frontend joins both internal and edge.
  • Traefik labels on the frontend advertise the route: traefik.enable=true, rule Host(`<slug>.<domain>`), entrypoint web, 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) and ALTCHA_ENABLED: "true" with a per-client ALTCHA_HMAC_KEY and 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 from infra/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 with traefik.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 shared edge network (which this file creates), never a client's internal one.
  • --entrypoints.web.address=:80 and ports: "80:80"HTTP only. There is no :443 entrypoint; 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 with client 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-offline runs against pom.xml alone (with a /root/.m2 cache mount), so dependency downloads are cached independently of source changes; then mvn package -DskipTests and java -Djarmode=tools -jar target/*.jar extract --layers splits 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-root spring:spring user.
  • 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. Run mvn test in backend/ yourself (ci.md).

8. frontend/Dockerfile — static build behind nginx

File: frontend/Dockerfile.

  • Build stage (node:24-alpine): npm ci, then npm run build with ARG 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): copies dist/ and frontend/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.