Senior 18 min · March 06, 2026

Docker Compose depends_on — Fixing Container Startup Crashes

depends_on controls start order, not readiness.

N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Notes here come from systems that actually shipped.

Follow
Production
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • You declare desired state (services, volumes, networks) in docker-compose.yml
  • Compose translates that into container create/start/stop commands via the Docker API
  • Service names become DNS entries on an auto-created bridge network
  • services: each entry maps to one container
  • volumes: named storage that survives container lifecycle
  • networks: control which services can reach each other
  • depends_on with healthcheck: startup ordering with readiness gates
✦ Definition~90s read
What is Docker Compose?

Docker Compose is a tool for defining and running multi-container Docker applications. It solves the problem of manually wiring together containers with docker run commands, networks, volumes, and environment variables. Instead, you declare your entire application stack—services, networks, volumes, and dependencies—in a single YAML file (docker-compose.yml).

Imagine you're opening a restaurant.

Compose then orchestrates the lifecycle of all containers, handling creation, networking, and teardown with a single command like docker compose up. It's the standard for local development environments and CI pipelines where you need a reproducible, isolated multi-service setup (e.g., an API server, a PostgreSQL database, and a Redis cache).

depends_on is a key feature that controls the startup order of services. Without it, Compose starts all containers in parallel, which often causes application crashes when a service (like a web app) tries to connect to a database that isn't ready yet. depends_on tells Compose to start a service only after its dependencies have started.

However, it only waits for the container to be running (i.e., the Docker daemon reports it as started), not for the process inside to be ready to accept connections. This is a critical distinction: a PostgreSQL container can be running but still initializing, so your app may still crash.

For production-grade readiness checks, you must combine depends_on with condition: service_healthy and health checks, or use a retry loop in your application code.

Alternatives to depends_on include using a process supervisor like supervisord inside a single container, or a service mesh that handles retries and circuit breaking. But for most Compose-based setups, depends_on is the simplest and most readable way to express dependencies.

When not to use it: in production orchestrators like Kubernetes, where startup ordering is handled by init containers, liveness probes, and readiness probes—depends_on is a Compose-only concept and doesn't translate. Also, avoid relying on depends_on alone for critical services; always implement application-level retry logic (e.g., exponential backoff) to handle transient startup delays.

Plain-English First

Imagine you're opening a restaurant. You need a chef (your app), a waiter (your web server), and a cashier (your database) — all working together, in the right order, talking to each other. Docker Compose is the restaurant manager who reads one master plan (a single YAML file) and spins up every staff member at once, makes sure they can talk to each other, and shuts them all down cleanly at the end of the night. Without it, you'd be hiring each person separately, on different phones, hoping they find each other.

Every real-world application is more than one moving part. Your API needs a database. That database needs a volume so data survives restarts. Your frontend needs to know the API's address. Managing each with individual docker run commands is a copy-paste nightmare that breaks when a new developer joins or you move to a different machine.

Docker Compose is a declarative orchestrator for multi-container applications on a single host. You describe the desired state in YAML — services, networks, volumes, environment variables, health checks — and Compose handles creation, networking, and lifecycle ordering. The file is the documentation. The file is the deployment. There is no gap between what you describe and what runs.

The critical misconception: Compose is not just a convenience wrapper. It manages DNS resolution between services, network isolation between tiers, volume lifecycle across container restarts, and startup ordering with readiness gates. Understanding these mechanisms is what separates a working compose file from a production-grade one.

What Docker Compose Actually Does for Multi-Container Apps

Docker Compose is a declarative tool that defines and runs multi-container Docker applications from a single YAML file. Instead of manually wiring containers with docker run commands, you describe services, networks, and volumes in docker-compose.yml. The core mechanic: Compose translates that YAML into a set of Docker API calls, creating and linking containers in a user-defined order.

In practice, Compose handles three critical properties: service orchestration (which containers start, in what order), network isolation (each service gets its own DNS-resolvable hostname), and volume persistence (data survives container restarts). The depends_on directive controls startup sequencing, but it only waits for the container to start — not for the process inside to be ready. This is the source of most startup crashes.

Use Compose for local development, CI/CD integration tests, and single-host production deployments. It eliminates the need to manually manage container lifecycles, but it is not a production orchestrator — that's Kubernetes or Nomad. For teams running 3–10 services on one host, Compose provides the simplest path to reproducible environments.

depends_on ≠ readiness check
depends_on only waits for the container to start, not for the service inside to accept connections. Use healthcheck or a wait-for-it script to prevent race conditions.
Production Insight
A payment service using depends_on for a Postgres container crashed on every deploy because the app tried to connect before Postgres was ready.
The exact symptom: 'FATAL: the database system is starting up' followed by a connection refused error.
Rule of thumb: always pair depends_on with a healthcheck or a retry loop in the application startup code.
Key Takeaway
depends_on controls container start order, not service readiness.
Always add a healthcheck or wait-for-it script to prevent race conditions.
Compose is for dev/test environments; use an orchestrator for production.
Docker Compose depends_on — Container Startup Order THECODEFORGE.IO Docker Compose depends_on — Container Startup Order How depends_on controls startup sequence and prevents crashes depends_on Directive Declares dependency between services Startup Order Waits for dependency to start, not be ready Health Check Condition Use condition: service_healthy for readiness Container Crash Prevention Ensures dependent service is healthy before start ⚠ depends_on does not wait for readiness by default Add healthcheck and condition: service_healthy to avoid crashes THECODEFORGE.IO
thecodeforge.io
Docker Compose depends_on — Container Startup Order
Docker Compose

The Anatomy of a docker-compose.yml File (and Why Every Key Matters)

A Compose file is a declaration of intent, not a script. You're telling Docker 'here is the world I want' — Compose figures out how to build it. That mental shift matters: you're not writing steps, you're describing a state.

Every Compose file has a few top-level keys. services is the main one — each entry is a container. volumes defines named storage that outlives containers. networks lets you control which services can see each other (by default Compose creates one shared network, which is great for getting started but dangerous if you want to isolate, say, your admin panel from your public API).

The depends_on key is widely misunderstood. It controls start order, not readiness. Your app container will start after the database container starts, but not after Postgres is actually ready to accept connections. That's a real gotcha we'll cover shortly. For readiness you need health checks.

The build key is your escape hatch from public images — point it at a directory with a Dockerfile and Compose builds the image itself before starting the container. This is how you develop locally with live code while still using Compose for orchestration.

Service configuration depth: Each service can specify image or build (mutually exclusive in intent — image pulls from a registry, build creates locally). The restart policy controls behavior on container exit: no (default), always, on-failure, unless-stopped. In production, unless-stopped is almost always correct — it restarts on crash but respects your manual docker compose stop.

