Multi-Service Applications with Compose
Docker Compose lets you describe a whole application stack in one file instead of starting each container by hand. For a real app, that usually means a web service, a database, a cache, background workers, and shared networks or volumes. Compose matters because it turns a pile of docker run commands into a repeatable project definition that teammates and CI systems can run the same way.
Overview: How Compose Runs Multiple Services
A Compose application is a project made of named services. Each service becomes one or more containers created from either an image, such as postgres:16-alpine, or a local build context, such as ./api. Compose also creates default resources for the project: a private bridge network so services can reach each other by service name, named volumes for persistent data, and containers whose names are tied to the project and service.
The important idea is that Compose is still Docker. It does not replace images, containers, networks, or volumes. It calls the Docker Engine API through the modern docker compose CLI plugin and asks the daemon to build images, create networks, create volumes, and start containers. On Linux those containers use Linux namespaces and cgroups directly. On Docker Desktop, the daemon runs inside a small Linux VM, but the Compose model is the same.
In a multi-service app, service discovery is built in. If a service is named db, another service can connect to hostname db on the container port, for example postgres://app:changeme@db:5432/appdb. You do not need to publish the database port to the host just so the web container can reach it. The ports: key is only for host-to-container access, such as opening localhost:8080 in your browser.
Compose startup order is often misunderstood. depends_on controls the order in which Compose starts containers, but it does not automatically mean the database is ready to accept connections. For readiness-sensitive apps, add a healthcheck to the dependency and use a condition such as service_healthy, or make the application retry database connections on startup. Retrying in the app is still a best practice because databases can restart later too.
Syntax
A typical multi-service Compose file uses this shape:
services:
web:
build: ./web
ports:
- "8080:8080"
environment:
API_URL: "http://api:3000"
depends_on:
api:
condition: service_started
api:
image: my-api:1.0
environment:
DATABASE_URL: "postgres://app:changeme@db:5432/appdb"
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
interval: 5s
timeout: 3s
retries: 10
volumes:
db-data:
| Key | Purpose |
|---|---|
services |
Defines the containers that make up the app. Each child key, such as api or db, becomes a DNS name on the Compose network. |
image |
Runs an existing image. Use specific tags instead of latest for reproducible environments. |
build |
Builds an image from a local Dockerfile before starting the service. |
ports |
Publishes a container port to the host. This is different from EXPOSE, which is only image metadata. |
environment |
Sets container environment variables. Use obvious placeholders such as changeme in examples; do not bake real secrets into images. |
depends_on |
Declares startup dependencies. With health checks, it can wait for a dependency to become healthy before starting another service. |
volumes |
Mounts persistent or shared storage. Named volumes are managed by Docker and are the usual choice for database data. |
networks |
Optionally customizes networks. If omitted, Compose creates one default network for the project. |
The main commands are:
docker compose up -d
docker compose ps
docker compose logs api
docker compose down
upcreates missing resources, builds images when needed, and starts services.-druns containers in the background.pslists containers for the current Compose project.logs SERVICEshows logs for one service; omit the service name to show all logs.downstops and removes the project containers and default network. It does not remove named volumes unless you add--volumes.
Examples
Example 1: Web Plus Redis
This Compose file starts a small web container and a Redis cache. The web service can connect to Redis using hostname redis and port 6379 inside the private Compose network.
services:
web:
image: nginx:1.27-alpine
ports:
- "8080:80"
depends_on:
redis:
condition: service_started
redis:
image: redis:7.2-alpine
docker compose up -d
Output:
[+] Running 3/3
- Network demo_default Created
- Container demo-redis-1 Started
- Container demo-web-1 Started
Only web publishes a port. Redis stays private to the Compose network, which is usually what you want for infrastructure services. If you open http://localhost:8080, the request enters the Nginx container on port 80.
Example 2: API, Database, and Persistent Data
A common application has an API and a database. This file uses a named volume so PostgreSQL data survives container replacement.
services:
api:
image: node:20-alpine
working_dir: /app
command: ["node", "server.js"]
volumes:
- ./api:/app
ports:
- "3000:3000"
environment:
DATABASE_URL: "postgres://app:changeme@db:5432/appdb"
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: changeme
POSTGRES_DB: appdb
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
interval: 5s
timeout: 3s
retries: 10
volumes:
db-data:
docker compose up -d
docker compose ps
Output:
[+] Running 3/3
- Network shop_default Created
- Container shop-db-1 Healthy
- Container shop-api-1 Started
NAME IMAGE COMMAND SERVICE STATUS
shop-api-1 node:20-alpine "docker-entrypoint.sh" api Up
shop-db-1 postgres:16-alpine "docker-entrypoint.sh" db Up (healthy)
The API bind-mounts ./api for local development, while the database uses a named volume. That distinction matters: a bind mount points at a host path you choose, but a named volume is managed by Docker and remains available even if the db container is deleted.
Example 3: Build One Service, Pull Another
Compose can build your application image and pull supporting images in the same project. This Dockerfile uses a pinned base image and a cache-friendly dependency install order.
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
EXPOSE 3000 documents that the image listens on port 3000, but it does not publish the port to your host. Compose publishes it with ports:.
services:
api:
build: ./api
image: shop-api:1.0
ports:
- "3000:3000"
environment:
REDIS_URL: "redis://cache:6379"
depends_on:
cache:
condition: service_started
cache:
image: redis:7.2-alpine
docker compose up --build -d
Output:
[+] Building 8.4s (9/9) FINISHED
[+] Running 3/3
- Network shop_default Created
- Container shop-cache-1 Started
- Container shop-api-1 Started
Compose builds shop-api:1.0 from ./api, then starts it next to Redis. The Dockerfile copies dependency manifests before the rest of the source so ordinary source changes do not force npm ci to rerun unless dependency files changed.
How It Works Step by Step
- Compose chooses a project name, usually from the directory name unless you pass
--project-name. - It reads
compose.yml, resolves environment interpolation, and compares desired services with existing project containers. - For services with
build, Docker sends the build context to the daemon. BuildKit evaluates Dockerfile layers and reuses cached layers when inputs have not changed. - For services with
image, Docker checks the local image store and pulls missing image layers from the registry if needed. - Compose creates the project network and named volumes. Containers attached to the same network get DNS entries matching service names.
- Compose creates containers with their environment, mounts, commands, health checks, and port publishing rules.
- When you run
docker compose down, Compose removes project containers and networks. Named volumes remain unless you explicitly request removal.
Common Mistakes
Publishing Every Port
services:
db:
image: postgres:16-alpine
ports:
- "5432:5432"
This is often unnecessary in a multi-service app. Other services can already reach db:5432 on the Compose network. Publish the database port only when a host tool, such as a desktop SQL client, needs direct access. Otherwise omit ports:.
Assuming depends_on Means Ready
services:
api:
image: shop-api:1.0
depends_on:
- db
db:
image: postgres:16-alpine
This starts db before api, but the database process may still be initializing. Add a health check and wait for service_healthy, and make the API retry its connection because a healthy dependency can still restart later.
Losing Data on down –volumes
docker compose down --volumes
This removes named volumes for the project. That is useful for resetting a development database, but it is the wrong command when you want to keep local data. Use plain docker compose down to remove containers while preserving named volumes.
Best Practices
- Use
docker compose, the modern Compose V2 subcommand, not the old standalonedocker-composecommand. - Keep databases, caches, and message brokers private unless the host truly needs access.
- Use named volumes for persistent service data and bind mounts for local source-code development.
- Pin image tags such as
postgres:16-alpineandredis:7.2-alpine; avoidlatestfor repeatable projects. - Add health checks for dependencies that take time to become ready.
- Do not store real secrets in a Dockerfile or committed Compose file. Use environment files excluded from version control, Docker secrets, or your deployment platform’s secret store.
- Give services stable names that describe roles:
api,web,db,cache,worker. - Keep the Compose file development-friendly, and use override files or separate deployment configuration when production needs differ.
Practice Exercises
- Create a Compose file with
webusingnginx:1.27-alpineandapiusing a locally built image from./api. Publish only the web service to the host. Hint: the web service can callhttp://api:3000by service name. - Add PostgreSQL to an existing API project. The expected end state is an
apiservice that waits for a healthydbservice and a named volume that stores database files. - Run
docker compose down, then start the app again and confirm that database data remains. Then decide whendocker compose down --volumeswould be appropriate.
Summary
- Compose describes a full application stack as services, networks, volumes, builds, and runtime settings.
- Services on the same Compose network can reach each other by service name without publishing ports to the host.
depends_oncontrols startup order; health checks and application retries handle readiness.- Named volumes preserve important data across container replacement.
- Compose is a convenient layer over normal Docker Engine behavior, so images, layers, containers, ports, and volumes still work the same way.
