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

Tokyo's Backend Hiring Problem Is Not Just Language — It Is Speed and Scale

Foreign founders in Tokyo assume the language barrier is their biggest hiring obstacle. It usually isn't.

Read more

Stop Managing Multiple Containers Manually. Use Docker Compose.

Running multiple Docker containers with individual docker run commands creates an operations problem: inconsistent environments, no dependency ordering, no shared networking, and a runbook nobody maintains. Docker Compose solves all of this.

Read more

Protecting Your Main Branch Is the Cheapest Quality Gate You Have

Branch protection rules take fifteen minutes to configure and prevent an entire class of problems — direct pushes to main, merges without review, and deployments of code that failed CI. The cost of not enabling them is paid in incidents.

Read more

What Really Happens When You Annotate @Transactional

Spring's @Transactional annotation hides significant machinery: proxy creation, connection management, propagation behavior, and rollback rules. The bugs that come from misunderstanding this machinery are among the hardest to diagnose in Spring applications.

Read more