Docker Compose conventions
Apply these conventions when creating or modifying docker-compose.yml.
Principles
.env(git-ignored) +.env.example(checked in, with doc comments).
All secrets and environment-specific values live in.env. The example file documents each variable.No root.
Every service that doesn't need root getsuser: "${UID:-1000}:${GID:-1000}".- Dockerfiles must make the working directory group-writable (
RUN chmod g+w .). - Postgres is exempt — its official image handles user switching.
- Dockerfiles must make the working directory group-writable (
No port forwarding in the base compose file.
ports:only appear in override files. The base file exposes nothing to the host.Override file (example only).
Writedocker-compose.override-example.ymlwith documented, commented-out options.
Users copy it todocker-compose.override.yml(git-ignored) for their local customisations.
Never writedocker-compose.override.ymlinto the project — only the example.Lightweight images.
Prefer-alpineor-slimvariants.Named project.
Setname:at the top so volumes and containers are predictable. Or use COMPOSE_PROJECT_NAME env var.Mandatory env vars.
Use${VAR:?required}so compose errors immediately when a required variable is missing.Short service names.
pg,bun,ml,py,sk(SvelteKit),pb(PocketBase), etc. If there's a "primary" app, their service should be namedapp.Restart policy.
restart: unless-stoppedfor long-lived services,on-failure:Nfor batch jobs. Never usealways(it reanimates after intentionaldocker compose stop).Read-only root.
read_only: trueon every service that doesn't need to write to its own filesystem. Mounttmpfsfor paths that must be writable (/tmp,/run). Combine withtmpfsfor the writable paths the app actually needs.Log rotation.
Every service getslogging.driver: json-filewithmax-size: 10mandmax-file: 3. Prevents disk fills.Pin images.
Never:latest— use explicit version tags or digests. Reproducible builds.
Patterns
Base compose
name: prj
services:
app:
build: .
image: prj-app
user: "${UID:-1000}:${GID:-1000}"
environment:
DB_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@pg:5432/${POSTGRES_DB}
depends_on:
pg:
condition: service_healthy
pg:
image: postgres:17-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:?required}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?required}
POSTGRES_DB: ${POSTGRES_DB:?required}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pgdata:
Override example
# docker-compose.override.yml
services:
pg:
ports:
- "127.0.0.1:5432:5432"
app:
ports:
- "127.0.0.1:8000:8000"
volumes:
- ./src:/app/src:ro
Dockerfile — writable working dir
WORKDIR /app
RUN chmod g+w .
.env.example
# ── Section ────────────────────────────────────────────────────────
# Description of what this is for.
VAR_NAME=default-value
Production checklist
Before deploying, verify:
-
restart: unless-stoppedon every long-lived service -
deploy.resources.limits.memory+cpusset per service - Health checks on all services with
start_period -
depends_onusescondition: service_healthywhere needed -
read_only: true+tmpfsfor writable paths -
user:set to non-root on every non-Postgres service - No
:latest— all images pinned to versions or digests - Log rotation configured (
max-size/max-file) -
.envin.gitignore,.env.examplechecked in -
name:set at top of compose file - No
ports:in base compose (use override files) -
cap_drop: ALL+ specificcap_add+no-new-privileges:true - Backend/internal networks use
internal: true