Volume lifecycle: Named volumes declared under the top-level volumes: key are managed by Docker. They survive docker compose down but are destroyed by docker compose down -v. Bind mounts (host:path syntax) are managed by the host filesystem. Anonymous volumes (- /app/node_modules) are created by Docker and removed when the container is removed.

docker-compose.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# docker-compose.yml — A full three-tier web application
# Services: React frontend, Node/Express API, PostgreSQL database

version: '3.9'

services:

  # ── PostgreSQL Database ──────────────────────────────────────────
  postgres_db:
    image: postgres:15-alpine          # Use the slim Alpine variant to keep image size small
    restart: unless-stopped            # Restart on crash, but respect manual `docker compose stop`
    environment:
      POSTGRES_USER: ${DB_USER}        # Pulled from .env file — never hardcode credentials
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: ${DB_NAME}
    volumes:
      - postgres_data:/var/lib/postgresql/data   # Named volume: data persists across `down`/`up`
      - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql  # Seed script runs on first start
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]  # Actual readiness probe
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - backend_network   # Only the API can reach the DB — frontend cannot

  # ── Node.js / Express API ────────────────────────────────────────
  api_server:
    build:
      context: ./api          # Build from local Dockerfile in the /api directory
      dockerfile: Dockerfile
    restart: unless-stopped
    ports:
      - "3001:3001"           # Expose API to host for direct testing with Postman etc.
    environment:
      NODE_ENV: development
      DATABASE_URL: postgres://${DB_USER}:${DB_PASSWORD}@postgres_db:5432/${DB_NAME}
      #                                                   ^^^^^^^^^^^
      #  'postgres_db' is the SERVICE NAMECompose's built-in DNS resolves it automatically
      JWT_SECRET: ${JWT_SECRET}
    depends_on:
      postgres_db:
        condition: service_healthy   # Wait until the healthcheck PASSES, not just starts
    volumes:
      - ./api:/app             # Bind mount for hot-reload in development
      - /app/node_modules      # Anonymous volume: keep container's node_modules, don't overwrite with host
    networks:
      - backend_network
      - frontend_network

  # ── React Frontend (served via Nginx) ────────────────────────────
  web_frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
    restart: unless-stopped
    ports:
      - "80:80"               # Expose port 80 to the host machine
    environment:
      REACT_APP_API_URL: http://api_server:3001   # Container-to-container via service name
    depends_on:
      - api_server
    networks:
      - frontend_network      # Frontend can only see the API, never the raw database

# ── Named Volumes ────────────────────────────────────────────────────
volumes:
  postgres_data:              # Managed by Docker — survives `docker compose down`
                              # Destroyed only by `docker compose down -v`

# ── Networks ─────────────────────────────────────────────────────────
networks:
  backend_network:            # Isolated: only api_server and postgres_db
    driver: bridge
  frontend_network:           # Isolated: only web_frontend and api_server
    driver: bridge
Output
[+] Running 4/4
✔ Network app_backend_network Created
✔ Network app_frontend_network Created
✔ Container app-postgres_db-1 Healthy
✔ Container app-api_server-1 Started
✔ Container app-web_frontend-1 Started
Compose as a Declarative State Machine
  • Declarations are idempotent — running up twice does not create duplicate containers.
  • The file is the documentation — no gap between what is described and what runs.
  • Compose can diff desired state against actual state and converge incrementally.
  • Multiple files can merge — base config + environment overrides without duplication.
Production Insight
The default Compose network (project_default) puts all services on one flat network. This is fine for getting started but creates an implicit trust boundary violation — your frontend can reach your database directly. In production, define explicit networks per tier (frontend, backend, data) and assign services to only the networks they need. This is defense in depth at the network layer.
Key Takeaway
A Compose file is a desired state declaration, not a script. services, volumes, and networks are the three pillars. The default network is flat — define explicit networks per tier for production security. depends_on without healthcheck is a race condition waiting to happen.
Compose File Structure Decisions
IfSingle service, no dependencies
UseUse docker run — Compose adds no value for a single container
If2+ services that need networking and ordering
UseUse Compose with explicit networks per tier and healthcheck-gated depends_on
IfNeed different configs for dev, staging, prod
UseUse base docker-compose.yml + override files (-f flag) per environment
IfNeed conditional services (admin UI only in dev)
UseUse profiles: [dev] on the service, start with --profile dev

Top-Level YAML Key Reference Table

The docker-compose.yml file has several top-level keys that control the entire multi-container stack. Each key serves a distinct purpose — volumes persist data, networks isolate traffic, and configs manage non-file configuration. Below is a reference table of every top-level key available in Compose specification version 3.9 (the most widely supported version across Docker Engine and Docker Desktop).

KeyRequired?PurposeExample
versionYesDeclares the Compose file format version. Should always be '3.9' for current projects.version: '3.9'
servicesYesDefines the containers to run. Each service is a named entry with image/build, ports, volumes, etc.services: web: image: nginx
volumesNoDeclares named volumes that can be referenced by services. Survives docker compose down.volumes: db_data:
networksNoDeclares custom networks. By default Compose creates one network per project.networks: backend:
configsNoManages configuration files (Swarm mode only). Rarely used outside Swarm.configs: my_config: file: ./app.conf
secretsNoDeclares secrets (Swarm mode) or references external secrets (Docker Compose v2.5+).secrets: db_password: external: true
includeNoReferences external Compose files (v2.24+). Merges services, volumes, networks from included files.include: - ./auth/compose.yml
x- (extension fields)NoCustom YAML anchors for reuse. Not processed by Compose but can be referenced using <<: merge syntax.x-logging: &logging driver: json-file

How they connect: The services block is the only required top-level key because it defines the actual containers. Every other key is optional and exists to support the services. For example, you reference a named volume like my_volume in a service's volumes: list only if you declared my_volume under the top-level volumes: key. If you don't declare it, Docker creates an anonymous volume instead.

The include key is a game-changer for multi-team projects. Instead of one monolithic file, each team owns their own Compose file, and the parent file includes them. This is how you compose microservices without a single 500-line file that no one understands. The included files' services, volumes, and networks are merged at runtime.

Extension fields are the DRY superpower
  • Extension fields are ignored by Compose — they are purely for you to organize.
  • YAML anchors (&alias) and merge keys (<<:) let you compose reusable configurations.
  • Example: define x-common-restart: &restart_policy restart: unless-stopped and reuse it across services.
  • This keeps your compose file DRY without needing external templating tools.
Production Insight
The include key is powerful but dangerous if used carelessly. Each included file creates a separate compose project under the hood, meaning services from different included files cannot reference each other's volumes or networks unless they are explicitly declared as external. Always test the merged output with docker compose config before deploying. A common mistake is including a file that defines a volume but forgetting to mark it as external if it needs to be shared across included files.
Key Takeaway
Know the seven top-level keys — services is the only required one. Volumes, networks, configs, secrets, include, and extension fields are optional but essential for production-grade compose files. Use include to break monolithic files into team-owned modules.

