Docker Compose for Multi-Container Apps: Services, Networks, Volumes, Health Checks and Production Settings

Key takeaways

How to run a multi-container application with Docker Compose: how services find each other by name, why depends_on alone does not wait for a database, when to use named volumes versus bind mounts, how .env interpolation really works, and the production traps (published database ports, fixed ports with replicas, stale Postgres volumes).

What this post covers

Docker Compose describes a set of containers, their networks and their volumes in one YAML file, and starts them with one command. This post goes through a typical web stack (frontend, API, PostgreSQL, Redis, Nginx) and focuses on the parts that cause most real problems: how containers find each other, why start order is not readiness, where data actually lives, how environment variables flow into the file, and what changes when the same file is used on a server.

The examples use the docker compose plugin (Compose v2). The old Python docker-compose binary (with a hyphen) is end-of-life; most commands are the same, but behavior differs in details such as container naming (project-web-1 instead of project_web_1).

What Compose does, and what it does not

For each project (by default, the directory name), Compose creates:

  • one container per service (or several, if you scale it),
  • a default bridge network on which every service is reachable by its service name,
  • the named volumes you declare, which survive docker compose down.

What Compose does not do is just as important. It runs on one Docker host, so it cannot move containers to another machine when a server dies. It does not restart a container that its health check marks unhealthy; a failing health check only changes the status shown in docker compose ps. And it does not roll out new versions gradually. Those are the reasons teams move to an orchestrator, not the size of the YAML file.

Installation

# Docker Desktop (Windows, macOS): Compose is included
# Linux, with Docker's apt repository (download.docker.com) configured
sudo apt install docker-compose-plugin
# Verify
docker compose version

docker-compose-plugin comes from Docker’s own package repository. If apt cannot find it, the machine is using the distribution’s Docker packages; either add Docker’s repository as described in the official install guide, or install the distribution’s package (on recent Ubuntu, docker-compose-v2). Avoid mixing both sources on one host, since they install different docker engines.

Basics

docker-compose.yml

services:
  web:
    image: nginx:1.27
    ports:
      - "8080:80"
    volumes:
      - ./html:/usr/share/nginx/html
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: mydb
    volumes:
      - postgres-data:/var/lib/postgresql/data
volumes:
  postgres-data:

Older examples start with version: '3.8'. Compose v2 implements the Compose Specification and ignores that key; recent versions print the attribute 'version' is obsolete, it will be ignored, please remove it to avoid potential confusion. It can simply be deleted.

The two volumes entries are different things. ./html:/usr/share/nginx/html is a bind mount: a host directory shown inside the container, which is what you want for files you edit. postgres-data:/var/lib/postgresql/data refers to a named volume, declared at the bottom and managed by Docker. It is the right choice for database files: it keeps working when the project directory moves, it avoids file-permission and performance problems of bind mounts on Docker Desktop, and plain docker compose down does not delete it.

Pin image tags (nginx:1.27, postgres:16) rather than using latest. With latest, docker compose pull can move the whole stack to a new major version without any change in the file.

Commands

docker compose up            # start in the foreground, logs of all services
docker compose up -d         # start in the background
docker compose ps            # status, including health
docker compose logs -f web   # follow one service's logs
docker compose exec db psql -U postgres   # shell/command in a running container
docker compose down          # stop and remove containers and the network (volumes stay)
docker compose config        # print the fully resolved file

docker compose config deserves more use than it gets. It shows the file after variable interpolation and after merging override files, so it answers “what will actually run?” faster than reading three YAML files side by side.

Hands-on example: full-stack app

# docker-compose.yml
services:
  frontend:
    build:
      context: ./frontend
    environment:
      - REACT_APP_API_URL=http://localhost/api
    depends_on:
      - backend
  backend:
    build:
      context: ./backend
    environment:
      - DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@db:5432/mydb
      - REDIS_URL=redis://redis:6379
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: mydb
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d mydb"]
      interval: 5s
      timeout: 5s
      retries: 10
  redis:
    image: redis:7-alpine
    volumes:
      - redis-data:/data
  nginx:
    image: nginx:1.27
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      - frontend
      - backend
volumes:
  postgres-data:
  redis-data:

