Your Docker Compose File Is Messier Than It Needs to Be

by Arif Ikhsanudin, Backend Developer

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

Seattle Has Amazon and Microsoft. Everyone Else Competes for the Same Engineers — or Goes Remote

You found a backend engineer who loved your product, aced the technical screen, and seemed genuinely excited. Then Amazon matched with a $50K signing bonus.

Read more

Why Overcomplicated Access Rules Hurt Productivity

Ever spent half an hour just to get permission to open a file? Complex access rules might seem secure, but they can silently kill efficiency.

Read more

Why Your Unit Tests Are Slow and What to Do About It

A unit test suite that takes 10 minutes to run will stop being run. Slow tests accumulate through specific, fixable causes — hidden I/O, over-reliance on containers, and tests that are integration tests wearing unit test clothing.

Read more

The Backend Hiring Reality for Boston Startups That Nobody Talks About

Everyone knows Boston has a strong tech talent base. Fewer people talk about why that talent is so hard for startups to actually hire.

Read more