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:

  1. Hardcoded passwords or credentials → move to .env
  2. Services without health checks → add them
  3. Port mappings that expose internal services (5432:5432 for Postgres) → move to a dev override file
  4. Duplicate environment configuration across services → extract to YAML anchors
  5. 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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Germany's Most Supply-Constrained Tech Market Has a Remote Hiring Solution

You've been searching for a backend engineer in Munich for three months. The recruiter just asked if you'd consider widening the search to "all of DACH." That's not a good sign.

Read more

Using Trello, Notion, or Jira as a Solo Contractor

Project management tools aren’t just for teams. Even as a solo contractor, using Trello, Notion, or Jira can keep your work organized and your brain sane.

Read more

Why Your Git Workflow Is Slowing Your Team Down

Most teams blame slow delivery on unclear requirements or code complexity. The actual bottleneck is often the Git workflow — specifically the conventions around branching, reviews, and merging that nobody thought through when the team was two people.

Read more

The Soft Skills Nobody Mentions in Backend Engineering Job Descriptions

Job descriptions for backend engineers list languages, frameworks, and system design experience. The skills that actually determine whether an engineer is effective at senior levels are almost never listed — and almost always matter more.

Read more