Docker Compose Command Cheat Sheet

Docker Compose commands follow a consistent pattern: docker compose <command> [options] [service...]. Below is a quick reference for the most frequently used commands in day-to-day development and debugging. These commands assume you are in the directory containing docker-compose.yml (or using the -f flag to point to one).

CommandWhat It DoesCommon Flags
docker compose upCreates and starts containers according to the compose file. Detaching with -d runs in background.-d (detach), --build (rebuild images before starting), --force-recreate (recreate containers even if config unchanged).
docker compose downStops and removes containers, networks, and default networks. Does NOT remove named volumes by default.-v (remove named volumes), --rmi all (remove all images), --remove-orphans (clean up containers not in the compose file).
docker compose psLists containers managed by the compose file, showing status, ports, and health.-a (show stopped containers too).
docker compose logsShows logs from all services or a specific service.-f (follow/stream), --tail=50 (show last 50 lines), --timestamps (show timestamps).
docker compose execRuns a command inside a running container. Useful for debugging.-it (interactive terminal for shell access), --user <user> (run as specific user).
docker compose runRuns a one-off command in a new container (for migrations, seeds, etc.).--rm (remove container after command exits), --service-ports (expose ports defined in compose).
docker compose buildBuilds (or rebuilds) images defined in the compose file.--no-cache (force fresh build without layer cache), --pull (always pull base image).
docker compose configValidates and shows the resolved configuration (merging all -f files and .env interpolation).--services (list service names only), --volumes (list volume names only).
docker compose topShows running processes inside each container.None commonly used.
docker compose imagesLists all images used by the compose services and whether they are built locally or pulled.-q (show only image IDs).
docker compose eventsStreams real-time events from containers (start, stop, die, health_status).--json (output as JSON for machine parsing).
docker compose pause / unpauseSuspends/resumes all processes in a container (SIGSTOP/SIGCONT).Not commonly used.

Command examples for real debugging: ```bash # Start the stack and rebuild the API image docker compose up --build -d

# Watch logs from the API service only docker compose logs -f api_server

# Shell into a running container to inspect environment docker compose exec -it api_server sh

# Run a one-off migration (remove container after) docker compose run --rm api_server npx prisma migrate deploy

# Check the entire resolved config (useful before prod deploy) docker compose config

# Stop everything and clean up named volumes (careful — destroys data) docker compose down -v ```

Pro tip: Use docker compose config --services to list all service names in the current project. This is invaluable when you have many services and need to reference them in scripts.

Production Insight
The most dangerous command in production is docker compose down -v. The -v flag removes named volumes, which usually contain your database. This is a data-loss event. In production, never run down -v unless you explicitly intend to destroy the database. Use docker compose down without flags — it leaves volumes intact. If you must clean a volume, use docker compose down then docker volume rm <project>_<volume> to remove exactly the volumes you want.
Key Takeaway
Memorize up, down, ps, logs, exec, run, build, and config as your daily drivers. docker compose config is the single most important command for validating your compose file before deploying. Never use down -v in production without explicitly confirming you want to destroy persistent data.

Environment Variables and Secrets — The Right Way vs. The Dangerous Way

Hardcoding passwords directly in docker-compose.yml is one of the most common and dangerous mistakes in real projects. If that file ever gets committed to a public repo — even accidentally — your credentials are exposed permanently (git history remembers everything).

The correct pattern is a .env file alongside your Compose file. Docker Compose automatically reads .env and substitutes ${VARIABLE_NAME} placeholders. You commit a .env.example with dummy values to your repo, and each developer (and your CI system) fills in the real .env locally. The actual .env lives in .gitignore.

For production, environment variables should come from your hosting platform's secret manager (AWS Secrets Manager, Doppler, HashiCorp Vault) injected at runtime — never baked into an image or a file on disk.

You can also use multiple Compose files with docker compose -f docker-compose.yml -f docker-compose.prod.yml up. The second file merges and overrides the first. This is how you maintain one base config and swap out dev-specific settings (like bind mounts and debug ports) for production-hardened ones without duplicating the whole file.

Secret exposure vectors: - Hardcoded in docker-compose.yml → committed to git → visible in git history forever - ENV in Dockerfile → baked into every image layer → visible in docker history and docker inspect - .env file not in .gitignore → committed to git → same as hardcoded - docker compose config output includes resolved secrets → if captured in CI logs, credentials are exposed

The right pattern for each environment: - Local development: .env file, in .gitignore, .env.example committed as template - CI/CD: secrets injected from CI platform secrets (GitHub Secrets, GitLab CI variables) - Production: secrets from a secrets manager (AWS Secrets Manager, Vault), mounted as files or injected as environment variables at deploy time

.env.exampleBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# .env.example — commit this to git as a template
# Copy to .env and fill in real values — NEVER commit .env itself

# Database credentials
DB_USER=your_db_username
DB_PASSWORD=your_secure_password_here
DB_NAME=your_app_db

# Auth
JWT_SECRET=replace_with_a_long_random_string_at_least_32_chars

# App config
NODE_ENV=development
API_PORT=3001
Output
# No output — this is a config file.
# Running `docker compose config` will show the fully resolved
# compose file with all variables substituted:
#
# $ docker compose config
# services:
# postgres_db:
# environment:
# POSTGRES_USER: your_db_username
# POSTGRES_PASSWORD: your_secure_password_here
# ...and so on
Secrets as a Lifecycle Problem
  • No rotation — changing a secret requires redeploying with a new .env file.
  • No audit trail — anyone with filesystem access can read the secret.
  • No revocation — if a secret is compromised, you cannot invalidate it without changing it everywhere.
  • No access control — the file is readable by any process running as the same user.
  • Secrets managers (Vault, AWS Secrets Manager) solve all four problems.
Production Insight
The failure scenario is not theoretical. In 2023, a major data breach occurred because a developer committed a docker-compose.yml with hardcoded AWS credentials to a public GitHub repository. The credentials were valid for 6 months before detection. Git history made the fix (removing the credentials in a later commit) irrelevant — the credentials were already scraped by automated scanners. The fix: pre-commit hooks that scan for secrets (git-secrets, truffleHog), .env files in .gitignore, and secrets managers for production.
Key Takeaway
Never hardcode secrets in docker-compose.yml or Dockerfiles. Use .env files for local development (in .gitignore). Use secrets managers for production. The .env.example pattern ensures every developer knows which variables are needed without exposing actual values. Pre-commit hooks catch accidental secret commits.
Secret Management Strategy by Environment
IfLocal development
UseUse .env file (in .gitignore) with .env.example as template
IfCI/CD pipeline
UseInject from platform secrets (GitHub Secrets, GitLab CI variables) — never log them
IfProduction deployment
UseUse secrets manager (AWS Secrets Manager, Vault) — inject at runtime, never bake into images
IfDocker Compose with Docker Swarm
UseUse docker secret create — secrets are encrypted at rest and mounted as files in /run/secrets/

