Skip to content
yutils

Docker Compose Explained — Services, Networks, Volumes, and the Practical Setup

How docker-compose.yml is structured, what each top-level key (services / networks / volumes / configs / secrets) does, depends_on vs healthcheck, named volumes vs bind mounts, and the gotchas that break dev environments.

~10 min read

Docker Compose defines and runs multi-container applications. It's the de-facto standard for local dev environments and is sometimes used for small production deployments too. This guide walks through the structure of docker-compose.yml, what each of the five top-level keys does, how depends_on differs from healthchecks, named volumes vs bind mounts, and the gotchas that break dev environments.

The five top-level keys

# v3+ format — the version key is no longer required
services:        # 1. container definitions (required)
networks:        # 2. network definitions
volumes:         # 3. named volume definitions
configs:         # 4. Swarm configs (optional)
secrets:         # 5. Swarm secrets (optional)

Every manifest revolves around services. The other four exist only when services reference them; they live in the same file.

services — container definitions

services:
  web:
    image: nginx:1.27         # image or build
    container_name: my-web    # optional explicit name
    ports:
      - "80:80"               # host:container
      - "443:443"
    environment:              # env vars
      LOG_LEVEL: info
      DATABASE_URL: postgres://db/app
    env_file:                 # pull from .env file
      - .env
    depends_on:               # start order
      - db
    networks:                 # which networks to attach
      - frontend
    volumes:                  # which paths to mount
      - ./static:/usr/share/nginx/html
      - logs:/var/log/nginx
    restart: unless-stopped   # restart policy
    healthcheck:              # health probe
      test: ["CMD", "curl", "-f", "http://localhost"]
      interval: 30s
      timeout: 3s
      retries: 3

The service name (web above) becomes the container's DNS name. Other services in the same Compose can reach it via http://web — no DNS configuration needed.

image vs build

  • image: pull from Docker Hub or a private registry. image: postgres:16.
  • build: build from a local Dockerfile. build: ./api or build: {context: ., dockerfile: Dockerfile.dev}.

With both, build wins and the resulting image is tagged with the image value. Use one or the other normally.

networks — service isolation

networks:
  frontend:
    driver: bridge           # default
  backend:
    driver: bridge
    internal: true           # no external access (internal only)
  monitoring:
    external: true           # an existing network outside Compose

Services see each other only on shared networks. Above: db on backend is unreachable from web on frontend. A natural DMZ pattern.

Compose creates a default network automatically (<project>_default). Without explicit networks: in services, everything joins this single network.

volumes — persistent data

Named volume (recommended)

services:
  db:
    image: postgres:16
    volumes:
      - db-data:/var/lib/postgresql/data  # named: mount

volumes:
  db-data:                                 # top-level declaration
  • Managed by Docker. Stored at /var/lib/docker/volumes/<project>_db-data/.
  • docker compose down removes containers but keeps volumes.
  • docker compose down -v explicitly deletes them.
  • Backup- and migration-friendly. Portable across hosts.

Bind mount (mount host filesystem)

volumes:
  - ./src:/app/src                 # host ./src → container /app/src
  - /etc/timezone:/etc/timezone:ro # read-only
  • Mounts a host filesystem path.
  • Essential for hot reload in dev environments.
  • Risk of OS- and permission-mismatch. Not recommended for production.

tmpfs (in memory)

services:
  api:
    tmpfs:
      - /tmp                  # RAM-backed; cleared on restart

To see structure at a glance, drop your manifest into Docker Compose Visualizer — named volumes show as nodes connected to their services. Bind mounts are excluded (host-dependent, not Compose-managed).

depends_on — start order ≠ readiness

services:
  api:
    depends_on:
      - db
  db:
    image: postgres:16

Compose starts db first, then api. But nothing guarantees db is ready to accept connections. Container started ≠ process ready.

Fix: long form with a condition that waits on healthcheck.

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

Condition values:

  • service_started (default) — container is up
  • service_healthy — healthcheck passes
  • service_completed_successfully — exited successfully (Job pattern)

environment vs env_file vs secrets

environment

environment:
  - LOG_LEVEL=info             # array form
  - DATABASE_URL=postgres://...

# or map form
environment:
  LOG_LEVEL: info
  DATABASE_URL: postgres://...