A few lines here carry most of the design:

  • @db:5432 and redis://redis:6379 use service names as host names. Compose’s embedded DNS resolves them on the project network. Inside a container, localhost is that container itself, so localhost:5432 from the backend reaches nothing. This is the single most common first error with Compose, and it shows up as connection refused or could not connect to server.
  • Only Nginx publishes a port. The backend, database and Redis talk over the internal network and do not need ports: at all. Publishing 5432:5432 is convenient for a local GUI client, but on a server it exposes the database on every interface. Docker writes its own iptables rules for published ports, so a host firewall such as ufw does not block them the way people expect. If you need host access during development, bind to loopback: "127.0.0.1:5432:5432".
  • REACT_APP_API_URL points at the host, not at backend. The frontend’s JavaScript runs in the user’s browser, which is outside the Docker network and cannot resolve backend. Routing /api through Nginx gives the browser one origin and avoids CORS configuration.
  • :ro on the Nginx config mounts it read-only, so a process in the container cannot modify the host file.

One Nginx-specific trap: with proxy_pass http://backend:8000;, Nginx resolves backend once, at startup. If the backend container does not exist yet, Nginx exits with host not found in upstream "backend", and if the backend is recreated with a new IP, Nginx keeps sending traffic to the old one until it is reloaded. depends_on handles the first case; for the second, restart Nginx after recreating the backend, or use a resolver 127.0.0.11 directive with a variable in proxy_pass so Nginx re-resolves the name.

Environment variables

.env file

# .env (next to docker-compose.yml; do not commit real secrets)
POSTGRES_PASSWORD=change-me
API_PORT=8000

Usage

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
  backend:
    env_file:
      - ./backend.env
    environment:
      PORT: ${API_PORT:-8000}

Two mechanisms are easy to mix up. The .env file in the project directory is used for interpolation: Compose replaces ${POSTGRES_PASSWORD} in the YAML with its value before creating containers. The file is not automatically passed into any container. env_file: is the opposite: it injects the variables of that file into the container’s environment, without substituting anything in the YAML.

The modifiers help catch mistakes early. ${VAR:-default} uses a default when the variable is unset or empty. ${VAR:?message} stops with an error, which is better than starting Postgres with an empty password variable and getting a confusing failure later. Variables set in the shell take precedence over .env, which explains the occasional “I changed .env but nothing happened”: an old export in the terminal still wins.

Also remember that docker compose config prints interpolated values, secrets included, so do not paste its output into a public issue.

Networking

Custom networks

services:
  frontend:
    networks:
      - frontend-network
  backend:
    networks:
      - frontend-network
      - backend-network
  db:
    networks:
      - backend-network
networks:
  frontend-network:
  backend-network:

With only the default network, every service can reach every other service. Separate networks restrict that: here the frontend can reach the backend, and the backend can reach the database, but the frontend has no route to the database at all, and db does not even resolve from inside the frontend container. It is a cheap layer of defense for a stack that runs untrusted or third-party components. Declaring networks: on a service removes it from the default network, so a service that you forget to attach to the right network becomes unreachable, and the error is a DNS failure (Name or service not known, getaddrinfo ENOTFOUND db), not a refused connection.

Health checks and startup order

depends_on on its own only controls start order: Compose starts the database container first, but does not wait until the database inside it is ready to accept connections. A database can take several seconds to initialize, especially on first start when it creates its data directory, and an app that connects immediately crashes with “connection refused”. Pair a health check on the dependency with condition: service_healthy:

services:
  db:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s   # failures during this grace period don't count
  backend:
    image: myapp/backend
    depends_on:
      db:
        condition: service_healthy   # wait until the check passes
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 30s
      timeout: 10s
      retries: 3

The $$ escapes the dollar sign, so the variable is expanded inside the container rather than by Compose when it reads the file. The health check command also has to exist in the image: curl is missing from many slim and distroless images, so use a tool that is present, or a small check built into the app. Even with this in place, the application should still retry its database connection on startup. Health checks only gate the first start, and docker compose up does not restart a service whose health check starts failing later.

A health check that is too strict is its own problem. If the backend’s check calls an endpoint that also verifies the database, Redis and a third-party API, one slow dependency marks the backend unhealthy, and everything that depends_on it with service_healthy refuses to start. Keep the check to “this process is up and serving”.

Postgres volumes and the “password did not change” problem

The official Postgres image runs its initialization only when the data directory is empty. On that first start it creates the user and database from POSTGRES_USER, POSTGRES_PASSWORD and POSTGRES_DB, and runs any scripts in /docker-entrypoint-initdb.d/. On every later start the log says PostgreSQL Database directory appears to contain a database; Skipping initialization, and those variables are ignored.

In practice this is the Compose issue I see asked about most. Someone changes the password in .env, recreates the containers, and the backend fails with password authentication failed for user "postgres", because the database in the volume still has the old password and only the application’s connection string changed. The same thing happens when a new init script is added and never runs. For a development database the fix is docker compose down -v (which deletes the data). For anything real, change the password inside Postgres with ALTER USER postgres PASSWORD '...' and then update .env.