.env File vs environment Key — Two Separate Mechanisms, One Common Confusion

A recurring theme in every Docker Compose workshop: developers confuse the .env file with the environment key inside a service. They think the .env file sets environment variables inside the container. It does not. The .env file exists only for the Compose file parser — it provides values for variable interpolation (${VAR}) in the YAML. The environment key inside a service is what actually sets environment variables inside the container at runtime.

The separation of concerns is critical: - .env → feeds the YAML parser during docker compose config and docker compose up. - environment: (or env_file:) → feeds the container's OS environment.

If you define DB_PASSWORD in .env, the container does not automatically have a DB_PASSWORD environment variable. You must explicitly pass it via environment: or env_file: in the service definition. The .env file just makes ${DB_PASSWORD} resolve in the YAML — that resolved value can then be assigned to an environment: key, but you have to write the mapping.

Example of the confusion: ``yaml # docker-compose.yml services: api: image: my-api environment: - DATABASE_URL=postgres://user:pass@db:5432/mydb ` If the DATABASE_URL contains a password, that password is hardcoded in the YAML. The correct approach is to put the password in .env` and reference it:

In .env: `` DB_PASSWORD=supersecret ` In docker-compose.yml: `yaml services: api: image: my-api environment: - DATABASE_URL=postgres://user:${DB_PASSWORD}@db:5432/mydb ` Now the resolved DATABASE_URL inside the container will contain 'supersecret'. The .env file provided the value, but the environment` key set the variable.

When to use env_file: instead: If you have many environment variables, you can use the env_file: directive to point to a file that gets parsed line-by-line and sets each variable inside the container. This file can be different from the .env file. For example, env_file: ./app.env will read key=value pairs from that file and set them in the container. This is useful when you have a pre-existing env file from a non-Docker setup.

Priority order (highest wins): 1. environment: key with inline values (takes precedence). 2. env_file: pointed file (overrides .env indirectly via ${VAR}). 3. .env file variables (used for interpolation only, not directly passed). 4. Environment variables from the host shell when running docker compose up (if the variable is referenced with ${VAR} but not defined in .env). 5. Defaults from YAML anchors or extension fields.

Deceptively dangerous pattern: Never rely on .env to pass environment variables to the container without explicitly mapping them. If you forget to add the environment: key, your container won't see the variables, and your app might fail with an obscure error like DB_PASSWORD not set while you stare at a .env file that clearly has the value. The .env file is for Compose, not for the container.

docker-compose.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# ── Example 1: environment key with hardcoded values (DO NOT DO THIS) ──
services:
  api:
    image: my-api
    environment:
      - DB_USER=admin
      - DB_PASSWORD=s3cret   # Hardcoded! Bad!

# ── Example 2: environment key using .env interpolation (CORRECT) ──
services:
  api:
    image: my-api
    environment:
      - DB_USER=${DB_USER}
      - DB_PASSWORD=${DB_PASSWORD}

# ── Example 3: env_file directive (alternative) ──
services:
  api:
    image: my-api
    env_file: ./api.env   # This file is read and each line becomes an env var

# ── Example 4: mixing .env interpolation with env_file ──
services:
  api:
    image: my-api
    env_file:
      - .env              # Can use .env as env_file (not recommended — confusing)
    environment:
      - SPECIAL_FLAG=true  # This overrides any same-named variable from env_file
Output
# Check what env vars the container gets:
$ docker compose config
services:
api:
image: my-api
environment:
DB_USER: admin
DB_PASSWORD: s3cret
# The .env file values are injected during config resolution
$ docker compose exec api env | grep DB_
DB_USER=admin
DB_PASSWORD=s3cret
Don't use .env as env_file unless you understand the implications
  • The .env file may contain temporary or CI-only variables that are not needed inside the container.
  • It mixes concerns: the file that controls Compose resolution also controls container environment.
  • Debugging becomes harder — you don't know which variables are for Compose and which are for the app.
  • If you ever remove a variable from .env that was used as environment: ${VAR}, the service still gets it via env_file, masking the missing interpolation.
Production Insight
The most common production bug from this confusion: a developer defines DATABASE_URL in .env but never maps it via environment: in the service. The container starts, the app tries to connect to the database, fails with 'cannot connect', and the developer spends an hour debugging before realizing the env var is not set inside the container. Always verify with docker compose exec <service> env | grep DATABASE or docker compose config to check that the variable is actually being passed.
Key Takeaway
The .env file is for the Compose YAML parser — it substitutes ${VAR} placeholders. The environment: key (or env_file:) is what actually sets variables inside the container. They are two separate mechanisms. Always map your .env variables into environment: if you want them inside the container. Use env_file: as a convenient alternative when you have a stand-alone env file.

Networking Between Containers — How Compose DNS Actually Works

This is where most intermediate developers have a fuzzy mental model, and it costs them hours of debugging. When Compose starts your services, it creates a virtual network and registers each service under its own DNS name — that name is simply the service name you defined in the YAML.

So when your API container wants to connect to Postgres, it doesn't use localhost (that points to itself) and it doesn't use an IP address (those change). It uses postgres_db:5432 — the service name and the container port, not the host-mapped port. This is a huge source of confusion: ports: '5432:5432' exposes port 5432 to your laptop. Other containers don't need that — they communicate on the internal network directly.

Multiple networks let you enforce security boundaries at the network layer, not just at the application layer. In our example, the frontend container literally cannot reach the database — there's no route. Even if someone finds an XSS vulnerability in your frontend, they can't pivot directly to the database because Compose's networking won't allow it.

Use docker compose exec api_server sh to shell into a running container and test DNS resolution live with nslookup postgres_db or curl http://api_server:3001/health from the frontend container.

DNS resolution internals: Compose uses Docker's embedded DNS server at 127.0.0.11. When a container resolves a service name, the request goes to this DNS server, which maps the service name to the container's internal IP on the appropriate network. If a container is on multiple networks, it can resolve services on all of them. If a container is on network A but not network B, it cannot resolve services that are only on network B.

Port mapping confusion: The ports directive maps HOST:CONTAINER. Your database connects at localhost:5432 from your laptop (host port). Your API connects at postgres_db:5432 from inside Docker (container port). These are completely separate network paths. Using the host port from inside a container (localhost:5432) either fails or connects to the wrong thing.

network-debugging-commands.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# ── Inspecting Compose Networks ──────────────────────────────────────

