Docker Compose describes several containers in one compose.yaml file — services, ports, volumes, network and environment variables — and starts them with docker compose up. To keep the API from starting before the database, PostgreSQL and Redis get a healthcheck and the API gets depends_on with condition: service_healthy. Secrets come from .env, and data lives in named volumes that survive docker compose down unless you add -v.
After this you will be able to
- describe an API, PostgreSQL and Redis in
compose.yamlwith ports, named volumes and a restart policy - explain how services find each other by name on the internal network and how a container port differs from a host port
- pass settings via
.envwith${…}interpolation, defaults and required variables, and check the result withdocker compose config - set up
healthcheckanddepends_onwithservice_healthyandservice_completed_successfully, and move dev settings into an override and profiles
Know beforehand
Verified with
Docker Compose 5.3 (Docker Desktop, Docker Engine 29.6)PostgreSQL 17.11 (postgres:17-alpine)Redis 8.10 (redis:8.10-alpine)Node.js 24.21 (node:24.21.0-alpine)Express 5.3.0pg 8.23.1node-redis 6.3.0
Checked against docs: October 11, 2026
Contents
Why Compose: three containers with one command
In “Docker from scratch” we ran one container with docker run, a port, variables and a volume. A real API also needs a database and a cache — three long commands, a shared network, volumes, passwords and the right start order. Compose describes all of this declaratively in compose.yaml, and every developer gets the same environment.
- 01curl / browser
- 02api :3000
- 03db: PostgreSQL :5432
- 04cache: Redis :6379
Project
- What it is in Compose
- Everything described in one
compose.yaml; the default name is the folder,noteshere
Service
- What it is in Compose
- A container definition: image or build, ports, variables, volumes;
api,db,cache
Container
- What it is in Compose
- A running instance of a service:
notes-api-1
Network
- What it is in Compose
- Created automatically (
notes_default); services find each other by name
Volume
- What it is in Compose
- A named data store of the project:
notes_pgdata,notes_redisdata
Compose ships with Docker Desktop; on Linux it is installed as the docker compose plugin. Verified on Docker Compose 5.3 with Docker Engine 29.6. The version field at the top of the file is no longer needed: according to the documentation it is informative only, and Compose warns that it is obsolete.
The app: an API with PostgreSQL and Redis
Let's take the notes API from the Docker lesson and connect real storage: notes go to PostgreSQL, and the notes list is cached in Redis for 30 seconds using cache-aside. /health checks both the database and Redis — the container's HEALTHCHECK will call it. Storage addresses come from the DATABASE_URL and REDIS_URL variables.
mkdir notes && cd notes
npm init -y && npm pkg set type=module
npm install express@5.3.0 pg@8.23.1 redis@6.3.0
npm install -D typescript@7.0.2 @types/express@5.0.6 @types/node@24.19.2 @types/pg@8.23.1import express from "express";
import pg from "pg";
import { createClient } from "redis";
const port = Number(process.env.PORT ?? 3000);
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const redis = createClient({ url: process.env.REDIS_URL });
redis.on("error", (err) => console.error("redis error:", err.message));
await redis.connect();
const CACHE_KEY = "notes:all";
const app = express();
app.use(express.json());
app.get("/health", async (_req, res) => {
try {
await pool.query("SELECT 1");
await redis.ping();
res.json({ status: "ok", db: "ok", redis: "ok" });
} catch (err) {
res.status(503).json({ status: "error", message: (err as Error).message });
}
});
// Cache-aside: read from Redis, on a miss read from PostgreSQL and cache for 30 s
app.get("/api/notes", async (_req, res) => {
const cached = await redis.get(CACHE_KEY);
if (cached) {
res.set("X-Cache", "HIT").type("json").send(cached);
return;
}
const { rows } = await pool.query("SELECT id, text, created_at FROM notes ORDER BY id");
await redis.set(CACHE_KEY, JSON.stringify(rows), { EX: 30 });
res.set("X-Cache", "MISS").json(rows);
});
app.post("/api/notes", async (req, res) => {
const text = typeof req.body?.text === "string" ? req.body.text.trim() : "";
if (!text) {
res.status(400).json({ error: "text is required" });
return;
}
const { rows } = await pool.query(
"INSERT INTO notes (text) VALUES ($1) RETURNING id, text, created_at",
[text],
);
await redis.del(CACHE_KEY);
res.status(201).json(rows[0]);
});
const server = app.listen(port, () => {
console.log(`notes-api listening on port ${port}`);
});
process.on("SIGTERM", () => {
console.log("SIGTERM received, shutting down");
server.close(async () => {
await pool.end();
await redis.close();
process.exit(0);
});
});tsc with no errors, runs in a container with PostgreSQL 17.11 and Redis 8.10.-- Runs once, when the PostgreSQL data volume is empty
CREATE TABLE notes (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
text text NOT NULL CHECK (length(text) BETWEEN 1 AND 500),
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO notes (text) VALUES ('Hello from init.sql');/docker-entrypoint-initdb.d only when the data directory is empty — that is, on the first start with a new volume.The Dockerfile is the same multi-stage one from “Docker from scratch”, minus the two data-folder lines (DATA_DIR and /app/data): the app no longer writes files. Its HEALTHCHECK calls /health, so the API container becomes healthy only when both the database and Redis are reachable.
compose.yaml: services, ports, volumes
services:
api:
build: .
ports:
- "127.0.0.1:${API_PORT:-8080}:3000"
environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
REDIS_URL: redis://cache:6379
depends_on:
db:
condition: service_healthy
restart: true
cache:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:?set POSTGRES_USER in .env}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
POSTGRES_DB: ${POSTGRES_DB:?set POSTGRES_DB in .env}
volumes:
- pgdata:/var/lib/postgresql/data
- ./db/init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
restart: unless-stopped
cache:
image: redis:8.10-alpine
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redisdata:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
psql:
image: postgres:17-alpine
profiles: ["tools"]
environment:
PGHOST: db
PGUSER: ${POSTGRES_USER}
PGPASSWORD: ${POSTGRES_PASSWORD}
PGDATABASE: ${POSTGRES_DB}
entrypoint: ["psql"]
depends_on:
db:
condition: service_healthy
volumes:
pgdata:
redisdata:docker compose up -d --wait — all three services healthy.build: .
- What it does
- Build the image from the Dockerfile in the project folder; the image is named
notes-api
image: postgres:17-alpine
- What it does
- A ready image from the registry; the version is pinned, as in “PostgreSQL: setup in Docker”
ports: "127.0.0.1:${API_PORT:-8080}:3000"
- What it does
- Host port → container port; without
127.0.0.1the port is open on all interfaces
volumes: pgdata:/var/lib/postgresql/data
- What it does
- A named volume for data; for PostgreSQL 17 this is the path, for 18 it is
/var/lib/postgresql
./db/init.sql:…:ro
- What it does
- A host file (bind mount), read-only
command:
- What it does
- Replaces the image
CMD: Redis with the AOF log (--appendonly yes) so data survives restarts
restart: unless-stopped
- What it does
- Restart after a crash and after Docker restarts, unless the container was stopped manually
volumes: at the end
- What it does
- Declares the project's named volumes
Network and service names
Compose creates a <project>_default network and attaches all services to it. On that network a service name works as a hostname: that is why DATABASE_URL says @db:5432 and REDIS_URL says cache:6379. Services talk to each other on container ports; the host port (8080, 55442) is only for connections from outside, from your machine.
$ curl -s http://127.0.0.1:8080/health
{"status":"ok","db":"ok","redis":"ok"}
$ curl -si http://127.0.0.1:8080/api/notes | grep -E "^X-Cache|^\["
X-Cache: MISS
[{"id":"1","text":"Hello from init.sql","created_at":"2026-10-12T02:08:11.573Z"}]
$ curl -si http://127.0.0.1:8080/api/notes | grep -E "^X-Cache|^\["
X-Cache: HIT
[{"id":"1","text":"Hello from init.sql","created_at":"2026-10-12T02:08:11.573Z"}]
$ curl -s -X POST http://127.0.0.1:8080/api/notes -H "Content-Type: application/json" -d '{"text":"Order coffee beans"}'
{"id":"2","text":"Order coffee beans","created_at":"2026-10-12T02:08:29.159Z"}
$ curl -si http://127.0.0.1:8080/api/notes | grep -E "^X-Cache|^\["
X-Cache: MISS
[{"id":"1","text":"Hello from init.sql","created_at":"2026-10-12T02:08:11.573Z"},{"id":"2","text":"Order coffee beans","created_at":"2026-10-12T02:08:29.159Z"}]POST clears the cache. Real output.$ docker compose exec api node -e "require('node:dns').promises.lookup('db').then(a => console.log('db ->', a.address))"
db -> 192.168.0.3
$ docker network ls --filter name=notes
NETWORK ID NAME DRIVER SCOPE
392a30dbb8d0 notes_default bridge localdb resolves to the container address on the network; the network is named after the project. During verification the project had a kb- prefix; it is removed in the article.idcame back as a string ("1"): thepgdriver returnsbigintas a string by default so large numbers do not lose precision in JavaScriptlocalhostinside a container is the container itself. “The API cannot see the database” is most oftenlocalhostinstead of the service name in the connection string- You can declare networks explicitly under
networks— for example to separate the database from public services. For a single project the default network is enough
Environment variables and .env
Compose reads the .env file next to compose.yaml automatically and substitutes its values into ${…} expressions in the file itself. This is interpolation: values reach a container only if you pass them via environment. Commit .env.example without secrets and put .env itself into .gitignore.
# Local development only. Copy from .env.example; never commit this file
POSTGRES_USER=notes
POSTGRES_PASSWORD=change-me-locally
POSTGRES_DB=notes
API_PORT=8080@, : or /, it must be URL-encoded in DATABASE_URL.POSTGRES_USER=notes
POSTGRES_PASSWORD=
POSTGRES_DB=notes
API_PORT=8080${POSTGRES_USER}
- Meaning
- The variable value; if missing — an empty string and a warning
${API_PORT:-8080}
- Meaning
- The value, or
8080if the variable is unset or empty
${POSTGRES_PASSWORD:?text}
- Meaning
- Stop with an error and the text if the variable is unset or empty
$${POSTGRES_USER}
- Meaning
- A double
$means no substitution: the shell inside the container expands the variable
$ docker compose config --services
cache
db
api
$ docker compose --profile tools config --services
cache
db
api
psql
$ docker compose config | grep -E "DATABASE_URL|POSTGRES_PASSWORD"
DATABASE_URL: postgresql://notes:change-me-locally@db:5432/notes
POSTGRES_PASSWORD: change-me-locally
$ mv .env .env.bak; docker compose config > /dev/null; mv .env.bak .env
time="2026-10-11T20:07:56-06:00" level=warning msg="The \"POSTGRES_USER\" variable is not set. Defaulting to a blank string."
time="2026-10-11T20:07:56-06:00" level=warning msg="The \"POSTGRES_PASSWORD\" variable is not set. Defaulting to a blank string."
time="2026-10-11T20:07:56-06:00" level=warning msg="The \"POSTGRES_DB\" variable is not set. Defaulting to a blank string."
error while interpolating services.db.environment.POSTGRES_USER: required variable POSTGRES_USER is missing a value: set POSTGRES_USER in .envdocker compose config prints the final configuration after substitution — the first thing to check when something behaves oddly. Without .env the :? check fires.Source precedence: a shell variable beats a file passed with --env-file, which beats .env. So you can change a port for one run without touching any files:
$ DB_PORT=55443 docker compose config | grep published
published: "8080"
published: "56392"
published: "55443"DB_PORT from the command line overrode the value from .env.healthcheck and depends_on: start order
Compose always starts and stops containers in dependency order, but by default it only waits until a dependency is running, not until it is ready. After its container starts, PostgreSQL still spends a few seconds initialising the database. That is why db and cache have a healthcheck (pg_isready and redis-cli ping) and api depends on them with condition: service_healthy. The --wait flag makes the command itself wait until every service is healthy.
$ docker compose up -d --wait
Image notes-api Building
Image notes-api Built
Network notes_default Creating
Network notes_default Created
Volume notes_redisdata Creating
Volume notes_redisdata Created
Volume notes_pgdata Creating
Volume notes_pgdata Created
Container notes-cache-1 Creating
Container notes-db-1 Creating
Container notes-cache-1 Created
Container notes-db-1 Created
Container notes-api-1 Creating
Container notes-api-1 Created
Container notes-cache-1 Starting
Container notes-db-1 Starting
Container notes-cache-1 Started
Container notes-db-1 Started
Container notes-cache-1 Waiting
Container notes-db-1 Waiting
Container notes-cache-1 Healthy
Container notes-db-1 Healthy
Container notes-api-1 Starting
Container notes-api-1 Started
Container notes-db-1 Waiting
Container notes-cache-1 Waiting
Container notes-api-1 Waiting
Container notes-db-1 Healthy
Container notes-cache-1 Healthy
Container notes-api-1 Healthy
$ docker compose ps
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
notes-api-1 notes-api "docker-entrypoint.s…" api 12 seconds ago Up 5 seconds (healthy) 127.0.0.1:8080->3000/tcp
notes-cache-1 redis:8.10-alpine "docker-entrypoint.s…" cache 12 seconds ago Up 11 seconds (healthy) 127.0.0.1:56392->6379/tcp
notes-db-1 postgres:17-alpine "docker-entrypoint.s…" db 12 seconds ago Up 11 seconds (healthy) 127.0.0.1:55442->5432/tcp
$ curl -s http://127.0.0.1:8080/health
{"status":"ok","db":"ok","redis":"ok"}cache and db reach Healthy first, only then api starts. Image build lines are omitted.What happens with the short form depends_on: [db, cache]: we started the same project with it and polled /health every half second.
$ docker compose -f compose.short.yaml up -d
Network notes_default Creating
Network notes_default Created
Volume notes_pgdata Creating
Volume notes_pgdata Created
Volume notes_redisdata Creating
Volume notes_redisdata Created
Container notes-db-1 Creating
Container notes-cache-1 Creating
Container notes-cache-1 Created
Container notes-db-1 Created
Container notes-api-1 Creating
Container notes-api-1 Created
Container notes-cache-1 Starting
Container notes-db-1 Starting
Container notes-cache-1 Started
Container notes-db-1 Started
Container notes-api-1 Starting
Container notes-api-1 Started
$ for i in 1 2 3 4 5 6; do curl -s -o /dev/null -w "%{http_code} " http://127.0.0.1:8080/health; sleep 0.5; done; echo
000 503 200 200 200 200
$ docker compose -f compose.short.yaml logs api
api-1 | notes-api listening on port 3000
$ docker compose -f compose.short.yaml ps --format 'table {{.Service}}\t{{.Status}}'
SERVICE STATUS
api Up 4 seconds (health: starting)
cache Up 4 seconds (health: starting)
db Up 4 seconds (health: starting)000 — the API is not listening yet, 503 — the API is up but the database is not ready; a second later everything works. On a slow machine or with migrations the error window is longer.service_started
- When the dependent service starts
- When the dependency container is running (this is what the short form does)
service_healthy
- When the dependent service starts
- When the dependency healthcheck has passed
service_completed_successfully
- When the dependent service starts
- When the dependency has exited with code 0 — for migrations and data preparation
restart: true in depends_on restarts the API when Compose restarts or updates the database:
$ docker compose restart db
Container notes-db-1 Restarting
Container notes-db-1 Started
Container notes-db-1 Waiting
Container notes-db-1 Healthy
Container notes-api-1 Restarting
Container notes-api-1 Started
$ docker compose ps --format 'table {{.Service}}\t{{.Status}}'
SERVICE STATUS
api Up Less than a second (health: starting)
cache Up 36 seconds (healthy)
db Up 6 seconds (healthy)
$ docker compose logs api --tail 3
api-1 | notes-api listening on port 3000
api-1 | SIGTERM received, shutting down
api-1 | notes-api listening on port 3000
$ curl -s http://127.0.0.1:8080/health
{"status":"ok","db":"ok","redis":"ok"}restart db Compose waited for Healthy and restarted api; the log shows a clean SIGTERM shutdown.Profiles and a development override
Besides compose.yaml, Compose automatically reads compose.override.yaml from the same folder and merges them: port lists are combined, variables are merged by name, and single values from the second file replace the first. It is a good place for developer-only things — for example database and Redis ports for a GUI client.
# Loaded automatically by "docker compose up": local development extras
services:
db:
ports:
- "127.0.0.1:${DB_PORT:-5432}:5432"
cache:
ports:
- "127.0.0.1:${REDIS_PORT:-6379}:6379"DB_PORT/REDIS_PORT in .env.$ docker compose config | grep -E "^ [a-z]+:$|published"
api:
published: "8080"
cache:
published: "56392"
db:
published: "55442"
default:
pgdata:
redisdata:
$ docker compose -f compose.yaml config | grep -E "^ [a-z]+:$|published"
api:
published: "8080"
cache:
db:
default:
pgdata:
redisdata:-f compose.yaml only the API does. That is how you run the environment without dev extras.Profiles enable services on demand. A service without profiles always starts; one with a profile starts only when the profile is active (--profile tools or COMPOSE_PROFILES=tools) or when the service is called explicitly. That is how psql is defined here — a database client not needed in normal work:
$ docker compose run --rm psql -c "\dt"
Container notes-db-1 Running
Container notes-db-1 Waiting
Container notes-db-1 Healthy
Container notes-psql-run-fa3cc8af018b Creating
Container notes-psql-run-fa3cc8af018b Created
List of relations
Schema | Name | Type | Owner
--------+-------+-------+-------
public | notes | table | notes
(1 row)run --rm creates a one-off container of the service, waits for a healthy database and removes the container on exit.$ docker compose up -d --wait
Image notes-api Building
…
Container notes-db-1 Starting
Container notes-cache-1 Starting
Error response from daemon: failed to set up container networking: driver failed programming external connectivity on endpoint notes-cache-1 (bc682bd84ba0…): Bind for 0.0.0.0:6379 failed: port is already allocated
$ printf 'DB_PORT=55442\nREDIS_PORT=56392\n' >> .env
$ docker compose up -d --wait
Container notes-cache-1 Recreate
Container notes-db-1 Recreate
…
Container notes-api-1 Healthy…).Everyday commands and exercises
docker compose up -d --wait
- What it does
- Build if needed, start in the background and wait for
healthy
docker compose up -d --build
- What it does
- Rebuild images before starting — after code changes
docker compose ps
- What it does
- Project containers and their status
docker compose logs -f api
- What it does
- Service logs in real time; without a name — all services
docker compose exec db psql -U notes
- What it does
- A command in a running service container
docker compose run --rm psql -c …
- What it does
- A one-off service container
docker compose restart db
- What it does
- Restart a service
docker compose down
- What it does
- Remove containers and the network; volumes stay
docker compose down -v
- What it does
- The same plus project volumes — data will be lost
$ docker compose down
…
Container notes-api-1 Removed
…
Container notes-db-1 Removed
…
Container notes-cache-1 Removed
…
Network notes_default Removed
$ docker volume ls --filter name=notes
DRIVER VOLUME NAME
local notes_pgdata
local notes_redisdata
$ docker compose up -d --wait
…
Container notes-cache-1 Healthy
Container notes-db-1 Healthy
…
Container notes-cache-1 Healthy
Container notes-db-1 Healthy
Container notes-api-1 Healthy
$ docker compose logs db | grep -iE "skipping|init.sql"
db-1 | PostgreSQL Database directory appears to contain a database; Skipping initialization
$ curl -s http://127.0.0.1:8080/api/notes
[{"id":"1","text":"Hello from init.sql","created_at":"2026-10-12T02:08:11.573Z"},{"id":"2","text":"Order coffee beans","created_at":"2026-10-12T02:08:29.159Z"}]
$ docker compose down -v
…
Container notes-api-1 Removed
…
Container notes-db-1 Removed
…
Container notes-cache-1 Removed
…
Volume notes_pgdata Removed
Volume notes_redisdata Removed
Network notes_default Removed
$ docker volume ls --filter name=notes
DRIVER VOLUME NAMEdown kept the volumes — after up the database was not re-initialised and the notes are still there; down -v removed the volumes. Real output, repetitive progress lines collapsed (…).Next — secrets and environment variables in production, and deployment: how the same project runs on a server where a .env with passwords should not exist.
How this was verified11 · passedchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.