Docker & Container Fundamentals

Multi-Container Applications

A team's "run the stack locally" instructions are a 12-step README with docker run commands, a handwritten network, shell variables for container IPs, and notes like "if the database was running before the API you need to restart the API." A new hire joins, follows the steps, gets a connection-refused error on step 7, and spends three hours debugging. Another engineer says "just use the compose file" and points to a 30-line YAML. One command — docker compose up — starts all five services, creates the network, mounts the volumes, injects the env vars, and waits until everything is healthy. Setup time: 30 seconds. Mystery eliminated.

Docker Compose is the difference between "individual docker run commands glued together with README steps" and "one YAML file describes the whole application." For local development, CI integration tests, and small single-host deployments, Compose is the right tool. This lesson covers the v2 plugin (the one you should be using), the shape of a compose file, and the patterns that make multi-container apps actually work.


docker compose vs docker-compose: The Hyphen Matters

There are two Compose binaries in the wild. You need to know which one you are looking at.

  • docker-compose (with the hyphen) — the v1 Python implementation. Separate binary, separate install, slow startup, effectively deprecated. You still see it in older systems and legacy CI images.
  • docker compose (space, no hyphen) — the v2 Go plugin bundled with modern Docker installs. Faster, integrated into the docker CLI, actively maintained. Use this one.
# Check which one you have
docker compose version
# Docker Compose version v2.24.6
# ← v2 plugin, the correct one

docker-compose --version
# docker-compose version 1.29.2, build ...
# ← v1, legacy; try to avoid

# Docker Desktop and modern docker-ce install v2 automatically
which docker-compose
# (often not found — v2 is the only thing installed)

Functionally, v1 and v2 accept the same compose files with minor differences. But the commands are docker compose (v2) vs docker-compose (v1) in CI scripts — and that is where teams get bitten. A CI image that only has docker-compose installed (v1, Python) will fail on a v2-specific feature; an image with docker compose (v2 plugin) will behave differently than a developer's local v1 install.

KEY CONCEPT

Standardize on v2 (docker compose with a space) and make sure your CI images include it. Delete docker-compose v1 from developer machines to avoid the "works locally, fails in CI" divergence. Every example in this lesson uses v2 syntax — when you see docker compose in this course, that is the v2 plugin.


The Shape of a Compose File

A compose file (canonically compose.yaml, legacy docker-compose.yml) describes an application stack as a collection of:

  • services — containers
  • networks — user-defined networks the services attach to
  • volumes — named volumes
  • configs / secrets — config files and secrets (more Swarm-focused; limited use in compose up)

A minimal example:

# compose.yaml
services:
  web:
    image: nginx:1.25-alpine
    ports:
      - "8080:80"
    volumes:
      - ./html:/usr/share/nginx/html:ro

One command:

docker compose up -d
# [+] Running 2/2
#  ✔ Network myapp_default  Created
#  ✔ Container myapp-web-1  Started

Done. An nginx is serving on 8080 with the local ./html mounted in. To tear down:

docker compose down
# [+] Running 2/2
#  ✔ Container myapp-web-1  Removed
#  ✔ Network myapp_default  Removed

A Realistic Stack

Here is the canonical shape you will use over and over — web app + database + cache:

# compose.yaml
name: myapp

services:
  web:
    build: .                       # build image from ./Dockerfile
    image: myapp/web:dev
    ports:
      - "127.0.0.1:8080:8080"      # localhost-only
    environment:
      NODE_ENV: development
      DATABASE_URL: postgres://user:pass@db:5432/app
      REDIS_URL: redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    volumes:
      - .:/app
      - node_modules:/app/node_modules   # volume — don't overlay w/ host
    develop:
      watch:
        - action: sync
          path: ./src
          target: /app/src

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: app
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "user"]
      interval: 5s
      timeout: 5s
      retries: 10
    ports:
      - "127.0.0.1:5432:5432"      # expose locally for psql

  cache:
    image: redis:7-alpine
    volumes:
      - redisdata:/data

volumes:
  pgdata:
  redisdata:
  node_modules:

A lot happens here. Let us decode it piece by piece.

name

Top-level name: myapp sets the project name. Compose uses this to prefix container names, network names, and volume names (myapp-web-1, myapp_default, myapp_pgdata). Without name:, Compose uses the directory name. Explicit is better — your compose file works identically from any directory.

services

Each key under services: becomes one container (or more with deploy.replicas). Inside each service:

