DEV Community

Cover image for The Only docker-compose.yml Pattern You Need (2026 Edition)
yobox
yobox

Posted on • Originally published at yobox.dev

The Only docker-compose.yml Pattern You Need (2026 Edition)

There are two kinds of docker-compose.yml files in the world: the one a developer writes in twenty minutes that works for exactly one machine, and the one a team refactors fourteen times before settling on a pattern that survives both local dev and CI. This article gives you the second one, fully formed, with the reasoning behind each section.

The YoBox Docker Builder scaffolds it; this guide explains why the lines exist.

The five jobs a good compose file does

Bring the app up locally with a single command.
Reproduce CI exactly, no surprises.
Compose nicely with sidecars (databases, caches, e2e runners).
Survive partial failures with health checks and explicit dependencies.
Stay readable when a new engineer opens it on day one.

The pattern

name: myapp

x-defaults: &defaults
restart: unless-stopped
init: true

services:
db:
<<: *defaults
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app
volumes: ["db-data:/var/lib/postgresql/data"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 5s
timeout: 3s
retries: 20

redis:
<<: *defaults
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10

api:
<<: *defaults
build: ./api
environment:
DATABASE_URL: postgres://app:app@db:5432/app
REDIS_URL: redis://redis:6379
YOBOX: https://yobox.dev/api
depends_on:
db: { condition: service_healthy }
redis: { condition: service_healthy }
ports: ["3000:3000"]
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
interval: 5s
timeout: 3s
retries: 30

web:
<<: *defaults
build: ./web
environment:
API_URL: http://api:3000
depends_on:
api: { condition: service_healthy }
ports: ["8080:80"]

e2e:
profiles: ["test"]
build: ./tests
environment:
BASE_URL: http://web:80
YOBOX: https://yobox.dev/api
depends_on:
web: { condition: service_started }
api: { condition: service_healthy }

volumes:
db-data:
Enter fullscreen mode Exit fullscreen mode

That's it. Eighty lines that handle local dev, CI e2e, sidecar databases, health-checked dependencies, and a profile gate so docker compose up doesn't pull in the test runner unless you ask.

Why each section matters

x-defaults anchor
YAML anchors are the single most underused feature of compose. They eliminate the restart / init boilerplate that otherwise drifts between services.

Health checks on everything
depends_on: [db] without condition: service_healthy only guarantees the container started — not that Postgres is accepting connections. The retries-with-interval pattern above prevents the race condition that makes 5% of your CI runs go red.

Profiles for test-only services
profiles: ["test"] keeps e2e out of normal up invocations. Run with docker compose --profile test up.

YoBox over the public network
The e2e service hits https://yobox.dev/api directly. That keeps the compose file simple — no mail server, no webhook tunnel, no extra sidecar. The same compose file runs identically on a laptop, in GitHub Actions, and on a self-hosted runner.

Local dev workflow

docker compose up -d
docker compose logs -f api
When you change application code in a bind-mounted volume, restart just one service:

CI workflow

  • run: docker compose --profile test up --build --abort-on-container-exit --exit-code-from e2e --abort-on-container-exit stops everything as soon as e2e finishes. --exit-code-from e2e propagates the test runner's exit code to the workflow.

Comparison: common patterns

Pattern Pros Cons
One compose, one machine Trivial Doesn't survive CI
Multiple compose files (-f) Maximum flexibility Easy to drift, hard to onboard
Profiles + healthchecks (this) One file, two modes, deterministic deps Slightly more upfront thought

Pairs with

Docker Builder for Cypress & Playwright CI for the test-runner Dockerfile.
Cypress + YoBox and Playwright + YoBox for the test patterns running inside e2e.

Common pitfalls

No healthchecks. Race conditions on slow CI runners are the most common e2e flake.
Bind-mounting node_modules. Native modules differ between host and container — keep node_modules inside the image.
Forgetting init: true. Without it, Node child processes don't get reaped, leading to zombie PIDs in long runs.
Putting secrets in the compose file. Use env_file: and a .gitignored .env.

FAQ

Should I use docker-compose (v1) or docker compose (v2)?
v2. It's the supported version and the syntax above targets it.

Do I need a version: key at the top?
No — it's been ignored since v2. Leaving it out is cleaner.

Can I run this on ARM?
All the images above publish multi-arch tags. Yes.

Where do volumes go in production?
This pattern is for dev and CI. Production uses managed Postgres, managed Redis, and Kubernetes or Fly — not compose.

Conclusion

One compose file. Two modes (up and --profile test up). Healthchecks on everything, anchors for shared defaults, and YoBox over the public internet so your e2e suite has real inboxes and real webhook capture without any sidecar gymnastics. Generate the starting skeleton from the Docker Builder and customize from there.

See also: Docker Builder for Cypress & Playwright CI, Cypress + YoBox, Playwright + YoBox.

Advanced: \develop.watch\ for hot reload

Compose v2.22+ ships \develop.watch\ — file-system events that sync source into a container and trigger a restart. Replaces \docker compose up\ plus a \nodemon\ sidecar with a single declarative block.

Resource limits in CI

Add \deploy.resources.limits\ to keep noisy services from starving the e2e runner. Especially important on GHA runners where 7 GB is the hard ceiling.

Secrets

Use Docker secrets for anything sensitive. They mount as files inside \/run/secrets\ rather than appearing in \docker inspect.

Migration: from a sprawling Makefile

Most teams arrive at a clean compose file by replacing a 200-line Makefile. The transition is mechanical: each Make target becomes either a service, a profile, or a \run --rm\ invocation.

CI invocation reference

\docker compose --profile test up --build --abort-on-container-exit --exit-code-from e2e\ is the one-liner. Add --quiet-pull\ in CI to suppress noise.

The pattern, restated

One docker-compose.yml per repo. Services named after what they do, not where they came from. Volumes for state, networks only when you need isolation, and a .env file for everything that differs between developers. That's it. Anything more is premature.

services:
app:
build: .
env_file: .env
ports: ["3000:3000"]
depends_on: [db, redis]
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes: ["pgdata:/var/lib/postgresql/data"]
redis:
image: redis:7-alpine
volumes:
pgdata:
Five services, three lines of volumes, zero networks. If a new contributor can't run docker compose up and get a working app, the file is wrong, not them.

Profiles for optional services

Compose profiles let you keep heavy or optional services in the same file without forcing every developer to run them.

services:
mailhog:
image: mailhog/mailhog
profiles: ["email"]
jaeger:
image: jaegertracing/all-in-one
profiles: ["tracing"]
docker compose --profile email up starts MailHog only when needed. For real inbox testing without MailHog, point your app at YoBox Temp Mail and skip the local SMTP stack entirely.

Healthchecks that actually matter

depends_on only waits for container start, not readiness. Add a healthcheck so app waits for Postgres to accept connections:

db:
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 2s
timeout: 2s
retries: 20
app:
depends_on:
db: { condition: service_healthy }

Bind mounts vs. named volumes

Use case Mount type Why
Source code (live reload) Bind Edit on host, run in container
Database files Named volume Docker manages permissions and lifecycle
Shared between dev + CI Named volume Same image, no host-path surprises
One-off seed data Bind (:ro) Reproducible, version-controlled
The biggest source of "works on my machine" is bind-mounting a directory that contains files created by the container as root.

Production parity

The same compose file should not be your production deployment. The pattern is:

docker-compose.yml — local dev, with bind mounts and dev images.
docker-compose.ci.yml — overrides for CI: no bind mounts, deterministic seeds.
Production runs on Kubernetes, ECS, Fly, or Render — translated from compose by hand or with tools like kompose.
Trying to make compose your prod runtime is the road to a custom orchestrator built out of bash.

CI usage

In GitHub Actions:

  • run: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d
  • run: docker compose exec -T app npm test
  • run: docker compose down -v Pair with the Docker builder for Cypress and Playwright CI when you need browsers inside the same compose graph.

Troubleshooting

Port already in use.
Either another compose stack is running (docker compose ls) or a host process owns the port. Change the host side: "3001:3000".

Database loses data between runs.
You're using a bind mount on a path that gets cleaned, or you're running docker compose down -v. Use named volumes and avoid -v unless you mean it.

Image rebuilds take forever.
Add a .dockerignore to exclude node_modules, .git, and build artifacts. Order Dockerfile instructions so dependencies are cached above source.

FAQ

Should I use Docker Compose v1 or v2?
v2 — it's the official Docker CLI plugin. docker compose (space, not dash) is the current command.

Can I run multiple compose files together?
Yes, with -f base.yml -f override.yml. Later files override earlier ones key-by-key.

How do secrets work in compose?
Use env_file for dev, secret managers for prod. Don't commit .env. The secrets: block exists but is mostly relevant for Swarm.

Is compose dead?
No. It remains the best tool for local dev parity. Kubernetes is for production; compose is for the developer loop.

How does this fit with the YoBox Docker Builder?
The Docker Builder generates the kind of opinionated Dockerfile that plugs into the compose pattern above — node/python/go templates, multistage builds, and a sane default user.

YoBox Team

Builder behind YoBox — a privacy-first toolbox for developers and QA engineers covering disposable email, webhook capture, regex, secure passwords, Docker, and end-to-end testing.

Top comments (0)