-
One file, services as the unit. Pin every tag, name every volume. Floating :latest makes the stack non-reproducible and breaks silently on pull; bare anonymous volumes orphan and lose data on down. Use compose.yaml (the modern name — drop the version: key, it's obsolete):
name: myapp
services:
db:
image: postgres:16.4-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql/data
- ./db/init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 3s
timeout: 3s
retries: 20
start_period: 5s
ports: ["5432:5432"]
redis:
image: redis:7.4-alpine
command: ["redis-server", "--save", "", "--appendonly", "no"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 3s
timeout: 2s
retries: 20
app:
build: { context: ., target: dev }
command: ["npm", "run", "dev"]
depends_on:
db: { condition: service_healthy }
redis: { condition: service_healthy }
environment:
DATABASE_URL: postgres://app:app@db:5432/app
REDIS_URL: redis://redis:6379
volumes:
- ./src:/app/src
- /app/node_modules
ports: ["3000:3000"]
volumes:
pgdata:
-
Order startup with depends_on: condition: service_healthy — never bare depends_on. Bare depends_on only waits for the container to start, not to be ready; the app then connects to a Postgres still replaying WAL and crash-loops. The gate is the healthcheck on each backing service. Pick the right probe per service:
| Service | Healthcheck test | Why not just TCP |
|---|
| Postgres | pg_isready -U $USER -d $DB | port opens before it accepts queries |
| MySQL | mysqladmin ping -h localhost | same early-port problem |
| Redis | redis-cli ping → PONG | trivial, do it |
| Kafka (KRaft) | kafka-broker-api-versions --bootstrap-server localhost:9092 | broker advertises before it serves metadata |
| RabbitMQ | rabbitmq-diagnostics -q ping | mgmt port lies about readiness |
| Elasticsearch | curl -fsS localhost:9200/_cluster/health?wait_for_status=yellow | green never comes single-node |
| App migrations | a one-shot migrate service the app depends_on (condition: service_completed_successfully) | keeps schema setup off the app's hot path |
Tune retries × interval ≥ real cold-start time (Kafka/ES need start_period: 20s+) or healthy never arrives and the dependents abort.
-
Seed once via docker-entrypoint-initdb.d; run migrations every start via a one-shot service. The init dir (*.sql/*.sh, alphabetical) runs only when the data volume is empty — perfect for extensions, roles, and static seed (01-schema.sql, 02-seed.sql). It does not re-run after the volume exists, so never put evolving migrations there. Migrations belong in a dedicated short-lived service the app waits on:
migrate:
build: { context: ., target: dev }
command: ["npm", "run", "migrate:deploy"]
depends_on: { db: { condition: service_healthy } }
restart: "no"
Then set app.depends_on.migrate.condition: service_completed_successfully. Idempotent migration tools make this safe to run on every up.
-
Hot reload = bind mount source + a dev command + a watcher, not a rebuild. Bind ./src:/app/src and run the dev server (npm run dev/uvicorn --reload/air/nodemon). Mask installed deps with an anonymous volume (- /app/node_modules) so the host's empty/mismatched dir doesn't shadow the image's. Build the image from a dev stage (target: dev) that includes dev deps and the watcher — keep the lean prod stage for shipping (that's dockerfile-optimize's job). Changing package.json/requirements.txt still needs a rebuild; code does not.
-
Split config: committed compose.yaml + .env + an uncommitted compose.override.yml. Compose auto-merges compose.override.yml on top of compose.yaml with no -f flag — put local-only tweaks there (extra port bindings, mounted debug tools, DEBUG=1) and gitignore it so teammates' hacks don't collide. Variables interpolate from .env (committed .env.example, real .env gitignored). Never hardcode host-specific ports or paths in the base file.
-
Gate optional services behind profiles. Tag heavy/rarely-needed services (Kafka, a second DB, mailhog, a metrics stack) with profiles: ["kafka"] so a plain docker compose up starts only the core stack. Opt in with docker compose --profile kafka up. Keeps the default path fast; a service with no profiles always runs.
-
Use the default network and talk service-to-service by name; publish only the host ports you need. Compose gives you a default bridge network where services resolve each other by service name (db, redis) — the app must use db:5432, never localhost:5432 (localhost inside the app container is the app). Publish stable host ports (5432:5432) only for tools you run on the host (psql, a GUI). Collisions with a host Postgres → remap the host side (5433:5432), never the container side.
-
Make one-command verbs in a Makefile (or Taskfile.yml) so nobody memorizes flags. up must block until healthy; reset must wipe volumes:
up: ## start core stack, wait until healthy
docker compose up -d --wait
down: ## stop, keep data
docker compose down
reset: ## stop AND wipe volumes -> clean slate
docker compose down -v --remove-orphans
docker compose up -d --wait
logs: ## tail everything
docker compose logs -f --tail=100
ps:
docker compose ps
--wait makes up exit non-zero if any service never goes healthy — that's your machine-checkable gate. down -v is the only thing that deletes data; keep it on reset alone so down is always safe.