FieldWhat it does
imageImage to run (or to tag after build)
buildPath to a Dockerfile context; Compose builds the image
portsEquivalent to -p; list of host:container[/proto]
exposeInternal ports only (no host binding); documentation
environmentEnv vars (map or list form)
env_filePath(s) to .env files
volumesBind mounts and named volume refs
depends_onStartup ordering (and health-gating with condition)
healthcheckSame as Dockerfile's HEALTHCHECK, overridable here
restartno / on-failure / always / unless-stopped
networksWhich networks to attach to (default: default)
commandOverride image's CMD
entrypointOverride image's ENTRYPOINT
userRuntime UID
working_dirEquivalent to -w
deploySwarm-only in v1; some fields work in v2 (resources)

networks

If you do not declare any network, Compose creates a default one named <project>_default and attaches every service to it. You rarely need explicit networks in compose — the default is fine for most single-stack apps.

Explicit networks matter when you want isolation inside a single compose file:

services:
  frontend:
    networks: [public, internal]
  backend:
    networks: [internal]    # not reachable from public

networks:
  public:
  internal:
    internal: true          # no outbound to host network

volumes

Named volumes declared at the top level. Compose creates them with <project>_<name> and manages their lifecycle.

volumes:
  pgdata:                    # shortest form — uses local driver
  sharedcache:
    driver: local
    driver_opts:
      type: tmpfs
      device: tmpfs
      o: "size=100m"         # inline tmpfs

Essential Compose Commands

# Start everything in the foreground (Ctrl-C stops)
docker compose up

# Detached
docker compose up -d

# Rebuild images before starting
docker compose up -d --build

# Start only specific services (and their deps)
docker compose up -d web

# Scale a service
docker compose up -d --scale worker=3

# Stop (containers removed, networks and volumes kept)
docker compose down

# Stop + remove volumes (DESTRUCTIVE — careful)
docker compose down -v

# Status
docker compose ps
# NAME              IMAGE            STATUS        PORTS
# myapp-web-1       myapp/web:dev    Up 2 min      127.0.0.1:8080->8080/tcp
# myapp-db-1        postgres:16-alpine  Up 2 min   127.0.0.1:5432->5432/tcp
# myapp-cache-1     redis:7-alpine   Up 2 min      6379/tcp

# Logs
docker compose logs             # all services, interleaved
docker compose logs -f web       # follow one service
docker compose logs --tail=100 db

# Exec into a running service (like docker exec)
docker compose exec web sh
docker compose exec db psql -U user app

# Run a one-off command (new container, same image)
docker compose run --rm web npm test

# Restart a single service
docker compose restart web

# Validate your compose file (parse + resolve — does NOT run anything)
docker compose config
PRO TIP

docker compose config is the single most useful command when a compose file is misbehaving. It prints the fully-resolved configuration with all variable substitutions, includes, and inheritance applied — the exact document Compose will act on. Most "this isn't working" bugs are visible in the config output as a misspelled env var, a missing volume definition, or an unresolved ${VAR}.


Environment Variables: The .env File

Compose auto-loads .env from the directory containing the compose file:

# .env
POSTGRES_PASSWORD=devpassword
API_PORT=8080
LOG_LEVEL=debug
# compose.yaml
services:
  api:
    environment:
      DATABASE_URL: postgres://user:${POSTGRES_PASSWORD}@db:5432/app
      LOG_LEVEL: ${LOG_LEVEL}
    ports:
      - "${API_PORT}:8080"

The .env file is for Compose-time substitution into the YAML. Values are baked into the resolved config before containers start.

To set env vars inside the container, use environment: or env_file::

services:
  api:
    env_file:
      - ./secrets.env   # each KEY=VALUE becomes a container env var
    environment:
      EXPLICIT: value   # additional / overrides

The difference matters: .env is for "values you want to inject into the YAML" (ports, tags, feature flags). env_file: is for "container environment" (database URLs, API keys).


depends_on and Service Readiness

Naive depends_on:

services:
  api:
    depends_on:
      - db        # just ordering: start db before api

This only means "start db's container before api's container." It does not wait for db to be ready. If api starts connecting to db immediately and db is still initializing, api fails.

Health-gated depends_on (v2):

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

Now api waits until db's healthcheck passes. Conditions:

  • service_started — default; wait only for container creation.
  • service_healthy — wait for the healthcheck to pass.
  • service_completed_successfully — wait for a oneshot service to exit 0.
WARNING

Even with service_healthy, your app should still retry transient connection failures at startup. Healthchecks have a window, and transient network blips happen. Graceful retry logic in the app is a superset of what Compose can guarantee.


Profiles: Optional Services

Profiles let you mark services as optional — they only start when a matching profile is selected:

services:
  api:
    image: myapp/api:dev
  db:
    image: postgres:16
  jaeger:
    image: jaegertracing/all-in-one:1.55
    profiles:
      - observability
docker compose up -d                       # api + db start; jaeger does NOT
docker compose --profile observability up -d  # all three start

