docker-compose.yml #

The docker-compose.yml file is the document that defines your multi-container application stack. It’s YAML with a hierarchical structure that splits the configuration into logical sections: services (mandatory), volumes, networks, configs, and secrets.

This article covers the anatomy of docker-compose.yml thoroughly — what the top-level keys are, how they relate to each other, and the conventions worth following to keep the file readable and maintainable.

Top-Level Keys #

The most complete docker-compose.yml file has several top-level keys. Some are mandatory, some optional.

# docker-compose.yml
version: "3.8"  # optional in Compose V2

services:        # MANDATORY
  web:
    ...
  api:
    ...

volumes:         # optional
  db-data:

networks:        # optional
  backend:

configs:         # optional, for configuration files
  nginx.conf:

secrets:         # optional, for sensitive data
  db-password:

services — Mandatory #

services is the only mandatory top-level key. It contains the list of services to run. Each service represents one container (or several containers if scaled).

volumes — For Persistent Data #

volumes declares the volumes services use. If a service has a volumes: field referencing an undeclared volume, Compose automatically creates it. But the best practice is explicit declaration at the top level.

networks — For Isolation #

networks declares the networks services use. By default, Compose creates one network for all services in the file. For more advanced configuration, you can define several networks.

configs — For Configuration Files #

configs lets you mount files or directories into containers. The difference from bind mounts: configs are defined in the Compose file and injected at runtime.

secrets — For Sensitive Data #

secrets is similar to configs but for sensitive data like passwords, API keys, and certificates. Secrets are stored in Docker’s secret storage (encrypted in production) and mounted to /run/secrets/ in the container.

Schema version: Since Docker Compose V2, the top-level version: field is optional and deprecated. If you use Compose V2, drop version: from the file. This makes the file more future-proof and avoids “unknown version” errors.

Service Anatomy #

Each service under services: has many fields. Not all are mandatory, but each has a specific role.

Basic Fields #

services:
  web:
    image: nginx:alpine          # the image to use
    # OR
    build: ./web                  # path to a Dockerfile
    
    container_name: my-nginx      # optional, container name
    hostname: web-server          # optional, hostname inside the container
    
    ports:
      - "8080:80"                 # port mapping
    
    environment:
      - NODE_ENV=production       # env vars
      - DEBUG=false
    
    volumes:
      - ./html:/usr/share/nginx/html:ro
      - db-data:/var/lib/data
    
    networks:
      - frontend
      - backend
    
    depends_on:
      - api
      - db
    
    restart: unless-stopped
    
    user: "1000:1000"
    working_dir: /app
    command: ["nginx", "-g", "daemon off;"]
    entrypoint: ["/docker-entrypoint.sh"]

Image vs Build #

A service can be specified with image: (use an image directly from the registry) or build: (build from a local Dockerfile).

services:
  # Use an image from Docker Hub
  redis:
    image: redis:7-alpine

  # Build from a local Dockerfile
  api:
    build:
      context: ./api
      dockerfile: Dockerfile.dev
      args:
        - NODE_ENV=development

  # Build AND tag
  api-prod:
    build: ./api
    image: myregistry.com/api:v1.2.3

When using build:, the resulting image is stored in the local Docker cache. For multi-stage builds, Compose automatically uses the stage specified in the Dockerfile (usually the last stage).

Ports #

The ports field maps host ports to container ports.

ports:
  - "8080:80"        # host:container
  - "127.0.0.1:8080:80"  # host_ip:host_port:container_port
  - "8443:443"        # HTTPS
  - "3000"            # random host port, expose 3000

The short "HOST:CONTAINER" format is the most common. For special cases (binding to a specific IP), use the long format.

For production, avoid - "80:80" or other low host ports. Ports below 1024 need root to bind. Use high host ports (e.g. "8080:80") then a reverse proxy.

Environment Variables #

services:
  api:
    environment:
      - NODE_ENV=production
      - DATABASE_URL=postgres://user:pass@db:5432/myapp
      - DEBUG=false
    
    # Or use map syntax
    environment:
      NODE_ENV: production
      DATABASE_URL: postgres://user:pass@db:5432/myapp
    
    # Use an .env file
    env_file:
      - .env
      - .env.production

