Running one container is easy. Real applications are never one container though. A typical web app is a server, a database, and a cache, all running together and talking to each other. Starting each one by hand with the right flags, in the right order, on the right network, gets old immediately.
Docker Compose fixes that. You describe every service once in a single YAML file, then bring the whole stack up with one command. In this post we build a real three service stack, a Node API backed by Postgres and Redis, and drive it end to end: up, prove the services are talking, then down. Every command and its output below was captured on a real Docker Engine.
Tip
Key takeaways
- Compose describes a multi-service app in one
docker-compose.yml, brought up withdocker compose up.- Containers on the same Compose network reach each other by service name (the API connects to
postgres:5432andredis:6379, no IPs).- Publish only what the outside world needs. Here only the API gets a host port; Postgres and Redis stay private on the Compose network.
depends_oncontrols start order, not readiness. Your app still needs to retry the first connection.- Keep secrets in a
.envfile that you never commit, and reference them from the Compose file.Info
Get the code. Every file in this post is in the docker-foundations repo, under
03-compose-stack/. Clone it to follow along.
Prerequisites
- Docker installed and running. If you need it, see Install Docker on macOS, Windows (WSL2), and Linux.
- Comfort with single containers helps. If
docker run,ps, andlogsare new, start with Run Your First Containers. - Modern Docker ships Compose as the
docker composesubcommand. Check yours withdocker compose version.
What Compose actually is
Compose is one YAML file plus one command. Instead of a pile of docker run lines, you declare each service (its image, ports, environment, volumes, and dependencies) in docker-compose.yml, and Compose creates them together on a shared private network. On that network, every service can reach every other by its service name, which is the piece that makes multi-container apps sane.
We will build this stack:
- api: a small Node HTTP server on port 3000, published to your machine on 8080.
- postgres: a Postgres 16 database, private to the stack.
- redis: a Redis 7 cache, private to the stack.
The API writes a row to Postgres and increments a counter in Redis on every request, which proves all three are wired together.
The project
Five small files. Here is the layout:
compose-stack/
docker-compose.yml
.env.example
app/
package.json
server.js
db/
init.sql
docker-compose.yml
services:
api:
image: node:22-alpine
working_dir: /app
command: sh -c "npm install --no-audit --no-fund && node server.js"
ports:
- "8080:3000"
environment:
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
REDIS_URL: redis://redis:6379
volumes:
- ./app:/app
depends_on:
- postgres
- redis
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- pgdata:/var/lib/postgresql/data
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
redis:
image: redis:7-alpine
volumes:
pgdata:
A few things worth pointing out:
-
No
version:line. Modern Compose treats the old top-levelversionfield as obsolete and ignores it. If you see it in older tutorials, you can delete it. -
Service-name hostnames. The API's
DATABASE_URLpoints at the hostpostgres, andREDIS_URLatredis. Those are the service names, and Compose resolves them on the shared network. You never hardcode an IP. -
Only the API publishes a port.
ports: "8080:3000"exposes the API to your machine. Postgres and Redis have noportsentry, so they are reachable only by other services in the stack, not from your host or the internet. That is exactly what you want for a database. -
depends_onmakes Compose start Postgres and Redis before the API. Read the readiness note below, because this does less than it looks. -
A named volume,
pgdata, keeps the database on disk so data survivesdownand restarts.init.sqlis mounted into Postgres's init directory and runs once when the volume is first created.
app/server.js
The app is deliberately tiny. On each request it bumps a Redis counter and inserts a Postgres row, then returns both:
const http = require("http");
const { Pool } = require("pg");
const { createClient } = require("redis");
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const redis = createClient({ url: process.env.REDIS_URL });
redis.on("error", (e) => console.error("redis error:", e.message));
// depends_on waits for the container to start, not for the service to be ready,
// so retry the first connection to Postgres and Redis.
async function withRetry(fn, label, tries = 30) {
for (let i = 1; i <= tries; i++) {
try {
return await fn();
} catch (e) {
console.log(`waiting for ${label} (${i}/${tries}): ${e.message}`);
await new Promise((r) => setTimeout(r, 1500));
}
}
throw new Error(`${label} not ready after ${tries} tries`);
}
async function start() {
await withRetry(() => pool.query("SELECT 1"), "postgres");
await withRetry(() => redis.connect(), "redis");
console.log("connected to postgres and redis");
const server = http.createServer(async (req, res) => {
try {
const visits = await redis.incr("visits");
const inserted = await pool.query(
"INSERT INTO hits (path) VALUES ($1) RETURNING id",
[req.url]
);
const total = await pool.query("SELECT COUNT(*)::int AS count FROM hits");
res.setHeader("Content-Type", "application/json");
res.end(
JSON.stringify(
{
message: "API is talking to Postgres and Redis over the Compose network",
redis_visits: visits,
postgres_hit_id: inserted.rows[0].id,
postgres_total_hits: total.rows[0].count,
},
null,
2
) + "\n"
);
} catch (e) {
res.statusCode = 500;
res.end(JSON.stringify({ error: e.message }) + "\n");
}
});
server.listen(3000, () => console.log("api listening on port 3000"));
}
start();
The redis.on("error", ...) line matters more than it looks. In node-redis, the client emits an error event while it cannot reach the server, and an error event with no listener is thrown and takes the process down. That is exactly the window this app is built to survive, so the handler stays. Each request is wrapped in a try/catch too, so a transient query failure returns a 500 instead of crashing the server.
Notice there is no Dockerfile here. The API uses the stock node:22-alpine image, bind-mounts your code in, and runs npm install at startup. That is fine for local development and keeps this post focused on Compose. Building a proper image with a Dockerfile is the next post in the series.
db/init.sql and .env.example
CREATE TABLE IF NOT EXISTS hits (
id SERIAL PRIMARY KEY,
path TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
# .env.example (copy to .env, which you never commit)
POSTGRES_USER=demo
POSTGRES_PASSWORD=change_me_in_a_real_project
POSTGRES_DB=demo
Compose automatically reads a file named .env in the project directory and substitutes those values into ${POSTGRES_USER} and friends. Commit .env.example so people know which variables to set, and keep the real .env out of git.
Bring the stack up
Copy the example env file, then start everything in the background:
cp .env.example .env
docker compose up -d
Compose creates the network and volume, then starts the services in dependency order:
Network compose-stack_default Created
Volume compose-stack_pgdata Created
Container compose-stack-postgres-1 Started
Container compose-stack-redis-1 Started
Container compose-stack-api-1 Started
Check what is running:
docker compose ps
SERVICE IMAGE STATUS PORTS
api node:22-alpine Up 23 seconds 0.0.0.0:8080->3000/tcp
postgres postgres:16-alpine Up 23 seconds 5432/tcp
redis redis:7-alpine Up 23 seconds 6379/tcp
Look at the PORTS column. Only api has a 0.0.0.0:8080->3000 mapping, so only the API is reachable from your machine. Postgres and Redis show their internal ports with no host mapping: private to the stack.
Prove the services are talking
Hit the API twice, at two different paths:
curl -s localhost:8080/
{
"message": "API is talking to Postgres and Redis over the Compose network",
"redis_visits": 1,
"postgres_hit_id": 1,
"postgres_total_hits": 1
}
curl -s localhost:8080/hello
{
"message": "API is talking to Postgres and Redis over the Compose network",
"redis_visits": 2,
"postgres_hit_id": 2,
"postgres_total_hits": 2
}
Both counters went up, which means each request really did reach both stores. You can confirm the data landed by reading each service directly with docker compose exec:
docker compose exec redis redis-cli GET visits
2
docker compose exec postgres psql -U demo -d demo -c "SELECT id, path FROM hits ORDER BY id;"
id | path
----+--------
1 | /
2 | /hello
(2 rows)
The Redis counter is at 2, and Postgres has both requests recorded, one row per call. Three separate containers, cooperating over a private network, from one YAML file.
How the wiring works
The mechanism is the Compose network. When Compose brings the stack up, it puts all services on one user-defined bridge network and registers each service name as a DNS name on it. So inside the API container, postgres resolves to the Postgres container and redis to the Redis container. That is why DATABASE_URL can say @postgres:5432 and just work, with no IP addresses and no links.
Warning
depends_onis start order, not readiness. Compose starts Postgres and Redis before the API, but "started" only means the container process launched, not that Postgres is accepting connections yet. On a cold start the database can need a second or two to come up, and if the API tries to connect first it will fail. That is why the API retries its first connection: if the database is slow to accept connections, you will see a fewwaiting for postgreslines in the API logs beforeconnected. Do not rely ondepends_onalone; make your app tolerant of a not-ready dependency, or add a healthcheck.
Once it is up, the API logs confirm it connected:
docker compose logs api
api-1 | connected to postgres and redis
api-1 | api listening on port 3000
Tear it down
Stop and remove the whole stack in one command:
docker compose down
Container compose-stack-api-1 Removed
Container compose-stack-postgres-1 Removed
Container compose-stack-redis-1 Removed
Network compose-stack_default Removed
docker compose down removes the containers and the network but keeps the named volume, so your database survives. When you want a truly clean slate, including the data, add the volume flag:
docker compose down -v
Warning
down -vdeletes your data. The-vflag removes named volumes too, which wipes the Postgres database. Use it when you want a fresh start, not on anything you care about.
Common gotchas
"port is already allocated" on 8080
Another process is using host port 8080. Change the API's mapping to something free, for example "8081:3000", and browse to 8081.
The API crashes or logs endless "waiting for postgres"
Usually the credentials do not match. The API's DATABASE_URL and the Postgres service must use the same POSTGRES_USER, POSTGRES_PASSWORD, and POSTGRES_DB. Since both read from .env, make sure .env exists (copy it from .env.example).
Changes to init.sql do not take effect
init.sql only runs when the Postgres data volume is first created. If you already ran the stack, the volume exists, so edits are ignored. Recreate it with docker compose down -v and bring the stack back up.
version warning on up
If Compose warns that the version field is obsolete, delete the top-level version: line. Modern Compose does not use it.
Where to go next
You now have a reproducible multi-service stack that starts and stops with one command. The API still installs its dependencies at runtime, which is slow and not how you ship to production.
- Next in this series: Lean Docker Images, where we write a real Dockerfile for the API, then cut its size and build time with multi-stage builds and layer caching.
Verified on 2026-09-10 on a real Ubuntu 24.04.5 LTS system with Docker Engine 29.8.0 and Docker Compose v5.5.1. The full stack (node:22-alpine, postgres:16-alpine, redis:7-alpine) was brought up with docker compose up -d, and the outputs shown come from that run (trimmed for width, and shown under the compose-stack project name this post uses): docker compose ps, the two curl round-trips (Redis counter and Postgres rows both incrementing), redis-cli GET visits returning 2, the psql query returning both rows, the api logs, and docker compose down.
Top comments (0)