Plain text in the manifest. Never put secrets here — committing the file leaks them.

env_file

env_file:
  - .env
  - .env.local

Load variables from a .env file (which should be gitignored). Standard for dev environments.

secrets (Swarm)

services:
  db:
    secrets:
      - db-password
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db-password

secrets:
  db-password:
    file: ./secrets/db-password.txt

The secret file mounts as /run/secrets/<name>. Safer than env vars — not in the process environment, not visible to docker inspect.

ports — host : container

ports:
  - "8080:80"             # host 8080 → container 80
  - "127.0.0.1:8080:80"   # bind to localhost (no external access)
  - "8080:80/udp"         # UDP
  - "8000-8010:8000-8010" # ranges

ports is for external exposure. Services within the same Compose talk to each other over the default network using DNS — no ports required. Ports are for the host and external clients.

Real-world — a full-stack dev environment

services:
  web:
    build: ./web
    ports: ["3000:3000"]
    volumes:
      - ./web/src:/app/src       # hot reload
    depends_on:
      api:
        condition: service_started
    environment:
      NEXT_PUBLIC_API_URL: http://localhost:8000

  api:
    build: ./api
    ports: ["8000:8000"]
    volumes:
      - ./api:/app
    depends_on:
      db:
        condition: service_healthy
    environment:
      DATABASE_URL: postgres://app:secret@db:5432/app

  db:
    image: postgres:16
    ports: ["5432:5432"]         # for local DBeaver
    volumes:
      - db-data:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: app
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      retries: 5

  cache:
    image: redis:7-alpine
    ports: ["6379:6379"]

volumes:
  db-data:

To see how the manifest connects together, paste the above into Docker Compose Visualizer.

Essential commands

docker compose up -d            # background start
docker compose up --build       # rebuild images and start
docker compose down             # remove containers + networks
docker compose down -v          # ... also remove volumes (careful)
docker compose ps               # status
docker compose logs -f api      # follow a service's logs
docker compose exec api bash    # shell into a container
docker compose restart api      # restart one service
docker compose pull             # refresh images

Compose vs Kubernetes

AxisDocker ComposeKubernetes
TargetSingle host, local devCluster, production
Learning curveMild (hours)Steep (weeks)
HA / scalingLimited (Swarm mode)First-class (Deployment / HPA)
ManifestsOne file (compose.yml)Many (Helm / Kustomize)

For Compose → K8s migration, kompose convert automates the mapping. With Kubernetes YAML Visualizer and Docker Compose Visualizer sharing the same visualization pattern, side-by-side structure comparison is intuitive.

Common pitfalls

1. Assuming depends_on means ready

API starts connecting to DB immediately; DB is still booting → connection refused. Healthcheck + condition: service_healthy is required.

2. Secrets in environment

Committing the manifest leaks them. Use env_file + .env (gitignored) or proper secrets.

3. Bind mount permissions

Host directory permissions don't match the container user → write fails. macOS/Windows Docker Desktop handles this automatically; Linux usually requires user: "${UID}:{GID}".

4. Setting version: "3"

Compose v2+ (the CLI itself) ignores the version: key and warns. Modern manifests omit it.

5. Two services on the same host port

Two services with ports: ["8080:80"] — the second fails. Host port collision. Pick different ports or only expose one.

6. Implicit shared network

Without explicit networks:, every service sits on the default network. Zero isolation. For production or security-sensitive setups, split into named networks.

7. docker compose down -v mistake

Wipes named volumes too. DB data gone. Common dev accident — have a backup policy.

8. v1 vs v2 command confusion

Old docker-compose (hyphen, Python) vs new docker compose (space, Go plugin). Always use the latter in new environments — the old command is deprecated as of 2024.

Summary

  • Five top-level keys: services / networks / volumes / configs / secrets. Services is the hub.
  • Service name = container DNS name. Same-network services auto-discover.
  • depends_on guarantees start order only. Readiness needs healthcheck + condition: service_healthy.
  • Named volumes = Docker-managed, bind mounts = host-dependent. Production prefers named.
  • Never put secrets in environment. Use env_file (.env gitignored) or secrets.
  • For an instant map of any compose file, paste it into Docker Compose Visualizer.
Back to guides