# List all networks Compose created for this project
docker network ls
# Output includes: app_backend_network, app_frontend_network

# Inspect who is on the backend network and their internal IPs
docker network inspect app_backend_network

# Shell into the running API container to test connectivity
docker compose exec api_server sh

# Inside the container — test that DNS resolves the DB service name
nslookup postgres_db
# Expected output:
# Server:    127.0.0.11      <-- Docker's embedded DNS resolver
# Address:   127.0.0.11:53
# Name:      postgres_db
# Address:   172.20.0.2      <-- Internal IP (changes every run, that's why we use names)

# Test that the DB is reachable on its CONTAINER port (not the host-mapped one)
curl -v telnet://postgres_db:5432
# This should open a TCP connection — if it fails, check your 'networks' config

# Verify the frontend CANNOT reach postgres directly (network isolation working)
docker compose exec web_frontend sh
nslookup postgres_db
# Expected: nslookup: can't resolve 'postgres_db'
# This is CORRECT — it proves your network isolation is working
Output
# From inside api_server container:
$ nslookup postgres_db
Server: 127.0.0.11
Address: 127.0.0.11:53
Name: postgres_db
Address: 172.20.0.2
# From inside web_frontend container:
$ nslookup postgres_db
nslookup: can't resolve 'postgres_db'
# Correct! Frontend is on a different network segment.
Compose Networks as Virtual LANs
  • Compose creates a bridge network for the project and registers each service with Docker's embedded DNS server.
  • Each container's /etc/resolv.conf points to 127.0.0.11 — Docker's DNS proxy.
  • The DNS proxy maps service names to container IPs on the correct network.
  • This is automatic — you never configure DNS manually. It is a Compose/Docker Engine feature.
Production Insight
The localhost confusion is the single most common networking bug in Docker Compose. Developers write DATABASE_URL=postgres://user:pass@localhost:5432/db and wonder why the API cannot connect. localhost inside a container refers to the container itself, not the host machine. The correct URL uses the service name: postgres://user:pass@postgres_db:5432/db. This bug wastes hours because the error message (ECONNREFUSED) does not hint at the misconfiguration.
Key Takeaway
Containers communicate by service name on the internal Docker network. Never use localhost between containers. Never use the host-mapped port for container-to-container traffic. Use multiple networks to enforce tier isolation — if the frontend cannot resolve the database name, an attacker who compromises the frontend cannot pivot to the database.
Container Networking Debug Decision Tree
IfService cannot resolve another service by name
UseCheck if both services share at least one network. Run docker compose exec <svc> nslookup <target>
IfService resolves the name but connection is refused
UseCheck if the target service's healthcheck is passing. Check if you are using the container port, not the host port.
IfConnection works on host but not from another container
UseYou are using localhost or 127.0.0.1 — use the service name instead.
IfFrontend can reach the database directly (should not happen)
UseCheck network isolation — both services should not share the same network if they should be isolated.

Profiles and Overrides — Running Different Stacks for Dev, Test and CI

A subtle but powerful Compose feature is profiles. You might have services that should only run in certain contexts — a database admin UI like pgAdmin in development, a mock email server in testing, or a metrics exporter in production. Profiles let you tag services and opt into them at runtime.

With docker compose --profile dev up, only services tagged with the dev profile (plus services with no profile) start. Your CI pipeline can run docker compose --profile test up --abort-on-container-exit to spin up integration test dependencies, run tests, and tear down — without launching the full dev UI tooling.

Compose file overrides are the production deployment pattern. You maintain a docker-compose.yml as the base truth and a docker-compose.prod.yml that overrides just the production-specific bits: replaces bind mounts with proper volumes, removes debug ports, adds resource limits, and switches to pre-built images instead of local builds. This avoids the 'it works on my machine' trap without duplicating hundreds of lines of YAML.

Override merge behavior: When multiple files are specified with -f, Compose deep-merges them. Arrays (like ports, volumes, environment) are replaced, not appended — if the override file specifies volumes: [], the base file's volumes are completely replaced. Scalar values (like image, restart) are overwritten. This is why docker compose config is essential — it shows the final merged result before you deploy.

Profile use cases beyond dev/prod: - Testing: profile test for mock services (WireMock, LocalStack) - Monitoring: profile monitoring for Prometheus, Grafana, cAdvisor - Debugging: profile debug for a debugger sidecar or log aggregator - Migration: profile migration for a one-shot database migration container

docker-compose.prod.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# docker-compose.prod.yml — Production overrides
# Usage: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
#
# This file MERGES with docker-compose.yml — only specify what changes.

version: '3.9'

services:

  api_server:
    # In prod, use a pre-built image from your registry instead of local build
    image: ghcr.io/your-org/api-server:${APP_VERSION:-latest}
    build: !reset null    # Remove the build config from the base file
    volumes: []           # Remove the dev bind mount — no hot reload in prod
    environment:
      NODE_ENV: production
    deploy:
      resources:
        limits:
          cpus: '0.50'      # Cap CPU usage per container instance
          memory: 512M      # Cap memory — prevents runaway leaks taking down the host
        reservations:
          memory: 256M
    logging:
      driver: "json-file"   # Structured logs for log aggregators
      options:
        max-size: "10m"     # Rotate logs — don't fill the disk
        max-file: "5"

  web_frontend:
    image: ghcr.io/your-org/web-frontend:${APP_VERSION:-latest}
    build: !reset null
    volumes: []

  # pgAdmin only runs in dev — it's tagged with 'dev' profile
  pgadmin:
    image: dpage/pgadmin4:latest
    profiles:
      - dev               # Only starts when: docker compose --profile dev up
    ports:
      - "5050:80"
    environment:
      PGADMIN_DEFAULT_EMAIL: admin@local.dev
      PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:-localdevonly}
    networks:
      - backend_network
Output
# Running with production overrides:
$ docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
[+] Running 3/3
✔ Container app-postgres_db-1 Healthy
✔ Container app-api_server-1 Started # Using registry image, no bind mount
✔ Container app-web_frontend-1 Started # Using registry image
# Note: pgadmin did NOT start — it requires --profile dev
# Running dev stack with pgAdmin:
$ docker compose --profile dev up
[+] Running 4/4
✔ Container app-postgres_db-1 Healthy
✔ Container app-api_server-1 Started
✔ Container app-web_frontend-1 Started
✔ Container app-pgadmin-1 Started # Now included
Override Files as Inheritance
  • Separate files duplicate 80% of the configuration — changes must be applied to both.
  • Override files share the base and only specify differences — single source of truth.
  • Override files can be combined: -f base.yml -f monitoring.yml -f prod.yml
  • docker compose config shows the merged result — no surprises at deploy time.
