Why Docker Compose for multi-service setups
As projects grow, you rarely run just one container. A typical development setup might include a web app, an API, a database, a cache, and a reverse proxy. Running each container with separate commands quickly becomes repetitive and error-prone: you have to remember environment variables, port mappings, volume paths, and the correct startup order. Docker Compose solves this by letting you define an entire multi-service application in a single file and manage it with a small set of commands.
Docker Compose is not “a different kind of container.” It is a tool and a file format that describes how multiple containers should run together: which images to use (or how to build them), how they connect, what configuration they need, and what data should persist. The key idea is that your application’s runtime topology becomes code: a declarative configuration you can version, review, and share.
The Compose file: the blueprint for your stack
Docker Compose uses a YAML file (commonly named docker-compose.yml or compose.yml) to describe services. A service is typically one container type (for example, web, api, db). Compose then creates containers, networks, and volumes based on that description.
Core building blocks you will use most
- services: the containers that make up your app.
- image: which prebuilt image to run.
- build: how to build an image from a Dockerfile for a service.
- ports: publish container ports to the host for local access.
- environment: configure services via environment variables.
- depends_on: express startup ordering between services.
- networks: define how services communicate (Compose creates a default network if you do nothing).
- volumes: define persistent storage and shareable data locations.
Compose is designed to be readable. The file is meant to answer: “What are the parts of this system, and how do they fit together?”
A first multi-service example: web + API + database
Below is a practical Compose file that defines three services: a web frontend, an API, and a database. The web and API are built from local Dockerfiles, while the database uses an official image. This example focuses on Compose structure and common options.
Continue in our app.
You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.
Or continue reading below...Download the app
services:
web:
build: ./web
ports:
- "8080:80"
environment:
- API_BASE_URL=http://api:3000
depends_on:
- api
api:
build: ./api
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgres://app:app@db:5432/appdb
depends_on:
- db
db:
image: postgres:16
environment:
- POSTGRES_USER=app
- POSTGRES_PASSWORD=app
- POSTGRES_DB=appdbImportant details to notice:
- Service names become DNS names. Inside the Compose network, the API can reach the database at
db:5432, and the web can reach the API atapi:3000. You do not need to hardcode IP addresses. - Host ports are for you, container ports are for containers. The web is reachable on your machine at
localhost:8080, but other services should use the internal service name and container port. - depends_on is not a health check. It ensures Compose starts containers in order, but it does not guarantee the database is ready to accept connections. You’ll learn practical patterns for readiness later in this chapter.
Step-by-step: creating and running the Compose setup
1) Create a project folder structure
Compose works best when each project has its own directory. A common layout is:
myapp/
compose.yml
web/
Dockerfile
...
api/
Dockerfile
...Put the Compose file at the root so it can reference relative paths like ./web and ./api.
2) Write the Compose file
Create compose.yml (or docker-compose.yml) and define your services. Start minimal: define services, then add ports and environment variables as needed.
3) Start the stack
From the project directory, run:
docker compose upThis builds images (if build is used), creates a network, and starts containers. Add -d to run in the background:
docker compose up -d4) View logs per service
Compose aggregates logs, which is extremely useful in multi-service debugging:
docker compose logs -fTo focus on one service:
docker compose logs -f api5) Stop and remove containers
To stop services:
docker compose stopTo stop and remove containers and the default network:
docker compose downIf you also want to remove named volumes created by Compose (be careful, this deletes persisted data):
docker compose down -vUnderstanding Compose networking in practice
When you run docker compose up, Compose creates a dedicated network for the project (unless you configure otherwise). All services join that network automatically. This gives you:
- Service discovery by name (use
db,api,webas hostnames). - Isolation from other Compose projects (each project gets its own network by default).
- Predictable connectivity without manual network creation.
A common beginner mistake is to connect from one container to another using localhost. Inside a container, localhost means “this same container,” not another service. In Compose, use the service name: http://api:3000, not http://localhost:3000.
Configuration patterns: environment variables and .env files
Compose supports two closely related ideas:
- Environment variables passed into containers via the
environmentsection. - Variable substitution in the Compose file using values from your shell or a local
.envfile.
Variable substitution lets you avoid hardcoding values like ports or passwords in the YAML. Example:
services:
api:
build: ./api
environment:
- NODE_ENV=${NODE_ENV:-development}
- DATABASE_URL=${DATABASE_URL}Then create a .env file next to compose.yml:
NODE_ENV=development
DATABASE_URL=postgres://app:app@db:5432/appdbNotes:
.envis for Compose variable substitution. It is not automatically injected into containers unless you reference the variables inenvironmentor use an env file feature.- Defaults with
:-are helpful for local development. - Keep secrets out of Git by adding
.envto.gitignorewhen it contains sensitive values.
Building images with Compose: build contexts and overrides
When you use build, Compose can build images for you. The simplest form is a path to the build context:
services:
api:
build: ./apiYou can also specify a Dockerfile name and build arguments:
services:
api:
build:
context: ./api
dockerfile: Dockerfile
args:
- APP_VERSION=1.0.0Compose will rebuild when needed. If you want to force a rebuild:
docker compose build --no-cacheOr rebuild and start:
docker compose up --buildManaging startup order and readiness
Multi-service setups often fail on first boot because one service tries to connect to another before it is ready. Compose offers depends_on for ordering, but ordering alone does not guarantee readiness.
Practical pattern: add health checks and wait for healthy dependencies
You can define a healthcheck for a service and then use a dependency condition so another service waits until it is healthy. Example with Postgres:
services:
db:
image: postgres:16
environment:
- POSTGRES_USER=app
- POSTGRES_PASSWORD=app
- POSTGRES_DB=appdb
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
interval: 5s
timeout: 3s
retries: 10
api:
build: ./api
environment:
- DATABASE_URL=postgres://app:app@db:5432/appdb
depends_on:
db:
condition: service_healthyThis is a practical improvement for local development because it reduces “connection refused” loops at startup. If your Compose implementation does not support the condition form, you can still keep the healthcheck for visibility and implement retry logic in the API (many frameworks already do).
Named volumes in Compose for stateful services
Stateful services (like databases) need persistent storage. In Compose, you typically define a named volume and mount it into the service. This keeps data across container restarts and recreations.
services:
db:
image: postgres:16
environment:
- POSTGRES_USER=app
- POSTGRES_PASSWORD=app
- POSTGRES_DB=appdb
volumes:
- db_data:/var/lib/postgresql/data
volumes:
db_data:Key points:
- Named volumes are managed by Docker, not tied to a specific host path.
- Compose names resources per project. The actual volume name will usually be prefixed with the project name.
- Removing volumes is explicit (for example,
docker compose down -v), which helps prevent accidental data loss.
Profiles: run optional services only when needed
Sometimes you want extra services only in certain situations: a local mail catcher, a debugging UI, or a metrics stack. Compose profiles let you mark services as optional and start them only when requested.
services:
api:
build: ./api
mailhog:
image: mailhog/mailhog
profiles: ["devtools"]
ports:
- "8025:8025"Start the default services:
docker compose upStart with the optional profile:
docker compose --profile devtools upThis keeps your main stack lean while still making helpful tools easy to enable.
Scaling stateless services for local testing
Compose can run multiple instances of a service, which is useful for testing load balancing behavior or concurrency issues. For example, to run three API containers:
docker compose up --scale api=3Important considerations:
- Port publishing conflicts: if your service publishes a fixed host port (like
"3000:3000"), scaling will fail because multiple containers cannot bind the same host port. For scaled services, avoid publishing fixed host ports and access them through another service (like a reverse proxy) or use internal networking. - Stateful services should not be scaled casually: databases and similar services require clustering strategies beyond basic scaling.
Adding a reverse proxy service (common multi-service pattern)
A reverse proxy can route requests to multiple backend services and provide a single entry point. In local development, it also helps when scaling services because only the proxy needs a published host port.
Here is a simple example using Nginx as a gateway in front of an API and a web app. The Nginx configuration is mounted into the container.
services:
gateway:
image: nginx:alpine
ports:
- "8080:80"
volumes:
- ./gateway/nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- web
- api
web:
build: ./web
api:
build: ./apiA minimal gateway/nginx.conf might route /api to the API service and everything else to the web service. The important Compose concept is that the proxy can reach api and web by service name on the internal network.
Compose project naming and resource isolation
Compose groups resources (containers, networks, volumes) into a “project.” By default, the project name is derived from the directory name. This matters because:
- Two different projects can both have a service called
dbwithout conflict, because their networks are separate. - Resource names are typically prefixed with the project name, making it easier to identify what belongs together.
You can set a custom project name when running commands:
docker compose -p myapp-dev up -dThis is useful when you want to run two copies of the same stack (for example, myapp-dev and myapp-test) on the same machine without collisions.
Override files for development vs. other environments
A common Compose workflow is to keep a base file that describes the core services, and then add a second file that overrides settings for local development (for example, extra port mappings, debug flags, or bind mounts). Compose can merge multiple files.
Base file (compose.yml):
services:
api:
build: ./api
environment:
- NODE_ENV=productionDevelopment overrides (compose.override.yml):
services:
api:
environment:
- NODE_ENV=development
ports:
- "3000:3000"By default, Compose automatically uses compose.override.yml if present. You can also specify files explicitly:
docker compose -f compose.yml -f compose.override.yml upThis pattern keeps your core definition stable while allowing local convenience settings without polluting the base configuration.
Troubleshooting multi-service Compose setups
Inspect running services and their status
To see what Compose started:
docker compose psThis shows container names, state, and published ports.
Exec into a specific service
When debugging connectivity or configuration, it’s often helpful to run commands inside a container:
docker compose exec api shFrom there, you can test DNS resolution (service names), check environment variables, or run application-specific diagnostics.
Recreate a service after config changes
If you change the Compose file, Compose may need to recreate containers to apply changes:
docker compose up -dTo force recreation:
docker compose up -d --force-recreateCommon mistakes and quick fixes
- Using
localhostbetween services: replace with the service name (for example,db,api). - Port confusion: containers talk to container ports; host ports are only for access from your machine.
- Startup race conditions: add health checks and/or retry logic.
- Stale containers after changes: rebuild with
--buildor recreate with--force-recreate. - Data issues during testing: use separate named volumes per project name, or remove volumes intentionally with
down -vwhen you want a clean slate.