env_file reads an .env file and injects its variables into the service. The .env file should be in .gitignore if it contains secrets.

Volumes #

The volumes field has several formats.

services:
  web:
    volumes:
      # Named volume
      - db-data:/var/lib/data
      
      # Bind mount
      - ./html:/usr/share/nginx/html
      
      # Bind mount with options
      - ./html:/usr/share/nginx/html:ro
      
      # Anonymous volume
      - /var/lib/data
      
      # tmpfs
      - type: tmpfs
        source: /tmp
        target: /tmp
        tmpfs:
          size: 100m

The source:target:options format is the most explicit and recommended. The short - source:target format is also valid and often used.

Networks #

Services automatically join the default network Compose creates. For custom networks:

services:
  web:
    networks:
      - frontend
      - backend

networks:
  frontend:
  backend:
    driver: bridge
    driver_opts:
      com.docker.network.bridge.name: br-backend

The web service now has two network interfaces. It can communicate with other services on both frontend and backend. Services on backend can’t be reached from frontend services unless also exposed there.

Depends On #

depends_on sets the startup order.

services:
  web:
    depends_on:
      - api
  
  api:
    depends_on:
      - db
      - redis

In the example above, db and redis start first, then api, then web. But — importantly — depends_on only waits for containers to start, not for services to be ready to accept requests.

To truly wait until a service is ready (e.g. the database accepting connections), use healthcheck + depends_on: { ... condition: service_healthy }.

services:
  api:
    depends_on:
      db:
        condition: service_healthy
  
  db:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5

api now won’t start until db’s healthcheck returns healthy. This is more reliable for dependencies that need initialization time.

Restart Policy #

services:
  web:
    restart: "no"                # default
    restart: always              # always restart
    restart: on-failure          # restart only on non-zero exit codes
    restart: unless-stopped      # restart unless manually stopped

For production, unless-stopped or always are safe choices. For development, no (default) is usually enough since you start/stop things yourself.

User, Working Directory, Command #

services:
  app:
    user: "1000:1000"          # run as non-root
    working_dir: /app
    command: ["python", "app.py"]
    entrypoint: ["/entrypoint.sh"]

command overrides CMD in the Dockerfile. entrypoint overrides ENTRYPOINT. user makes the container run as a specific user (not root) for security.

Resource Limits #

services:
  api:
    deploy:
      resources:
        limits:
          cpus: "0.5"
          memory: 512M
        reservations:
          cpus: "0.25"
          memory: 256M

deploy is a field commonly used in Docker Swarm. For single-host Compose, you can use deploy.resources (supported in Compose V2) or use mem_limit and cpus at the top level (legacy).


A Complete File Example #

A docker-compose.yml for a simple e-commerce stack:

services:
  web:
    build: ./web
    ports:
      - "3000:3000"
    environment:
      - API_URL=http://api:8080
    depends_on:
      api:
        condition: service_healthy
    networks:
      - frontend
    restart: unless-stopped

  api:
    build: ./api
    environment:
      - DATABASE_URL=postgres://user:pass@db:5432/shop
      - REDIS_URL=redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy
    networks:
      - frontend
      - backend
    restart: unless-stopped

  db:
    image: postgres:16-alpine
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=pass
      - POSTGRES_DB=shop
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user -d shop"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - backend
    restart: unless-stopped

  cache:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 3
    networks:
      - backend
    restart: unless-stopped

  proxy:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./certs:/etc/nginx/certs:ro
    depends_on:
      - web
    networks:
      - frontend
    restart: unless-stopped

volumes:
  db-data:

networks:
  frontend:
  backend:

This stack has 5 services: web (frontend), api (backend), database, cache, and a reverse proxy. Networks are split into frontend and backend — web and proxy on frontend, database and cache on backend, which isn’t exposed to the host.

Writing Conventions #

Indentation and Formatting #

Use 2-space indentation (the YAML standard). Don’t use tabs. The Compose parser rejects tabs.

# GOOD
services:
  web:
    image: nginx
    ports:
      - "8080:80"

# BAD
services:
  web:
      image: nginx
      ports:
        - "8080:80"

Line Length #

Try to keep lines under 120 characters so they’re easy to read in code reviews.

Comments #

Use comments to explain WHY, not WHAT. Code explains what; comments explain why.