Production Insight
The merge behavior for arrays (ports, volumes, environment) is replacement, not appending. If your base file has volumes: ['./api:/app'] and your prod override has volumes: [], the bind mount is completely removed. If you want to add a volume in the override without removing the base ones, you must re-declare all volumes in the override file. This catches many teams off guard — always run docker compose config before deploying to verify the merged result.
Key Takeaway
Profiles let you tag services for conditional startup. Override files let you maintain one base config and swap environment-specific settings. Always run docker compose config to verify the merged result before deploying. Array replacement in overrides is the most common source of surprise — re-declare all items if you need to add, not replace.
When to Use Profiles vs Override Files
IfService should only run in certain contexts (dev, test, monitoring)
UseUse profiles — tag the service with profiles: [dev] and start with --profile dev
IfSame services but different configuration per environment (dev vs prod)
UseUse override files — base docker-compose.yml + docker-compose.prod.yml
IfBoth: different services AND different config per environment
UseUse both — profiles for optional services, override files for environment-specific config
IfCI pipeline needs to run tests then tear down
UseUse profile test with docker compose --profile test up --abort-on-container-exit

Volumes That Survive the Wreck — Persistent Data You Can Actually Trust

Every junior dev learns the hard way: docker compose down is a liar. It doesn't kill your data unless you tack on -v. But here's the real problem — most teams treat volumes like magic bags that hold stuff. They're not. They're mounts with specific behaviors, and if you don't understand binding vs named volumes vs tmpfs, your database will vanish when your CI runner resets. Why it matters: containers are cattle, but your Postgres data is a pet. Named volumes outlive containers, survive restarts, and can be shared across services without permission nightmares. Bind mounts are for development — they mirror your host filesystem, but they break in production when your orchestration layer doesn't have the same paths. tmpfs is for secrets you want to incinerate on container stop. The how: define volumes under the top-level volumes: key, then mount them with source and target in each service. Always use driver: local unless you're connecting to NFS or cloud block storage. Never mount a macOS or Windows host path into a Linux container expecting consistent filesystem behavior — you'll get permission issues and symlink garbage. Test your persistence model with a forced kill, not a graceful stop.

production-postgres-with-volumes.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// io.thecodeforge — devops tutorial

version: "3.8"

services:
  postgres:
    image: postgres:15-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: orders   # database name the app expects
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${DB_PASSWORD}   # never hardcode
    volumes:
      - postgres_data:/var/lib/postgresql/data   # named volume survives down/up
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d orders"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  postgres_data:
    driver: local   # default, but explicit is better than implicit
Output
Creating network "project_default" with driver "bridge"
Creating volume "project_postgres_data" with local driver
Creating project_postgres_1 ... done
# Data persists after:
$ docker compose down
$ docker compose up -d
$ docker exec -it project_postgres_1 psql -U app -d orders -c "SELECT count(*) FROM orders;"
count
-------
1423
Production Trap:
If you use bind mounts in production (e.g., ./data:/var/lib/postgresql/data), your orchestrator will fail when the host path doesn't exist on the new node. Always use named volumes for stateful workloads that move.
Key Takeaway
Named volumes are your state insurance policy. Bind mounts are for hot-reloading code, not for data you care about.

Custom Networks Stop the Noise — Why Port Conflicts Are a Self-Inflicted Wound

Default bridge networks work fine until two apps both claim port 8080. Then you learn: Compose creates a dedicated network per project, but by default every service can talk to everything else. That's fine for a three-service app. It's a disaster when you're running a dozen microservices and one misconfigured container starts DNS-recycling requests into the wrong database. The fix: separate your traffic into logical networks. You don't need a single flat plane. Split internal API communication from public-facing ingress traffic, and isolate data-plane traffic from admin or monitoring channels. Why this works: Compose's internal DNS resolves container names to IPs, but on a shared network, any container can reach any other service. By creating an internal or frontend network and attaching only the relevant services, you enforce zero-trust boundaries at the network level — no firewall rules needed. The how: define networks under the top-level networks: key, then assign each service to specific networks with networks: [...]. Use driver: bridge for single-host setups; use attachable: false to prevent stray containers from joining. For extra paranoia, mark the internal network as internal: true — no outbound internet access. Test with docker compose exec <service> ping <other-service> to verify isolation.

multi-network-microservices.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// io.thecodeforge — devops tutorial

version: "3.8"

services:
  api-gateway:
    image: nginx:alpine
    ports:
      - "443:443"   # only gateway touches the outside world
    networks:
      - frontend   # public-facing
      - backend    # can talk to services, but services can't reply publicly

  orders-service:
    image: orders:latest
    expose:
      - "3000"   # no host port — only reachable via Compose DNS
    networks:
      - backend
      - db-network   # isolated from frontend traffic

  postgres:
    image: postgres:15-alpine
    networks:
      - db-network   # database is invisible to api-gateway entirely

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge
    internal: false   # still allowed to reach internet for updates
  db-network:
    driver: bridge
    internal: true    # no internet — database doesn't need it
Output
$ docker compose exec api-gateway ping orders-service
PING orders-service (172.20.0.3): 56 data bytes
64 bytes from 172.20.0.3: icmp_seq=1 ttl=64 time=0.123ms
$ docker compose exec postgres ping api-gateway
ping: bad address 'api-gateway' # network isolation works
$ docker compose exec postgres ping google.com
ping: bad address 'google.com' # internal network blocks internet
Senior Shortcut:
Name your networks after their purpose, not their position. frontend, backend, db-network beats net1, net2, net3 — you'll thank yourself in six months when you need to audit traffic flow.
Key Takeaway
Assign every service to the smallest set of networks it needs. If a service doesn't need to talk to another, isolate it. Compose DNS doesn't cross network boundaries.

Healthchecks That Actually Fail — Stop Waiting for Containers That Aren't Ready

You've seen the pattern: a Dockerfile with CMD to start the app, and Compose with depends_on. Then your Rails app starts before Postgres finishes initializing, throws a connection error, and crashes. depends_on only waits for the container to start — not for the process inside to be healthy. That's the gap. Why it matters: in production, your orchestrator won't route traffic to a container that hasn't passed its healthcheck. If you don't define one, you're relying on timing luck. And timing luck fails under load, on cold starts, and when your database image upgrades to a new major version with a slower init sequence. The fix: add a healthcheck block to every service that depends on another. Use start_period to give slow services time to initialize before the health checker starts hammering them. Use retries and interval that match your SLAs. The how: define healthcheck at the service level with a test command that returns exit code 0 for healthy, 1 for unhealthy. For HTTP services, use curl -f http://localhost/health. For databases, use the built-in client tool (e.g., pg_isready, mongosh --eval). Then use depends_on with condition: service_healthy — this makes Compose wait until the dependency passes its check before starting the dependent service. Without condition, depends_on is just a startup order hint, not a guarantee.

