Docker Compose Multi-Service Applications
The shape almost every real project takes: an application talking to a database over the project network, with the data on a named volume that survives a teardown. Built and verified against PostgreSQL 17.
Docker Compose Guide 25 of 46 Intermediate
- OSUbuntu 26.04 LTS (resolute)
- Docker Engine29.7.2
- Docker Compose5.4.0
- PostgreSQL imagepostgres:17-alpine (17.11)
- TimeAbout 15 min
- Reviewed21 August 2026
Tested on the versions above. Container names derive from the directory name and timings vary. Match the shape of the output, not the exact strings.
| Server Name | IP Address | OS | Roles | CPU | RAM | HDD |
|---|---|---|---|---|---|---|
| DOCKER01 | 192.168.0.21 | Ubuntu 26.04 LTS | Docker Host | 2 Core | 4 GB | 50 GB |
Before you start
- You can bring a Compose project up and down - guide 24 in this path.
- Named volumes - guide 12 in this path.
- Host port 5432 does not need to be free; nothing here publishes it.
-
The file
Two services. The database gets a named volume for its data directory and a healthcheck; the application waits for that healthcheck rather than for the container merely to exist. Nothing is published to the host - the app reaches the database over the project network, and the database is unreachable from outside it. That is the default worth keeping.
bash cat compose.yamlservices: db: image: postgres:17-alpine environment: POSTGRES_PASSWORD: example POSTGRES_DB: appdb volumes: - dbdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres -d appdb"] interval: 3s retries: 10 api: image: alpine:3.22 depends_on: db: condition: service_healthy command: sh -c "nc -z db 5432 && echo reached db:5432 by service name; sleep 300" volumes: dbdata:Expected resultTwo services and a top-level
volumesblock declaring the named volume.Success conditionThe volume is declared at the top level as well as mounted in the service. Omitting the top-level declaration is the most common error in this file.
-
Bring it up and watch the ordering
Read the sequence carefully, because it is the whole point of the file. The database starts, then Compose reports Waiting, then Healthy, and only then does the application start. Without the healthcheck condition the app would have started immediately and failed to connect - the database process needs a moment after the container exists.
bash Example session docker compose up -d Network cg-stack_default Created Container cg-stack-db-1 Created Container cg-stack-api-1 Created Container cg-stack-db-1 Starting Container cg-stack-db-1 Started Container cg-stack-db-1 Waiting Container cg-stack-db-1 Healthy Container cg-stack-api-1 Starting Container cg-stack-api-1 StartedExpected resultWaiting and Healthy for the database BEFORE the application starts.
Success conditionYou see Waiting then Healthy. If the app starts immediately after the db Started line, your
condition:is missing or misspelled. -
Confirm the health state
psreports the health state alongside the status, and it is the fastest read on whether a dependency is genuinely ready. Onlydbshows a port, and only inside the network - there is no host mapping.bash Example session docker compose ps --format "table {{.Service}}\t{{.Status}}\t{{.Ports}}"SERVICE STATUS PORTSapi Up Less than a seconddb Up 3 seconds (healthy) 5432/tcpExpected result
(healthy)in the status column, and a health state of healthy on inspect.Verify it worked
bash Example session docker inspect cg-stack-db-1 --format "health={{.State.Health.Status}} checks={{len .State.Health.Log}}"health=healthy checks=1Success conditionThe database reports healthy. A service with no healthcheck shows no such annotation at all - absence is not the same as unhealthy.
-
Prove the app reached the database by name
The application connected to the host
db- the service name - with no address, no link and no published port. Compose put both containers on a project network and registered each service name in its DNS, which is exactly the user-defined bridge behaviour from guide 10 set up on your behalf.bash docker compose logs apiapi-1 | API startingapi-1 | reached db:5432 by service nameExpected resultThe application reporting a successful connection, and one project network.
Verify it worked
bash Example session docker network ls --filter name=cg-stackNETWORK ID NAME DRIVER SCOPE5495fe0ddd42 cg-stack_default bridge localSuccess conditionThe service name resolved. This is why you should never hardcode container IPs in a Compose project.
-
Talk to the database directly
compose execgets you a session inside the database container. Useful for a schema check or a quick query - and note you did not need the database port published on the host to do it.bash Example session docker compose exec db psql -U postgres -d appdb -c "select version();" | head -3 version------------------------------------------------------------------------------------------ PostgreSQL 17.11 on x86_64-pc-linux-musl, compiled by gcc (Alpine 15.2.0) 15.2.0, 64-bitExpected resultA version banner from the running server.
Success conditionYou get a real answer from the database engine. If psql reports it cannot connect, the server is still starting - which is what the healthcheck exists to prevent.
-
Data survives the teardown - unless you ask otherwise
This is the distinction that catches people out.
downremoves the containers and the network but leaves named volumes alone, so bringing the project back up finds its data intact.down -vdeletes those volumes too, and there is no confirmation prompt. Learn the difference on a project you do not care about.bash # keeps the dbdata volume - the database comes back with its data:docker compose down# -v DELETES the dbdata volume and everything in the database:docker compose down -vExpected resultThe first form leaves the volume in
docker volume ls; the second removes it.Success conditionYou can state which of the two you want before typing it. For anything holding real data, back it up first - see guide 13.
Troubleshooting
The application starts before the database is ready and crashes
Why:
depends_onwith no condition only waits for the container to be CREATED, not for the process inside it to be usable. It is a startup-order hint, not a readiness gate.Fix:Give the dependency a healthcheck and depend on
condition: service_healthy, as this guide's file does. Applications should still retry their connection - a healthcheck reduces the failures, it does not eliminate them.bash depends_on: db:docker compose config | grep -A2 depends_onThe database is empty after changing environment variables
Why:
POSTGRES_DB,POSTGRES_PASSWORDand friends are only read when the data directory is initialised. Once the volume holds a database, those variables are ignored.Fix:To change them you must discard the volume and let the image initialise again - which destroys the data. Back it up first, or make the change with SQL instead.
bash # destroys the existing databasedocker compose down -v && docker compose up -dservice "db" refers to undefined volume dbdata
Why: The volume is mounted in the service but never declared in the top-level
volumes:block.Fix:Add the name to the top-level block. An empty value is fine - it means "default driver".
bash volumes: dbdata:docker compose config --volumesConnection refused from the app, even though the db is up
Why: Usually the wrong host. Inside the project network the database is reachable at the SERVICE name, not at localhost - localhost inside a container is that container itself.
Fix:Point the application at
db:5432. Confirm the name resolves from the calling service before changing anything else.bash docker compose exec api nc -zv db 5432