Docker Compose Guide — services/networks/volumes Mental Model, What depends_on Actually Means, and Real-World Pitfalls

Docker Compose Guide — services/networks/volumes Mental Model, What depends_on Actually Means, and Real-World Pitfalls


Introduction

“To run Spring Boot locally I need to start MySQL, then Redis, then build the app, then export the env vars…” — this dance ends in start-local.sh, which the next person on the team will inevitably break.

Docker Compose replaces that .sh with declarative YAML. You write down which containers belong to which networks, what volumes they use, and the order they should start in — then docker compose up -d does the rest.

This guide is for engineers who are new to Compose, or who have copy-pasted Compose files without quite knowing what services, networks, and volumes actually do. After reading it, you should know why things are written the way they are, and how to avoid the classic traps — like trusting depends_on and watching your app crash before the DB is ready.

One disclaimer up front. Compose’s primary uses are local development, CI integration testing, and self-hosted packaging. SaaS production deployments serving real traffic almost always run on Kubernetes (EKS/GKE) or ECS — Compose is a single-host orchestrator with no rolling deploys, no autoscaling, and no first-class secrets, and it doesn’t compete in that space. The “prod” examples in this post only make sense inside that boundary — CI environments, OSS self-hosted distributions, on-prem single-tenant appliances, hobby VPSes.


TL;DR

  • Compose isn’t a SaaS production tool. Its real domain is local dev, CI, and self-hosted packaging; serving real traffic at scale belongs to Kubernetes or ECS. The “prod” examples in this post are only valid inside that limited scope.
  • The point of Compose is to replace a sequence of docker run commands with a YAML declaration and to bind containers in the same project to a shared network and lifecycle.
  • services / networks / volumes are the three top-level keys. Services in one file automatically join the same network, and service name = DNS — they find each other by name.
  • depends_on alone only guarantees start order. It does not mean “the DB is ready to accept queries.” Pair it with condition: service_healthy and a real healthcheck to express true readiness.
  • Don’t cram dev and prod into one file. Use a base file plus override files (-f merging) or profiles. Hot-reload bind mounts go in dev only; resource limits and pinned tags go in prod only.
  • Four real-world traps: committing .env, macOS bind mount I/O cost, runaway container logs, and still writing version: "3.8" — Compose v2 ignores that field.

1. What Compose Solves

1.1 The limits of stacked docker run

Spinning up the same setup with raw docker run looks like this:

docker network create app-net
docker volume create mysql-data

docker run -d --name mysql --network app-net \
  -e MYSQL_ROOT_PASSWORD=rootpw -e MYSQL_DATABASE=mydb \
  -v mysql-data:/var/lib/mysql mysql:8.0

docker run -d --name redis --network app-net redis:7-alpine

docker run -d --name app --network app-net -p 8080:8080 \
  -e SPRING_DATASOURCE_URL=jdbc:mysql://mysql:3306/mydb \
  -e SPRING_DATA_REDIS_HOST=redis myapp:latest

The problem isn’t the length of the commands. It’s that this state lives only in shell history. Who ran what, in what order, with which env vars — none of it is captured anywhere durable. The next teammate debugs from scratch.

1.2 The same picture in Compose

Move that setup into Compose and it collapses to:

services:
  mysql:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: rootpw
      MYSQL_DATABASE: mydb
    volumes:
      - mysql-data:/var/lib/mysql

  redis:
    image: redis:7-alpine

  app:
    image: myapp:latest
    ports: ["8080:8080"]
    environment:
      SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/mydb
      SPRING_DATA_REDIS_HOST: redis
    depends_on: [mysql, redis]

volumes:
  mysql-data:

Five things happen at once:

  • Network is created automatically — every service in the file joins the same network. No docker network create.
  • Service name = DNS — from the app container, mysql:3306 just works. No --link, no hardcoded IPs.
  • Volume and network lifecycle is per projectdocker compose down cleans them all up.
  • The declaration is the documentation — anyone with docker-compose.yml can reproduce the environment.
  • Diff-friendly — check it into Git and review changes via PRs.

1.3 Aside: Compose v1 (docker-compose) vs v2 (docker compose)

There’s a reason you see the command written two different ways.

Aspectv1v2
Invocationdocker-compose (hyphen)docker compose (space)
ImplementationStandalone Python binaryDocker CLI plugin (Go)
StatusEOL since 2023Current standard
version: fieldMeaningfulIgnored