Upgrading postgres:16 to postgres:17 on an existing volume fails for a related reason: the data directory format is tied to the major version, and the server refuses to start with a message that the database files are incompatible. Major upgrades need pg_dump/restore or pg_upgrade, not a tag change.

Override files per environment

Compose merges multiple files, later ones overriding earlier ones. A shared base file plus small environment-specific files avoids copying the whole configuration:

# docker-compose.override.yml  (loaded automatically by `docker compose up`)
services:
  backend:
    command: npm run dev          # hot reload in development
    environment:
      NODE_ENV: development
    volumes:
      - .:/app                    # bind mount for live editing
      - /app/node_modules         # keep the container's node_modules
docker compose up                                          # base + override (development)
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d   # explicit production set

docker-compose.override.yml is picked up automatically when present, which makes it the natural place for development-only settings. For production, name the files explicitly with -f so the development override is not applied by accident.

The /app/node_modules line is worth understanding rather than copying. The bind mount .:/app hides everything the image put in /app, including the node_modules installed during the build. The second entry mounts an anonymous volume at /app/node_modules, which masks that one path again and keeps the container’s own modules (built for Linux) instead of the host’s (possibly built for macOS or Windows). The downside is that the anonymous volume is only populated when it is first created, so after adding a dependency you need docker compose up --build --renew-anon-volumes (-V), or the container keeps the old modules and fails with Cannot find module.

Common pitfalls

  • docker compose down -v deletes your data. -v removes named volumes, including the database’s. Plain down keeps them. Reserve -v for intentional resets.
  • Changes do not show up. up reuses the existing image if one is built. After changing the Dockerfile or dependencies, run docker compose up --build.
  • Port already in use. A host port can be bound by only one process. Another project or a locally installed database on the same port blocks the container with Bind for 0.0.0.0:5432 failed: port is already allocated. Change the host side of the mapping ("5433:5432"), or do not publish the port at all.
  • Two projects collide. Compose names containers, networks and volumes after the project name, which defaults to the directory name. Two checkouts in directories both called app share volumes. Set name: at the top of the file, or -p on the command line, to keep them apart.
  • Resource limits. Without limits, one runaway container can starve the others on the host. Compose v2 honors deploy.resources.limits (for example memory: 512M, cpus: '0.5').

Production settings

docker-compose.prod.yml

services:
  backend:
    image: myapp/backend:1.0.0
    restart: unless-stopped
    environment:
      - NODE_ENV=production
    deploy:
      replicas: 3
      resources:
        limits:
          cpus: '0.5'
          memory: 512M
        reservations:
          cpus: '0.25'
          memory: 256M
  db:
    image: postgres:16
    restart: unless-stopped
    volumes:
      - /data/postgres:/var/lib/postgresql/data
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

Several of these settings interact with the base file in ways that are easy to miss:

  • replicas: 3 and published ports do not mix. Three copies of backend cannot all bind host port 8000; the second fails with port is already allocated. That is why the base file above does not publish the backend’s port: Nginx reaches the replicas through the service name, and Docker’s DNS returns all three addresses. The same applies to container_name:, which must be unique and therefore prevents scaling.
  • restart: unless-stopped vs always. Both restart after a crash and after the Docker daemon restarts. unless-stopped respects a manual docker stop, which is usually what an operator expects during maintenance.
  • /data/postgres is a bind mount here, chosen so the data sits in a known host path for backups. It must be owned by the user the Postgres image runs as, or the container exits with permission errors on the data directory. A named volume avoids that, at the cost of a less obvious location (docker volume inspect shows it).
  • Use an image tag, not build:, in production. Building on the server means the running version depends on whatever the server’s checkout contains. Build in CI, push a versioned image, and let the production file only reference it.

Compose on a single server is a reasonable production setup for a small application, as long as you accept what it is: one host, no automatic failover, and deployments that briefly restart containers. Backing up named volumes (for Postgres, with pg_dump from a scheduled job rather than by copying files of a running database) is the part that is most often forgotten.

Frequently asked questions (FAQ)

Q. Compose or Kubernetes?

A. Compose runs containers on one host and is ideal for local development and small single-server deployments. Kubernetes schedules containers across a cluster, restarts or replaces failed ones, and rolls out updates gradually. If you do not need those features, Compose is far less to operate.

Q. How do I see where a named volume stores its data?

A. docker volume inspect <project>_<volume> shows the mount point on the host. On Docker Desktop that path is inside the Docker VM, not on your macOS or Windows file system.

Q. How do I reload code without rebuilding?

A. Bind-mount the source directory in the development override and run the app’s own watch mode, as in section 7.2. Compose 2.22 and later also offer docker compose watch with a develop.watch section, which syncs files or rebuilds when they change.