Your Docker Compose File Is Messier Than It Needs to Be
by Eric Hanson, Backend Developer at Clean Systems Consulting
The Compose file that grew without a plan
Most Docker Compose files start clean and degrade over time. Someone adds a service for a new dependency. Someone hardcodes a password that was supposed to be temporary. Someone adds a port mapping "for debugging" and forgets to remove it. Someone copies an environment block from another service and updates half of it. Six months later the file is 200 lines, nobody's confident about what's actually needed, and changing anything feels risky.
This isn't a Docker problem — it's an organization problem. The tool doesn't enforce structure, so structure comes from deliberate choices.
Structure: separate concerns across multiple files
The first organizational decision: don't put everything in one file. Use Compose's native override mechanism to separate base configuration from environment-specific overrides.
docker-compose.yml — base configuration, committed, shared:
services:
app:
image: your-registry/your-app:${APP_VERSION:-latest}
environment:
- DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL}
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 10s
timeout: 5s
retries: 5
volumes:
db_data:
docker-compose.dev.yml — development overrides, committed:
services:
app:
build:
context: .
target: development
volumes:
- ./src:/app/src
ports:
- "8080:8080"
- "9229:9229" # debugger port
db:
ports:
- "5432:5432" # expose to host for local tools
docker-compose.prod.yml — production configuration, possibly committed or generated:
services:
app:
restart: unless-stopped
deploy:
resources:
limits:
cpus: '0.5'
memory: 256M
db:
restart: unless-stopped
Development workflow:
docker compose -f docker-compose.yml -f docker-compose.dev.yml up
Production:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
The base file is readable in isolation. Overrides are explicit and purpose-labeled. Secrets aren't in the base file.
Use YAML anchors for shared configuration
When multiple services share configuration — common environment variables, logging settings, resource limits — YAML anchors reduce repetition:
x-common-env: &common-env
TZ: Asia/Singapore
LOG_LEVEL: ${LOG_LEVEL:-info}
METRICS_ENABLED: ${METRICS_ENABLED:-true}
x-restart-policy: &restart-policy
restart: unless-stopped
services:
app:
<<: *restart-policy
environment:
<<: *common-env
DATABASE_URL: ${DATABASE_URL}
worker:
<<: *restart-policy
environment:
<<: *common-env
QUEUE_URL: ${QUEUE_URL}
The x- prefix on top-level keys is the Compose extension field convention — Compose ignores keys starting with x-. YAML anchors (&common-env) define a block that can be merged (<<:) into other mappings. When you need to update the log level format across all services, you change it in one place.
This pattern breaks down if overused — deeply nested anchors are harder to read than the duplication they prevent. Use them for genuinely shared, stable configuration blocks.
Every service needs a health check
Services without health checks can't participate in depends_on with condition: service_healthy. They also give Compose no signal about whether the service inside the container is actually functioning.
Service-specific health checks by type:
# PostgreSQL
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
# Redis
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
# HTTP service
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
# MySQL/MariaDB
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "${MYSQL_USER}", "-p${MYSQL_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
start_period is important: it prevents health check failures during the service's initialization window from counting toward retries. Set it to longer than your slowest expected startup time.
Keep secrets out of the file
Hardcoded values in the Compose file become a problem when the file is committed to version control — which it should be. All secrets and environment-specific values belong in environment variables or a .env file that is gitignored.
# Don't do this
environment:
DB_PASSWORD: mysecretpassword
# Do this — value comes from environment or .env file
environment:
DB_PASSWORD: ${DB_PASSWORD}
Provide defaults for non-secret values only:
environment:
LOG_LEVEL: ${LOG_LEVEL:-info} # safe default
DB_PASSWORD: ${DB_PASSWORD} # no default — must be set
A missing required variable causes Compose to warn or fail, which is the correct behavior. A missing optional variable silently uses the default.
Document required variables in a .env.example file committed to the repo:
# .env.example — copy to .env and fill in values
DB_USER=
DB_PASSWORD=
DB_NAME=
DATABASE_URL=
REDIS_URL=
New developers know exactly what to fill in. CI systems know what secrets to inject.
Naming and labels for observability
When you have many containers, consistent naming helps. Compose prefixes container names with the project name (defaulting to the directory name), but you can set it explicitly:
name: your-project-name
This ensures consistent container names regardless of the directory the Compose file is in.
Add labels for monitoring and log routing:
services:
app:
labels:
- "app.component=api"
- "app.version=${APP_VERSION}"
- "logging=true"
Labels are surfaced by docker inspect, picked up by log shippers like Fluentd and Vector, and queryable with docker ps --filter label=app.component=api.
The cleanup you should do this week
Open your current docker-compose.yml. Check for:
- Hardcoded passwords or credentials → move to
.env - Services without health checks → add them
- Port mappings that expose internal services (
5432:5432for Postgres) → move to a dev override file - Duplicate environment configuration across services → extract to YAML anchors
- A missing
.env.example→ create one
None of these changes affect functionality. All of them reduce the chance that a new team member or a future you makes a mistake in the file.