healthchecked-dependency-chain.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// io.thecodeforge — devops tutorial

version: "3.8"

services:
  postgres:
    image: postgres:15-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d orders"]
      interval: 5s
      timeout: 3s
      retries: 5
      start_period: 30s   # gives Postgres time to init before grace period

  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 3
      start_period: 10s

  app:
    image: my-app:latest
    depends_on:
      postgres:
        condition: service_healthy   # won't start until pg_isready passes
      redis:
        condition: service_healthy
    ports:
      - "3000:3000"
Output
$ docker compose up -d
Creating network "project_default" with driver "bridge"
Creating project_postgres_1 ... done
Creating project_redis_1 ... done
# app waits...
project_postgres_1 | PostgreSQL init process complete; ready for connections.
project_postgres_1 | 2024-03-15 10:00:05.123 UTC [1] LOG: database system is ready to accept connections
project_redis_1 | 1:M 15 Mar 2024 10:00:03.456 * Ready to accept connections
Creating project_app_1 ... done
$ docker compose ps
NAME STATUS PORTS
project_app_1 Up (healthy) 0.0.0.0:3000->3000/tcp
project_postgres_1 Up (healthy) 5432/tcp
project_redis_1 Up (healthy) 6379/tcp
Never Do This:
Don't rely on depends_on without condition: service_healthy. It only guarantees the container started, not that the process inside is ready. You'll get race conditions on deployment day.
Key Takeaway
Healthchecks turn startup order hints into real dependency guarantees. Every service that waits for another needs condition: service_healthy — or you're gambling.

Install Docker Compose on Ubuntu — Two Paths, One Reliable Outcome

Docker Compose is not installed by default on Ubuntu. The apt package lags behind upstream releases, so you must install manually to get current features. The recommended method downloads the binary directly from the GitHub releases page. First, check your kernel version with uname -s and architecture with uname -m. Then fetch the binary for your platform using curl -SL https://github.com/docker/compose/releases/latest/download/docker-compose-linux-x86_64 -o /usr/local/bin/docker-compose. Apply executable permissions with chmod +x /usr/local/bin/docker-compose. Verify the installation with docker-compose --version. This binary approach avoids stale package versions and gives you access to Compose V2 features like docker compose (without the hyphen) commands. Always pin to a specific version in CI/CD pipelines to prevent unexpected breaking changes.

Example.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
// io.thecodeforge — devops tutorial

// Step 1: Download the Compose binary
curl -SL \
  https://github.com/docker/compose/releases/latest/download/docker-compose-linux-x86_64 \
  -o /usr/local/bin/docker-compose

// Step 2: Apply executable permissions
chmod +x /usr/local/bin/docker-compose

// Step 3: Verify version
// Expected output: Docker Compose version v2.32.4
docker-compose --version
Output
Docker Compose version v2.32.4
Production Trap:
Never pipe curl output directly into bash as root. Always download to a temp file, verify checksums, then move the binary. One compromised GitHub release or MITM attack could inject malware into your deployment pipeline.
Key Takeaway
Always download the binary manually and pin to a specific version — never rely on the system package manager for Compose.

Step 2: Download the Software — Where Most Install Guides Go Wrong

Downloading Docker Compose looks trivial but hides a critical failure point: architecture mismatch. Many copy-paste commands assume x86_64, but if your Ubuntu machine runs on ARM (like a Raspberry Pi or AWS Graviton), the binary will fail silently. Always detect the architecture dynamically using uname -m and substitute it into the download URL. For ARM systems, replace x86_64 with aarch64. The full URL pattern is https://github.com/docker/compose/releases/latest/download/docker-compose-linux-{arch}. After download, verify the SHA256 checksum against the official release page — a corrupted download will produce obscure "Exec format error" messages. This step prevents wasting hours debugging container orchestration when the real problem is a mismatched binary architecture. Store the downloaded binary in /usr/local/bin to ensure it's in the system PATH without modifying environment variables.

Example.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// io.thecodeforge — devops tutorial

// Step 2: Dynamic architecture detection
ARCH=$(uname -m | sed 's/x86_64/x86_64/;s/aarch64/aarch64/')

// Download with correct architecture
curl -SL \
  "https://github.com/docker/compose/releases/latest/download/docker-compose-linux-${ARCH}.tar.gz" \
  -o /tmp/docker-compose.tar.gz

// Extract the binary
tar -xzf /tmp/docker-compose.tar.gz -C /usr/local/bin/

// Clean up
rm /tmp/docker-compose.tar.gz
Output
No output on success — verify with `docker-compose version`
Production Trap:
Never hardcode x86_64. ARM is common on cloud VMs (AWS Graviton, Oracle Ampere). A mismatched binary gives no clear error — just crashes. Always detect architecture programmatically.
Key Takeaway
Use uname -m to detect architecture dynamically — hardcoding x86_64 will break on ARM servers.
● Production incidentPOST-MORTEMseverity: high

Staging Environment Down for 3 Hours — depends_on Without Healthcheck

Symptom
docker compose up reports all containers as 'Started', but the API container exits with code 1 within 10 seconds. Logs show: Error: connect ECONNREFUSED 172.20.0.2:5432. The frontend shows a blank page because the API is unreachable. Restarting the stack sometimes works, sometimes does not — non-deterministic failures.
Assumption
Team assumed a PostgreSQL configuration error or a corrupted database volume. They destroyed the volume (docker compose down -v), recreated it, and the problem persisted. Second assumption: a networking issue between containers. They tested DNS resolution and connectivity — both worked when the DB was already running.
Root cause
The docker-compose.yml used plain depends_on: postgres_db for the API service. This tells Compose to start the postgres_db container before the api_server container, but it does not wait for PostgreSQL to finish initialization. PostgreSQL takes 2-8 seconds to start accepting connections after its started during this window, tried to connect immediately, and crashed. On fast machines (CI runners with SSDs), the timing sometimes worked. On slower machines, it always failed.
Fix
container process begins. The API container 1. Added a healthcheck block to postgres_db using pg_isready. 2. Changed api_server depends_on from plain service name to condition: service_healthy. 3. Added restart: unless-stopped to api_server as a safety net for transient failures. 4. Added a startup retry loop in the application code as defense in depth. 5. Added docker compose config validation to CI to catch missing healthchecks.
Key lesson
  • depends_on controls start order, not readiness. Always pair it with a healthcheck.
  • Non-deterministic startup failures are almost always race conditions, not configuration errors.
  • Destroying the database volume to fix a startup race condition is a dangerous diagnostic step — you lose data.
  • Defense in depth: healthcheck at the Compose level AND retry logic at the application level.
  • CI runners are often faster than developer machines, hiding timing-dependent bugs.