For anything new, assume v2. version: "3.8" is a v1-era schema marker; v2 ignores it. If you copy an old guide and hit docker-compose: command not found, just use the space form.


2. services — The Core of Container Definition

Each entry under services is one container (or a group, if you scale).

2.1 image vs build — Two Starting Points

ApproachWhen to use itExample
image:When you reuse an external image as-is (DB, cache, broker, …)image: mysql:8.0
build:When you need to build an image from this project’s codebuild: .
BothTag and cache the build output for reuseimage: myapp:dev + build: .
services:
  app:
    image: myapp:dev          # Tag for the built image
    build:
      context: .              # Path containing the Dockerfile
      dockerfile: Dockerfile  # Default, usually omitted
      args:
        JAR_FILE: build/libs/*.jar
      target: runtime         # Final stage of a multi-stage build

2.2 Common Service Options at a Glance

OptionRoleNotes
container_namePin a container nameUsually skip it. Same name conflict → no scale-out
restartExit policyno / always / on-failure / unless-stopped
portsHost:container port mappingOnly when external access is needed
exposePublish ports to other services onlyNot mapped to the host
environmentEnv varsList or map (see 2.4)
env_fileRead env from file(s)Useful for separating secrets
volumesVolumes / mountsSee section 3
depends_onStart order / health gatingSee section 5
commandOverride CMDReplaces the Dockerfile CMD
entrypointOverride ENTRYPOINTFrequently used for debugging
healthcheckHealth ruleSee section 5

Note: container_name enforces a single container of that name per host. The moment you do docker compose up --scale app=3, or run two environments side by side on the same machine, you hit a collision. Outside of debugging, leaving it off is safer.

2.3 Four Ways to Set Environment Variables

There are four routes — easy to confuse.

services:
  app:
    # 1) Inline map — clearest
    environment:
      SPRING_PROFILES_ACTIVE: local
      DB_HOST: mysql

    # 2) Inline list — same idea, = syntax
    # environment:
    #   - SPRING_PROFILES_ACTIVE=local
    #   - DB_HOST=mysql

    # 3) Separate file
    env_file:
      - .env
      - .env.local

    # 4) Variable substitution inside the Compose file (host shell / .env)
    image: myapp:${APP_VERSION:-latest}
    ports:
      - "${APP_PORT:-8080}:8080"

One rule keeps things straight: secrets in .env (or a secrets manager); regular config in environment. ${VAR:-default} is resolved when Compose parses the file against the host shell and the sibling .env, while options 1–3 set variables inside the container. If you remember nothing else, remember that distinction.

2.4 Aside: List vs Map for environment

The two forms produce the same outcome but read differently.

# List
environment:
  - DB_HOST=mysql
  - JAVA_TOOL_OPTIONS=-Xms256m -Xmx512m

# Map
environment:
  DB_HOST: mysql
  JAVA_TOOL_OPTIONS: "-Xms256m -Xmx512m"
  • Map form separates keys from values cleanly — better autocompletion, sorting, and diffs.
  • List form handles values containing = without ceremony, and lets you forward a host shell variable by name (- HOME).

Default to the map; reach for the list in special cases. That’s enough.


3. volumes — Persistence and Mount Modes

When a container exits, its disk is gone. Anything that must outlive that — DB data, logs, source for hot reload — has to live on a volume.

3.1 Named Volume vs Bind Mount

TypeLives whereProsConsTypical use
Named VolumeDocker-managed (/var/lib/docker/volumes/...)Portable, permissions handledAwkward to edit from the hostDB data directory
Bind MountArbitrary host pathHost files visible directlyOS-specific perf/permissionsHot-reload source, config files
services:
  mysql:
    image: mysql:8.0
    volumes:
      - mysql-data:/var/lib/mysql           # Named volume

  app:
    build: .
    volumes:
      - ./src:/app/src                      # Bind mount
      - ./config:/app/config:ro             # Read-only

volumes:
  mysql-data:                               # Named volume declaration

3.2 Long Syntax — When You Need Options

When the short form runs out of room, switch to long syntax.

services:
  app:
    volumes:
      - type: volume
        source: mysql-data
        target: /var/lib/mysql
      - type: bind
        source: ./logs
        target: /app/logs
        read_only: false
        bind:
          create_host_path: true            # Create the host path if missing

3.3 Aside: Why bind mounts are slow on macOS/Windows

Docker Desktop on macOS and Windows runs containers inside a lightweight VM. Your host’s ./src is shared into that VM via a compatibility filesystem, and every read/write crosses that boundary. Directories like node_modules — tens of thousands of tiny files — pay that cost on every build and startup.

Three remedies:

  • Mount less — push build output and dependency directories into named volumes; bind-mount only source.
  • :cached / :delegated (macOS) — relax consistency for speed. For read-only mounts, :cached is the safest pick.
  • Switch the virtualization backend — VirtioFS in current Docker Desktop / Orbstack is dramatically faster.
volumes:
  - ./src:/app/src:cached     # Common option on macOS

4. networks — Talking Between Services

4.1 The default network — there even when you don’t write it

Omit networks: entirely and Compose still creates a default bridge network per project, attaching every service. That’s why app can talk to mysql:3306 with zero config.

services:
  app:
    image: myapp
  mysql:
    image: mysql:8.0
# Even without a `networks` key, both services share one network

4.2 Custom Networks for Isolation

When you want DMZ-style separation between front and back.

flowchart LR
    Internet([Internet])
    subgraph frontend["frontend network"]
        NGINX[nginx]
        APP[app]
    end
    subgraph backend["backend network"]
        APP2[app]
        DB[(mysql)]
    end
    Internet --> NGINX
    NGINX --> APP
    APP2 --> DB

app straddles both; nginx only sees frontend, mysql only sees backend.

services:
  nginx:
    image: nginx
    networks: [frontend]

  app:
    image: myapp
    networks: [frontend, backend]

  mysql:
    image: mysql:8.0
    networks: [backend]

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge

4.3 Joining an External Network

When another Compose project already created the network you want.

networks:
  shared-net:
    external: true     # Declared elsewhere; Compose neither creates nor deletes it

With external: true, Compose won’t create or remove the network. This is the pattern when several Compose projects share, say, one Redis: each app project joins a common network without owning it.


5. depends_on + healthcheck — A Reliable Start Order

This is the single most-misunderstood corner of Compose.

5.1 Bare depends_on only guarantees start order

services:
  app:
    depends_on:
      - mysql

This says only: “start mysql before app.” It does not say MySQL is ready to accept queries. So app boots first, gets Connection refused, and falls into a restart loop.

5.2 condition: service_healthy — health gating

To actually wait for “DB is ready,” combine a healthcheck with the right condition.

services:
  app:
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_started

  mysql:
    image: mysql:8.0
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
sequenceDiagram
    participant C as Compose
    participant M as mysql
    participant H as healthcheck
    participant A as app
    C->>M: 1. start
    M->>M: 2. boot (slow)
    loop interval=10s
      C->>H: 3. mysqladmin ping
      H-->>C: 4. unhealthy
    end
    H-->>C: 5. healthy
    C->>A: 6. start (only now)
    A->>M: 7. first query — succeeds

The valid condition values:

ValueMeaning
service_startedJust needs to be started (default behavior)
service_healthyWait until healthcheck reports healthy
service_completed_successfullyWait for a one-shot container (e.g. a migration runner) to exit with 0

5.3 Writing a Useful healthcheck

The four timing parameters mean different things.

ParameterMeaningTypical value
intervalProbe frequency10s
timeoutPer-probe deadline5s
retriesConsecutive failures before marking unhealthy5
start_periodGrace window where failures don’t count30s (longer for DBs)

People skip start_period constantly. MySQL or Postgres first-boot — including initialization scripts — easily takes 30–60 seconds, and a too-short start_period flags a perfectly healthy container as unhealthy.

5.4 Aside: What depends_on won’t fix — application-level retries

Even depends_on + healthcheck isn’t a cure-all.

  • Mid-flight DB restarts: if the DB blips while everything is already up, depends_on doesn’t fire again.
  • Outside Compose: containers on different hosts — Compose has no view of those dependencies.

That’s why production code always layers in connection-pool retries / backoff (HikariCP connectionTimeout, Lettuce reconnect, Spring @Retryable, …). Compose’s health gate is a startup-only safety net.


6. End-to-End Example — Spring Boot + MySQL + Redis

A working setup that combines everything in §1–5.

docker-compose.yml — full
services:
  app:
    image: myapp:dev
    build:
      context: .
      dockerfile: Dockerfile
    restart: unless-stopped
    ports:
      - "${APP_PORT:-8080}:8080"
    environment:
      SPRING_PROFILES_ACTIVE: local
      SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/mydb?useSSL=false&allowPublicKeyRetrieval=true
      SPRING_DATASOURCE_USERNAME: ${DB_USER:-user}
      SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD:-password}
      SPRING_DATA_REDIS_HOST: redis
      SPRING_DATA_REDIS_PORT: 6379
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_started
    networks: [app-network]

  mysql:
    image: mysql:8.0
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-rootpassword}
      MYSQL_DATABASE: mydb
      MYSQL_USER: ${DB_USER:-user}
      MYSQL_PASSWORD: ${DB_PASSWORD:-password}
      TZ: Asia/Seoul
    volumes:
      - mysql-data:/var/lib/mysql
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
    networks: [app-network]

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: redis-server --appendonly yes
    volumes:
      - redis-data:/data
    networks: [app-network]

networks:
  app-network:
    driver: bridge

volumes:
  mysql-data:
  redis-data:
Dockerfile — multi-stage build
FROM eclipse-temurin:17-jdk AS builder
WORKDIR /app
COPY gradlew .
COPY gradle gradle
COPY build.gradle settings.gradle ./
COPY src src
RUN chmod +x ./gradlew && ./gradlew bootJar --no-daemon

FROM eclipse-temurin:17-jre AS runtime
WORKDIR /app
COPY --from=builder /app/build/libs/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
.env — secrets out of the YAML
DB_ROOT_PASSWORD=rootpassword
DB_USER=user
DB_PASSWORD=password
APP_PORT=8080

Warning: always add .env to .gitignore. Don’t ship the example values to production — secrets belong in a real secrets manager (AWS Secrets Manager, HashiCorp Vault, etc.).


7. Separating Environments — Don’t Cram dev and prod Together

Caution: don’t read “prod” in this section as “company SaaS production.” Compose v2 is a single-host orchestrator: no multi-node scheduling, no rolling deploys, no autoscaling, no first-class secrets. Production deployments serving real traffic almost exclusively run on Kubernetes (EKS/GKE) or ECS.

Where the Compose prod pattern legitimately shows up in practice:

ScenarioExample
CI ephemeral integration test environmentsSpin up the full stack with a real DB and Redis on every PR
OSS self-hosted distributionsSentry, Mattermost, Plausible, GitLab Omnibus — “install our product on your server with this docker-compose.yml
On-prem single-tenant appliances · homelabs · hobby VPSesPlaces where the whole stack genuinely fits on one box

The dev/prod separation patterns below are only valid inside that boundary. Don’t treat them as a blueprint for an architecture that takes SaaS traffic.

A single YAML branched with if-style conditionals quickly becomes unreadable. Two standard patterns work well.

7.1 Base + override (-f merging)

# Development
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d

# Production
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

When the same key appears in both files, the later file wins (deep merge). The base holds shared config; overrides hold per-environment differences.

docker-compose.dev.yml — hot reload + debug
services:
  app:
    build:
      target: builder           # Use the builder stage of the multi-stage build
    volumes:
      - ./src:/app/src:cached   # Source hot reload
    environment:
      SPRING_PROFILES_ACTIVE: dev
      JAVA_TOOL_OPTIONS: "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005"
    ports:
      - "5005:5005"             # Debugger port
docker-compose.prod.yml — resource limits, pinned tags
services:
  app:
    image: myregistry/myapp:${TAG:-latest}
    build: !reset null          # Don't build in prod; pull from the registry
    restart: always
    deploy:
      resources:
        limits:
          cpus: "2"
          memory: 2G
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

7.2 Profiles — Toggling Within a Single File

Tag a service with profiles: and it only starts when that profile is active.

services:
  app-dev:
    build: .
    volumes: ["./src:/app/src"]
    profiles: [dev]

  app-prod:
    image: myapp:${TAG:-latest}
    profiles: [prod]

  mysql:
    image: mysql:8.0
    # No profiles = always running
docker compose --profile dev up -d
docker compose --profile prod up -d
PatternBest for
-f base + overrideMany differences (ports, mounts, build targets)
profilesDifferences are “on/off” (toggling optional services)

8. Four Real-World Pitfalls

These trip teams up more than any feature does.

8.1 .env committed to Git

.env files start with placeholder values, get committed casually, and once pushed they live in Git history forever.

  • Always add .env to .gitignore.
  • Commit a .env.example with placeholders for the team.
  • If a real .env already shipped: rotate the keys, then scrub history with git filter-repo or BFG.

8.2 macOS bind-mount I/O cost

As covered in §3.3, bind mounts are expensive on macOS. The symptom is builds and startups that are abnormally slow only on local machines. Move directories the host doesn’t need to see — node_modules, target/, .gradle/ — into named volumes.

8.3 Container logs that grow forever

The default json-file log driver doesn’t rotate. Days later, /var/lib/docker fills up and the host falls over.

services:
  app:
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

In production, set the same defaults at the daemon level (/etc/docker/daemon.json) so any forgotten service inherits sane rotation.

8.4 No resource limits

Without deploy.resources.limits, one container’s memory leak takes down the whole host. JVMs are particularly bad: some versions still see the host’s full memory inside a container and size the heap accordingly. Explicit limits are a safety net.

services:
  app:
    deploy:
      resources:
        limits:
          cpus: "1"
          memory: 1G
        reservations:
          cpus: "0.5"
          memory: 512M

Note: the deploy: block was originally Swarm-only, and Compose v2 standalone (docker compose up) honors only resources.limits / resources.reservations. Multi-node fields like replicas, update_config, placement, and restart_policy only make sense under Swarm and are silently ignored in standalone mode (use the service-level restart: instead of restart_policy).


9. Cheat Sheet — Common Commands

# Lifecycle
docker compose up -d               # Start in background
docker compose up                  # Foreground (logs attached)
docker compose down                # Tear down containers + network
docker compose down -v             # Also delete volumes (careful!)
docker compose restart app         # Restart one service

# Build
docker compose build               # Build images
docker compose build --no-cache    # Build ignoring cache
docker compose up -d --build       # Build then start

# Inspect
docker compose ps                  # Service status
docker compose logs -f app         # Follow logs
docker compose top                 # Process view
docker compose config              # Final merged config (best debug tool)

# Enter
docker compose exec app /bin/sh    # Shell into a running container
docker compose run --rm app gradle test   # One-off run

Bottom line: when something feels off, docker compose config is the first thing to run. It prints the YAML Compose actually sees, with variables substituted, files merged, and defaults filled in. Empty env vars and surprise mount paths fall out immediately.


Recap

The five takeaways:

  1. Compose’s job is to replace stacked docker run with a declaration and to bind containers per project. Services in one file share a network and find each other by name.
  2. services / networks / volumes are the three top-level keys. A correct mental model of these three solves most debugging on its own.
  3. depends_on alone only guarantees start order. To express “DB is ready to accept queries,” combine condition: service_healthy with a real healthcheck and a sensible start_period. Mid-flight blips still need application-level retries.
  4. Don’t cram dev and prod into one file. Split with base + override or profiles. “Prod” here means CI / self-hosted / single-host scenarios — actual SaaS production lives on Kubernetes or ECS, not Compose.
  5. The four most common production traps: committing .env, macOS bind mount cost, runaway logs, missing resource limits. Operational hygiene causes more incidents than feature gaps do.

If you’re brand new, §1–3 (services, volumes, networks) is the core; §4–5 (-f merging, profiles, traps) is something you’ll re-open as needed. The single best habit to build: run docker compose config whenever something is unclear — it shows the YAML that Compose actually applies, not the one you think you wrote.


Appendix — Options & Acronym Cheat Sheet

For quick re-reference while reading.

ItemMeaning
Compose v2Go-based plugin integrated into Docker CLI. Invoked as docker compose .... v1 (docker-compose) is EOL
servicesTop-level key for container definitions
networksNetwork definitions for inter-container communication
volumesPersistent data (named volumes, externals, etc.)
depends_onStart order, optionally with health gating
healthcheckContainer health rule. test / interval / timeout / retries / start_period
restartExit policy. no / always / on-failure / unless-stopped
expose vs portsexpose is service-network only; ports maps to the host
Named VolumeDocker-managed volume, easy on portability and permissions
Bind MountMount of an arbitrary host path; used for hot reload and config files
.envFile Compose reads automatically. Never commit
${VAR:-default}Variable substitution in the Compose file with a fallback
-f mergingDeep-merge multiple Compose files; the base + override pattern
profilesToggle services within a single file via --profile <name>
deploy.resourcesCPU/memory limits and reservations. Honored by Compose v2 up
Shop on Amazon

As an Amazon Associate, I earn from qualifying purchases.