Docker Compose for Multi-Container Apps
Learn Docker Compose by building a small two-service app — a web API and a Postgres database — defined in a single compose.yaml file and started with one command.
What you'll learn
- ✓What Docker Compose is and when to reach for it
- ✓How to write a compose.yaml file that describes multiple services
- ✓How service-to-service networking works automatically
- ✓How to use environment variables, volumes, and named volumes
- ✓The everyday docker compose commands
Prerequisites
- •Docker installed and working — see Install Docker
- •You have built and run at least one image — see Dockerfile Basics
A single container is fine for a single process. Real applications are rarely a single process — they usually combine a web server, a database, a cache, a background worker, and sometimes more. Starting six containers by hand, with the right ports and flags, gets old quickly. Docker Compose is the tool that fixes this.
What Compose is
Compose lets you describe a multi-container application in a single YAML file (compose.yaml), then start, stop, and rebuild the whole thing with one command. Each top-level entry under services: is a container Compose will manage for you.
Two things are worth knowing up front:
docker composeis built in. It is now a plugin to the main Docker CLI, invoked asdocker compose(two words). The old standalonedocker-composebinary is deprecated — use the new form.- Compose is for development and small deployments. Production at scale typically uses an orchestrator like Kubernetes, but for local development, prototypes, and modest single-host setups, Compose is excellent.
A worked example: web API plus database
We will build a small Express API that reads a counter from Postgres, increments it on each request, and returns the new value.
Create a folder called compose-demo with this layout:
compose-demo/
api/
Dockerfile
package.json
server.js
compose.yaml
api/package.json
{
"name": "compose-demo-api",
"version": "1.0.0",
"main": "server.js",
"scripts": { "start": "node server.js" },
"dependencies": {
"express": "^4.19.2",
"pg": "^8.11.5"
}
}
api/server.js
const express = require('express');
const { Pool } = require('pg');
const pool = new Pool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
port: 5432,
});
async function init() {
await pool.query(`
CREATE TABLE IF NOT EXISTS counters (
id INT PRIMARY KEY,
value INT NOT NULL
);
`);
await pool.query(`
INSERT INTO counters (id, value) VALUES (1, 0)
ON CONFLICT (id) DO NOTHING;
`);
}
const app = express();
app.get('/', async (req, res) => {
const result = await pool.query(
'UPDATE counters SET value = value + 1 WHERE id = 1 RETURNING value'
);
res.json({ count: result.rows[0].value });
});
init()
.then(() => app.listen(3000, '0.0.0.0', () =>
console.log('API listening on 3000')))
.catch((err) => {
console.error('Failed to initialise DB:', err);
process.exit(1);
});
api/Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
This is the same Dockerfile shape from the previous post. Nothing new yet.
Writing the compose file
Now the interesting file — compose.yaml at the project root:
services:
api:
build: ./api
ports:
- "3000:3000"
environment:
DB_HOST: db
DB_USER: app
DB_PASSWORD: secret
DB_NAME: appdb
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
interval: 5s
timeout: 5s
retries: 5
volumes:
db-data:
Read this top-down. Two services, api and db. Let us unpack what each line does.
The api service
build: ./api— instead of pulling a prebuilt image, build one from the Dockerfile in theapi/folder. Compose will run the equivalent ofdocker build ./api.ports: ["3000:3000"]— publish container port 3000 on host port 3000, same as-p 3000:3000.environment:— set environment variables inside the container. TheDB_HOSTvalue isdb— more on that in a moment.depends_on:— start thedbservice before this one, and wait for the database’s healthcheck to pass.
The db service
image: postgres:16-alpine— use the official Postgres 16 image.environment:— the Postgres image reads these variables at first start to create the user, password, and database for us.volumes: db-data:/var/lib/postgresql/data— mount a named volume at Postgres’s data directory so the database survivesdocker compose downand restarts.healthcheck:— Compose runspg_isreadyperiodically. Theapiservice uses this to wait until Postgres is genuinely ready, not just started.
The networking magic
Compose automatically creates a network shared by all services in the file. Inside that network, each service is reachable from the others by its service name. That is why the API uses DB_HOST: db — not localhost, not an IP address, just db. Compose’s built-in DNS resolves it to the database container.
This is the single biggest reason Compose makes multi-container work pleasant.
Starting and stopping
From the compose-demo folder:
# Build images (if needed) and start everything in the background
docker compose up -d
# Watch logs from all services, live
docker compose logs -f
# Watch logs from one service only
docker compose logs -f api
Visit http://localhost:3000. You should see {"count": 1}. Refresh — {"count": 2}. The counter is persisting to Postgres.
To check service status:
docker compose ps
To stop everything:
docker compose stop # stop containers, keep them
docker compose start # restart stopped containers
docker compose down # stop and remove containers + network
docker compose down -v # also remove named volumes (deletes the database)
docker compose down is non-destructive for your named volumes by default. Add -v only when you want to throw away data — for example, to start fresh during development.
Try it yourself. With the stack running, run docker compose down (without -v). Then docker compose up -d again and visit localhost:3000. The count continues from where it left off — your named db-data volume preserved the data. Now run docker compose down -v and bring it back up. The count restarts at 1.
Rebuilding after code changes
docker compose up -d only rebuilds an image if Compose thinks it needs to. When you change application code, force a rebuild:
docker compose up -d --build
This rebuilds any services with a build: key and restarts containers that use the new image. Containers for unchanged services are left alone.
For very rapid iteration, you can also mount your source code into the container as a bind mount so changes appear instantly without a rebuild. Add to the api service:
volumes:
- ./api:/app
- /app/node_modules
The second line is a small trick: an anonymous volume on /app/node_modules prevents the host’s (possibly empty) node_modules folder from clobbering the one installed inside the image. This pattern is common for Node, Python, and Ruby development.
Environment variables and .env files
Hard-coding DB_PASSWORD: secret in a YAML file is fine for a demo, but real projects use a .env file next to compose.yaml:
DB_USER=app
DB_PASSWORD=secret
DB_NAME=appdb
Then reference the values in compose.yaml:
environment:
DB_HOST: db
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: ${DB_NAME}
Compose reads .env automatically when you run docker compose up. Add .env to your .gitignore so secrets do not end up in version control.
Useful everyday commands
# Run a one-off command in a new container (does not start the service)
docker compose run --rm api node -e "console.log(2+2)"
# Open a shell in an already-running service container
docker compose exec api sh
docker compose exec db psql -U app -d appdb
# See the final, fully-resolved configuration
docker compose config
# Remove only stopped containers, keep volumes
docker compose rm
docker compose exec is the workhorse for debugging. Need to inspect database tables? docker compose exec db psql -U app -d appdb drops you into a psql prompt connected to the running database. No port mapping required.
Try it yourself. With the stack running, open a psql shell into the database and inspect the counters table:
docker compose exec db psql -U app -d appdb -c "SELECT * FROM counters;"You should see the current count value. Then hit localhost:3000 a few more times in your browser and rerun the query — the value should increase.
Recap
You now know:
- Docker Compose is the standard tool for running multi-container applications locally
- One
compose.yamlfile describes every service, network, and volume - Services can reach each other on a built-in network by service name as the hostname
- Named volumes keep databases and other stateful data alive across restarts
docker compose up -d,down,logs,exec, and--buildcover most everyday needsdepends_onwith ahealthcheckis the reliable way to start dependent services in the right order
Next steps
The final post in this series goes a layer deeper into the building blocks you have already been using. We will look at how images are actually stored, what layers really are, how the build cache works, and the difference between bind mounts and named volumes — the knowledge that makes the rest of Docker click into place.
→ Next: Docker Images, Layers, and Volumes Explained
Questions or feedback? Email codeloomdevv@gmail.com.