Production debug guideSystematic debugging paths for common Compose failures.6 entries
Symptom · 01
Container exits immediately after docker compose up.
Fix
Check logs first: docker compose logs <service>. If the process exits before writing logs, run the container interactively: docker compose run --rm <service> sh and execute the CMD manually to see the error.
Symptom · 02
Containers cannot reach each other by service name.
Fix
Verify both containers are on the same network: docker network inspect <project>_<network>. Test DNS resolution: docker compose exec <service> nslookup <target-service>. If they are on different networks, add a shared network or use networks: [shared_net] on both.
Symptom · 03
Changes to code are not reflected in the running container.
Fix
Verify bind mount is correct: docker compose exec <service> ls /app. Check that the host path is correct (use absolute paths or $(pwd)). On macOS, check Docker Desktop file sharing settings. Check that the container is not using a cached layer — rebuild with docker compose build --no-cache.
Symptom · 04
docker compose up works on one machine but fails on another.
Fix
Check for hardcoded paths in volumes. Check for missing .env file (docker compose config shows resolved variables). Check Docker Engine version compatibility. Check for port conflicts on the host: lsof -i :<port>.
Symptom · 05
Database data lost after docker compose down.
Fix
Check if the volume is a named volume (declared under top-level volumes:) or an anonymous volume. Named volumes survive docker compose down. Only docker compose down -v removes them. If using a bind mount, check that the host directory exists and has correct permissions.
Symptom · 06
docker compose build is extremely slow.
Fix
Check build context size — run du -sh . in the build directory. If large, add a .dockerignore file. Check layer caching: docker compose build should reuse cached layers. If every build reinstalls dependencies, ensure COPY package.json/requirements.txt comes before COPY . . in the Dockerfile.
★ Docker Compose Triage Cheat SheetFirst-response commands when a Compose-managed stack fails. No theory — just actions.
Container exits immediately or crashes in a restart loop.
Immediate action
Check logs and exit code.
Commands
docker compose logs --tail 100 <service>
docker compose ps -a
Fix now
Exit code 0 = process completed normally (CMD is wrong). Exit code 1 = application error (check logs). Exit code 137 = OOM killed (increase memory limit). Exit code 139 = segfault (check base image).
Service cannot connect to database or another service.+
Immediate action
Verify network membership and DNS resolution.
Commands
docker compose exec <service> nslookup <target-service>
docker network inspect <project>_default
Fix now
Use service name (not localhost) and container port (not host port) in connection strings. Ensure both services share at least one network.
Port already in use error on docker compose up.+
Immediate action
Find what is using the port on the host.
Commands
lsof -i :<port>
docker compose ps -a | grep <port>
Fix now
Kill the conflicting process, change the host-side port mapping (left side of ports: '8080:80'), or stop the other Compose project: docker compose -p <other-project> down
Environment variables not being picked up by a service.+
Immediate action
Verify .env file exists and variables are resolved.
Commands
docker compose config | grep -A 20 <service>
docker compose exec <service> env | grep <VARIABLE>
Fix now
Ensure .env is in the same directory as docker-compose.yml. Check for typos in ${VARIABLE_NAME}. Run docker compose config to see fully resolved config.
Named volume data is corrupted or needs to be reset.+
Immediate action
Back up the volume before any destructive action.
Commands
docker volume inspect <project>_postgres_data
docker run --rm -v <project>_postgres_data:/data -v $(pwd):/backup alpine tar czf /backup/volume-backup.tar.gz /data
Fix now
docker compose down -v removes all named volumes. Use selectively: docker volume rm <project>_postgres_data to remove only the database volume.
docker run vs Docker Compose
Aspectdocker run (manual)Docker Compose
Setup complexityOne command per container, manual flags every timeSingle YAML file, one command to start everything
NetworkingMust create networks manually and attach each containerAuto-creates a shared network; service names resolve via DNS
Startup orderYou remember the order yourselfDeclarative depends_on with optional health check conditions
Environment configLong --env-file or -e flags per commandBuilt-in .env file support and variable interpolation
Volume managementExplicit -v flags, easy to forget or mistypeNamed volumes and bind mounts declared in YAML
ReproducibilityLow — you have to document the exact commandsHigh — the file IS the documentation
Scaling a serviceRun more containers manually, manage names yourselfdocker compose up --scale api_server=3 (basic, no LB)
Best suited forQuick one-off containers, learning Docker basicsLocal dev, integration testing, simple multi-service deployments
Not suited forN/A — even single containers benefit from ComposeLarge-scale production orchestration (use Kubernetes for that)

Key takeaways

1
Docker Compose is a declaration of the desired state of your entire app stack
you describe it once in YAML and docker compose up handles creation, networking, and ordering every time.
2
depends_on controls start order, not start *readiness
always pair it with a healthcheck and condition: service_healthy to prevent race conditions when your app starts before the database is accepting connections.
3
Containers communicate by service name on the internal Docker network
never use localhost between containers, and never use the host-mapped port for container-to-container traffic.
4
Use multiple Compose files (-f flag) to maintain one base config and override only what changes between environments
cleaner and less error-prone than separate files for dev and prod.
5
Never hardcode secrets in Compose files or Dockerfiles. Use .env files for local dev (in .gitignore) and secrets managers for production. Pre-commit hooks catch accidental secret commits.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What's the difference between `depends_on` and a health check condition ...
Q02SENIOR
If you have two services in a Compose file and they can't communicate wi...
Q03SENIOR
You have a docker-compose.yml that works perfectly for local development...
Q04JUNIOR
Explain the difference between named volumes, bind mounts, and anonymous...
Q05SENIOR
Your CI pipeline runs docker compose config and sees the DATABASE_URL wi...
Q06SENIOR
You have a Compose file with 8 services. In production, 2 should not run...
Q01 of 06SENIOR

What's the difference between `depends_on` and a health check condition in Docker Compose, and when would a plain `depends_on` cause a race condition in production?

ANSWER
depends_on only controls container start order — it does not wait for the service to be ready. A plain depends_on: postgres_db starts your API as soon as the Postgres container starts, not when Postgres is actually accepting connections (which takes 2-8 seconds). The race condition: API connects, Postgres isn't ready yet, API crashes. Fix: add a healthcheck to Postgres using pg_isready, then use condition: service_healthy in depends_on.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the difference between Docker Compose and Kubernetes?
02
Does `docker compose down` delete my database data?
03
Can I use Docker Compose in production?
04
How do I share environment variables between services without repeating them?
05
What happens if I run docker compose up twice?
N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Notes here come from systems that actually shipped.

Follow
Verified
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
🔥

That's Docker. Mark it forged?

18 min read · try the examples if you haven't

Previous
Docker Volumes and Networking
9 / 18 · Docker
Next
Docker Registry and Docker Hub