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:5432andredis://redis:6379use service names as host names. Compose’s embedded DNS resolves them on the project network. Inside a container,localhostis that container itself, solocalhost:5432from the backend reaches nothing. This is the single most common first error with Compose, and it shows up asconnection refusedorcould 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. Publishing5432:5432is 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 asufwdoes 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_URLpoints at the host, not atbackend. The frontend’s JavaScript runs in the user’s browser, which is outside the Docker network and cannot resolvebackend. Routing/apithrough Nginx gives the browser one origin and avoids CORS configuration.:roon 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 -vdeletes your data.-vremoves named volumes, including the database’s. Plaindownkeeps them. Reserve-vfor intentional resets.- Changes do not show up.
upreuses the existing image if one is built. After changing the Dockerfile or dependencies, rundocker 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
appshare volumes. Setname:at the top of the file, or-pon 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 examplememory: 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: 3and published ports do not mix. Three copies ofbackendcannot all bind host port 8000; the second fails withport 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 tocontainer_name:, which must be unique and therefore prevents scaling.restart: unless-stoppedvsalways. Both restart after a crash and after the Docker daemon restarts.unless-stoppedrespects a manualdocker stop, which is usually what an operator expects during maintenance./data/postgresis 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 inspectshows 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.
Related Articles
- Docker & Kubernetes Beginners Guide
- Docker Multi-Stage Builds: Smaller Images, Layer Caching, BuildKit Cache Mounts and Secret Leaks
- Docker Security in Production: Non-Root Images, Secrets and Capabilities
- Node.js + Nginx Reverse Proxy Setup
- Kubernetes in Practice: Pods, Deployments, Services, Ingress, HPA and kubectl Troubleshooting
- GitHub Actions CI/CD: Workflows, Jobs, Matrix Builds, Secrets and Deployment