on
Practical Docker Compose Patterns for local microservices: profiles, overrides, and reliable startup
Docker Compose is a great tool for running a multi-service application on your laptop. But when your local microservices stack grows (API, DB, cache, dev tools, migration tasks), a single docker-compose.yml can quickly get messy — and services may race to start before their dependencies are ready. This article shows pragmatic patterns you can apply today: using profiles to gate dev-only services, override files for local tweaks, and a reliable startup strategy combining healthchecks, depends_on, and small “wait” tools when needed.
Why these patterns?
- Keep one canonical Compose model (the app) and layer developer-specific changes on top.
- Make it easy to start only what you need (fast inner-loop).
- Make startup deterministic: services that depend on databases, message brokers, etc., should wait until those dependencies really accept connections.
Profiles: gate dev tooling without duplicating YAML
- What they do: Profiles let you mark services so they only start when a named profile is active. Services without a profile always start. You enable profiles with the CLI flag –profile or the COMPOSE_PROFILES environment variable. Targeting a service on the command line auto-enables its profile, which is handy for one-off tools. (docs.docker.com)
Example (snippet):
services:
api:
build: ./api
postgres:
image: postgres:15
environment:
POSTGRES_PASSWORD: example
adminer:
image: adminer
profiles: ["dev-tools"] # only start in dev when profile enabled
Start everything for normal development:
- docker compose up -d (starts api + postgres)
Start dev tools too:
- docker compose –profile dev-tools up -d (adds adminer)
Or run a single tools service (auto-enables its profile):
- docker compose run –rm adminer
Use case: Put database GUI, mock services, storybook, and migration helpers behind profiles. Your teammates can run a minimal stack quickly, and opt-in for extra tools only when needed.
Override files: keep local changes out of main config
- By convention Compose reads compose.yaml plus compose.override.yaml; the override merges with the base file so you can mount local source folders, enable debugging ports, or change image tags for dev without editing the base. You can supply additional -f files and Compose merges in the order given. This keeps environment-specific changes tidy and version-controlled. (docs.docker.com)
Example override (compose.override.yaml):
services:
api:
volumes:
- ./api:/app # live code changes in dev
environment:
- DEBUG=1
ports:
- "3000:3000"
Healthchecks + depends_on: control startup order more reliably Compose has two helpful pieces for startup ordering:
- healthcheck: define how to test a service’s readiness (command, interval, retries, start_period, etc.). (docs.docker.com)
- depends_on with condition: you can declare that a service depends on another and specify that Compose should wait until that dependency is “healthy” before starting the dependent service. This is useful for simple readiness coordination. (docs.docker.com)
Example: wait for Postgres to accept connections before starting api
services:
postgres:
image: postgres:15
environment:
POSTGRES_PASSWORD: example
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"]
interval: 10s
timeout: 5s
retries: 6
start_period: 10s
api:
build: ./api
depends_on:
postgres:
condition: service_healthy
Notes and gotchas:
- A Docker “healthy” status comes from the healthcheck you define — make it reflect the real readiness your app needs (e.g., port open AND schema present). (docs.docker.com)
- Healthchecks don’t magically make apps resilient. Your app should still retry DB connections where appropriate; Compose coordination is convenience for development, not a production-grade recovery strategy. (docs.docker.com)
When healthchecks aren’t enough: wait-for tools Sometimes a service is “listening” but not yet able to perform specific work (e.g., migrations not applied, initialization scripts running). For those cases, two practical options:
1) Make the dependent service resilient: add retries/connect-backoff in code or an entrypoint script that retries until it can do what it needs.
2) Use a small wait tool (wait-for-it.sh, dockerize, wait-port) to block the container’s main command until a specific TCP/HTTP endpoint or file exists. These are simple and effective for local dev when you don’t want to change app code. Docker’s docs and community answers commonly recommend wait-for-it or dockerize for these use cases. (stackoverflow.com)
Example: using dockerize in a server image’s entrypoint
FROM python:3.11-slim
# install dockerize binary, or use jwilder/dockerize base
COPY dockerize /usr/local/bin/dockerize
COPY entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
entrypoint.sh:
#!/bin/sh
# wait for postgres to accept TCP connections
dockerize -wait tcp://postgres:5432 -timeout 60s
exec gunicorn app:app
Pattern: migrations as a profile-backed one-off A common pattern is to keep migrations as a dedicated service that runs the migration command (instead of running migrations automatically on app start). Make it part of a profile like tools or ci so it doesn’t run every time you bring the stack up.
Example:
services:
db-migrate:
image: myapp:latest
command: ["./migrate.sh"]
depends_on:
- postgres
profiles: ["tools"]
Run migrations explicitly (this automatically enables the profile for that run): docker compose run –rm db-migrate. This avoids surprising schema changes during dev hot-reload and lets CI execute migrations in a controlled step.
Putting it together: a simple local workflow
- Start the core stack for fast iteration:
- docker compose up -d
- When you need tooling (admin UI, mock servers), enable the profile:
- docker compose –profile dev-tools up -d
- To run migrations or one-off tasks:
- docker compose run –rm db-migrate (or) docker compose –profile tools up db-migrate
- When debugging a service with live code edits, use compose.override.yaml to mount source and expose ports; keep that file out of production configs.
Additional tips
- Use sensible start_period and retries for healthchecks to avoid false negatives on slow containers. (docs.docker.com)
- Prefer explicit one-off migration services over auto-migrations in an app’s startup — it’s safer and cleaner during dev and CI.
- Keep secrets out of override files in version control. Use environment variables, .env files, or a secrets manager for sensitive values.
- If you share Compose files across teams, document which profiles exist and what each profile contains.
Closing: make Compose work for you, not against you Docker Compose gives several orthogonal tools — profiles, mergeable override files, healthchecks, depends_on behaviors, and small wait utilities. Combined thoughtfully, they let you model a real microservices development experience that’s fast to start, easy to customize per-developer, and reliable enough to avoid “it works on my machine” races.
Key references
- Profiles and how to enable them. (docs.docker.com)
- Control startup order: depends_on conditions and examples. (docs.docker.com)
- healthcheck configuration options (test, interval, timeout, retries, start_period). (docs.docker.com)
- Merge/override files and default docker-compose.override.yaml behavior. (docs.docker.com)
- Practical waiting tools and community guidance (wait-for-it, dockerize). (stackoverflow.com)
If you want, I can:
- Draft a full example repo layout (compose.yaml + override + migration service + entrypoint scripts) you can drop into a new project.
- Convert the YAML snippets above into a ready-to-run example that starts a sample API, Postgres, and an admin UI behind a profile.