services:
  api:
    # PostgreSQL tuned for high-concurrency workloads
    image: postgres:16-alpine
    command: postgres -c max_connections=200

Service Order #

There’s no fixed rule, but some good conventions:

  • Most important services first (e.g. web, api)
  • Databases/caches at the end
  • Helper services (proxy, monitoring) by role
  • Group related services together

Naming Consistency #

  • Services: kebab-case (user-service, auth-service)
  • Volumes: kebab-case (user-uploads, app-cache)
  • Networks: descriptive (frontend, backend, monitoring)

Multiple Compose Files #

For different environments, you can override with additional files.

# Default + development override
docker compose -f docker-compose.yml -f docker-compose.dev.yml up

# Default + production override
docker compose -f docker-compose.yml -f docker-compose.prod.yml up

Override files overwrite fields in the default. This lets the same base configuration serve many environments.


Validation #

Before running, validate your Compose file.

# Show the parsed config
docker compose config

# Show and highlight issues
docker compose config --quiet

docker compose config reads all files (default + override + env file), merges, parses, then shows the result. Any syntax errors or invalid references appear here.

The --quiet flag only shows errors and warnings without the full output — great for a CI/CD validation step.


Advanced References #

Profiles #

Docker Compose has a profiles feature that lets you group services by purpose (development, testing, production). Services with a profile only run when that profile is activated.

services:
  web:
    image: nginx
    ports:
      - "80:80"

  debug-tools:
    image: alpine
    profiles: ["debug"]
    command: sleep infinity

  load-generator:
    image: loadtest
    profiles: ["load-test"]
# Default — debug-tools and load-generator don't run
docker compose up

# With the debug profile
docker compose --profile debug up

# With the load-test profile
docker compose --profile load-test up

Profiles are very useful for development environments where you sometimes need extra tools (debuggers, log viewers, mock servers) without adding them to the default setup.

Variable Substitution #

YAML supports variable substitution from the host environment.

services:
  api:
    image: myapp:${VERSION:-latest}
    ports:
      - "${PORT:-8080}:8080"
    environment:
      - LOG_LEVEL=${LOG_LEVEL}

${VAR} takes a value from the environment. ${VAR:-default} uses a default value if VAR isn’t set. Compose reads the host environment when parsing the file.

Multiple Compose Files and Merging #

Field overriding when using multiple files:

docker compose -f base.yml -f override.yml up

When override.yml has a service matching one in base.yml, the override’s fields overwrite the base’s. But lists and maps are merged, not replaced.

# base.yml
services:
  web:
    environment:
      - LOG_LEVEL=info
      - NODE_ENV=production

# override.yml
services:
  web:
    environment:
      - LOG_LEVEL=debug

Result: LOG_LEVEL=debug (overridden) + NODE_ENV=production (preserved).

JSON Syntax #

Compose also supports JSON as a YAML alternative. Useful if YAML isn’t available in your toolchain.

{
  "services": {
    "web": {
      "image": "nginx",
      "ports": ["80:80"]
    }
  }
}

But YAML is more readable for complex configuration, so JSON is rarely used except for programmatically generated configs.

Supporting tools: Some IDEs have extensions for docker-compose.yml — auto-complete, validation, and service-dependency visualization. Recommendation: VSCode with the “Docker” or “Kubernetes” extension (also supports Compose).

Summary #

  • docker-compose.yml is a declarative YAML file defining your entire multi-container application stack.
  • Top-level keys: services (mandatory), volumes, networks, configs, secrets. The version: field is optional in Compose V2.
  • Each service has many fields: image/build, ports, environment, volumes, networks, depends_on, restart, command, etc.
  • depends_on ≠ service readydepends_on only waits for containers to start. To truly wait for readiness, use healthchecks + condition: service_healthy.
  • Network isolation — split services across several networks (frontend, backend) to restrict communication.
  • Resource limits matter in production to prevent one service from consuming all resources.
  • Validate with docker compose config before running, to catch syntax errors and invalid references.
  • Override files (-f docker-compose.dev.yml) let the same base configuration serve many environments.
  • Conventions: 2-space indentation, no tabs, comments explain why, descriptive service and volume names.

← Previous: What is Docker Compose?   Next: Multi-Container →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact