How to buildOctober 11, 20267 min read
Docker and deploymentStep 4 of 8

Docker Compose: app and database locally with one command

In short

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.

TutorialLevel: IntermediateDockerPractice: ~1.5–3 h

After this you will be able to

  • describe an API, PostgreSQL and Redis in compose.yaml with 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 .env with ${…} interpolation, defaults and required variables, and check the result with docker compose config
  • set up healthcheck and depends_on with service_healthy and service_completed_successfully, and move dev settings into an override and profiles

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
  1. 01Why Compose: three containers with one command
  2. 02The app: an API with PostgreSQL and Redis
  3. 03compose.yaml: services, ports, volumes
  4. 04Network and service names
  5. 05Environment variables and .env
  6. 06healthcheck and depends_on: start order
  7. 07Profiles and a development override
  8. 08Everyday commands and exercises

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.

  1. 01curl / browser
  2. 02api :3000
  3. 03db: PostgreSQL :5432
  4. 04cache: Redis :6379
The lesson project: a Node.js API reads data from PostgreSQL and caches it in Redis — by service name on the internal network.

Project

What it is in Compose
Everything described in one compose.yaml; the default name is the folder, notes here

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.

bash
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.1
Project and dependencies; versions are current on npm as of 2026-10-11.
src/server.ts
import 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);
  });
});
Verified: tsc with no errors, runs in a container with PostgreSQL 17.11 and Redis 8.10.
db/init.sql
-- 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');
The postgres image runs scripts from /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

compose.yaml
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:
Verified: Docker Compose 5.3.1, 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.1 the 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.

terminal
$ 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"}]
Checking the API: the first request is a cache miss, the second a hit, and POST clears the cache. Real output.
terminal
$ 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    local
The name db 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.
  • id came back as a string ("1"): the pg driver returns bigint as a string by default so large numbers do not lose precision in JavaScript
  • localhost inside a container is the container itself. “The API cannot see the database” is most often localhost instead 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.

.env
# 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
The password is for local development only. If it contains @, : or /, it must be URL-encoded in DATABASE_URL.
.env.example
POSTGRES_USER=notes
POSTGRES_PASSWORD=
POSTGRES_DB=notes
API_PORT=8080
A template for colleagues: this file is committed.

${POSTGRES_USER}

Meaning
The variable value; if missing — an empty string and a warning

${API_PORT:-8080}

Meaning
The value, or 8080 if 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
terminal
$ 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 .env
docker 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:

terminal
$ 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.

terminal
$ 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"}
A clean start: 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.

terminal
$ 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)
Real output: 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:

terminal
$ 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"}
After 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.

compose.override.yaml
# 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"
The default port is the standard one; if it is busy, set DB_PORT/REDIS_PORT in .env.
terminal
$ 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:
With the override all three services publish ports; with an explicit -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:

terminal
$ 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.
terminal
$ 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
Real output, long parts shortened (…).

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
terminal
$ 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 NAME
down 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.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.