Good for optional dev tools (Mailhog, Jaeger, test data seeders) you do not want running every time.


Overrides: Different Files for Different Environments

Compose auto-merges compose.yaml with compose.override.yaml if both exist. This lets you commit a base config and an override used only for local development:

# compose.yaml (committed, "canonical")
services:
  web:
    image: myapp/web:${VERSION:-latest}
    restart: unless-stopped

# compose.override.yaml (local dev only, often gitignored)
services:
  web:
    build: .
    volumes:
      - .:/app
    environment:
      LOG_LEVEL: debug

For explicit multi-env use:

docker compose -f compose.yaml -f compose.prod.yaml up -d

Multiple -f flags are merged left-to-right; later files win on conflicting keys.


Generating the Actual Docker Commands

Every docker compose up is equivalent to a sequence of docker network create + docker volume create + docker run commands. If you want to see exactly what Compose would do:

docker compose config
# prints the merged, resolved YAML that Compose will execute

docker compose up --dry-run   # (v2.23+) show what would happen without running

Compose is not magic — it is an orchestration layer that parses YAML and drives the same Docker daemon you would drive by hand. Once you know what each service declaration maps to (a docker run with specific flags), compose files become trivial to read and write.


A Minimal Startup Pattern

Here is a production-quality compose file for a typical web-service + db + cache setup. Copy this as a starting point:

# compose.yaml
name: myapp

services:
  web:
    image: ghcr.io/myorg/myapp-web:${VERSION:-latest}
    restart: unless-stopped
    ports:
      - "${HOST_IP:-127.0.0.1}:${HOST_PORT:-8080}:8080"
    environment:
      NODE_ENV: production
      PORT: 8080
      DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
      REDIS_URL: redis://cache:6379
    depends_on:
      db: {condition: service_healthy}
      cache: {condition: service_started}
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/health"]
      interval: 15s
      timeout: 3s
      retries: 3
    deploy:
      resources:
        limits:
          memory: 512M
          cpus: "1.0"

  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
      interval: 5s
      retries: 20
    deploy:
      resources:
        limits:
          memory: 1G

  cache:
    image: redis:7-alpine
    restart: unless-stopped
    volumes:
      - redisdata:/data

volumes:
  pgdata:
  redisdata:

And a matching .env:

# .env
VERSION=v1.2.3
HOST_IP=127.0.0.1
HOST_PORT=8080
POSTGRES_USER=myapp
POSTGRES_PASSWORD=changeme
POSTGRES_DB=myapp

One command gets you a full stack:

docker compose up -d

Key Concepts Summary

  • Use docker compose (v2 plugin), not docker-compose (v1 Python). Check with docker compose version.
  • A compose file declares services, networks, and volumes for a whole app. One YAML, one command.
  • docker compose up -d brings the stack up detached; docker compose down stops and cleans (containers + networks). down -v also removes volumes.
  • .env is for YAML substitution; env_file: is for container env. Different jobs.
  • depends_on with condition: service_healthy gives you real readiness gating.
  • profiles: mark optional services; enable with --profile.
  • compose.override.yaml auto-merges over compose.yaml for local dev tweaks.
  • docker compose config is your debugger: prints the fully resolved YAML.
  • Project name scopes container/network/volume names; set with name: or -p.
  • Compose is an orchestration layer over docker run. Every service maps to a specific docker run invocation.

Common Mistakes

  • Mixing v1 and v2 Compose. One developer uses docker-compose, another uses docker compose; slightly different behaviors bite randomly.
  • Using depends_on without condition: service_healthy and getting intermittent startup failures. Add healthchecks and gate on them.
  • Overwriting /app/node_modules by bind-mounting the host's project dir. Use a named volume for node_modules to avoid the issue.
  • Committing .env with production secrets. .env should be gitignored and committed as .env.example with placeholder values.
  • Running docker compose down -v in production expecting it to only remove containers. It also deletes named volumes — your database is gone.
  • Forgetting that docker compose up will REBUILD images if build: is set — unless you also pass --build. Rules: up uses cached builds; up --build forces rebuild.
  • Using multiple compose files and mixing up merge order. docker compose -f base.yaml -f override.yaml up — override wins; know which wins.
  • Skipping docker compose config when a compose file "doesn't work." The resolved output almost always shows the bug.
  • Hardcoding service IPs instead of using service names. Compose's default network gives you DNS — rely on it.
  • Port-conflicting on the host. Multiple compose projects all publishing 5432 on the host will conflict; use 127.0.0.1:<different-port>:5432 per project.

KNOWLEDGE CHECK

Your team's compose file has `depends_on: [db]` on the API service. Locally, the stack works. In CI, about 20% of test runs fail with 'connection refused' at API startup. What is happening and how do you fix it?