Docker Compose: Run a Multi-Service Stack (Web + Postgres + Redis)

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
docker-compose.yml, brought up with docker compose up.Containers on the same Compose network reach each other by service name (the API connects to postgres:5432 and redis: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_on controls start order, not readiness. Your app still needs to retry the first connection.Keep secrets in a .env file that you never commit, and reference them from the Compose file.Info
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:
1compose-stack/2 docker-compose.yml3 .env.example4 app/5 package.json6 server.js7 db/8 init.sqldocker-compose.yml
1services:2 api:3 image: node:22-alpine4 working_dir: /app5 command: sh -c "npm install --no-audit --no-fund && node server.js"6 ports:7 - "8080:3000"8 environment:9 DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}10 REDIS_URL: redis://redis:637911 volumes:12 - ./app:/app13 depends_on:14 - postgres15 - redis16 17 postgres:18 image: postgres:16-alpine19 environment:20 POSTGRES_USER: ${POSTGRES_USER}21 POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}22 POSTGRES_DB: ${POSTGRES_DB}23 volumes:24 - pgdata:/var/lib/postgresql/data25 - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro26 27 redis:28 image: redis:7-alpine29 30volumes:31 pgdata:A few things worth pointing out:
- No `version:` line. Modern Compose treats the old top-level
versionfield 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_on` makes 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 survives
downand 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:
1const http = require("http");2const { Pool } = require("pg");3const { createClient } = require("redis");4 5const pool = new Pool({ connectionString: process.env.DATABASE_URL });6const redis = createClient({ url: process.env.REDIS_URL });7redis.on("error", (e) => console.error("redis error:", e.message));8 9// depends_on waits for the container to start, not for the service to be ready,10// so retry the first connection to Postgres and Redis.11async function withRetry(fn, label, tries = 30) {12 for (let i = 1; i <= tries; i++) {13 try {14 return await fn();15 } catch (e) {16 console.log(`waiting for ${label} (${i}/${tries}): ${e.message}`);17 await new Promise((r) => setTimeout(r, 1500));18 }19 }20 throw new Error(`${label} not ready after ${tries} tries`);21}22 23async function start() {24 await withRetry(() => pool.query("SELECT 1"), "postgres");25 await withRetry(() => redis.connect(), "redis");26 console.log("connected to postgres and redis");27 28 const server = http.createServer(async (req, res) => {29 try {30 const visits = await redis.incr("visits");31 const inserted = await pool.query(32 "INSERT INTO hits (path) VALUES ($1) RETURNING id",33 [req.url]34 );35 const total = await pool.query("SELECT COUNT(*)::int AS count FROM hits");36 res.setHeader("Content-Type", "application/json");37 res.end(38 JSON.stringify(39 {40 message: "API is talking to Postgres and Redis over the Compose network",41 redis_visits: visits,42 postgres_hit_id: inserted.rows[0].id,43 postgres_total_hits: total.rows[0].count,44 },45 null,46 247 ) + "\n"48 );49 } catch (e) {50 res.statusCode = 500;51 res.end(JSON.stringify({ error: e.message }) + "\n");52 }53 });54 55 server.listen(3000, () => console.log("api listening on port 3000"));56}57 58start();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
1CREATE TABLE IF NOT EXISTS hits (2 id SERIAL PRIMARY KEY,3 path TEXT NOT NULL,4 created_at TIMESTAMPTZ NOT NULL DEFAULT now()5);1# .env.example (copy to .env, which you never commit)2POSTGRES_USER=demo3POSTGRES_PASSWORD=change_me_in_a_real_project4POSTGRES_DB=demoCompose 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:
1cp .env.example .env2docker compose up -dCompose creates the network and volume, then starts the services in dependency order:
1 Network compose-stack_default Created2 Volume compose-stack_pgdata Created3 Container compose-stack-postgres-1 Started4 Container compose-stack-redis-1 Started5 Container compose-stack-api-1 StartedCheck what is running:
1docker compose ps1SERVICE IMAGE STATUS PORTS2api node:22-alpine Up 23 seconds 0.0.0.0:8080->3000/tcp3postgres postgres:16-alpine Up 23 seconds 5432/tcp4redis redis:7-alpine Up 23 seconds 6379/tcpLook 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:
1curl -s localhost:8080/1{2 "message": "API is talking to Postgres and Redis over the Compose network",3 "redis_visits": 1,4 "postgres_hit_id": 1,5 "postgres_total_hits": 16}1curl -s localhost:8080/hello1{2 "message": "API is talking to Postgres and Redis over the Compose network",3 "redis_visits": 2,4 "postgres_hit_id": 2,5 "postgres_total_hits": 26}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:
1docker compose exec redis redis-cli GET visits121docker compose exec postgres psql -U demo -d demo -c "SELECT id, path FROM hits ORDER BY id;"1 id | path2----+--------3 1 | /4 2 | /hello5(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
waiting for postgres lines in the API logs before connected. Do not rely on depends_on alone; make your app tolerant of a not-ready dependency, or add a healthcheck.Once it is up, the API logs confirm it connected:
1docker compose logs api1api-1 | connected to postgres and redis2api-1 | api listening on port 3000Tear it down
Stop and remove the whole stack in one command:
1docker compose down1 Container compose-stack-api-1 Removed2 Container compose-stack-postgres-1 Removed3 Container compose-stack-redis-1 Removed4 Network compose-stack_default Removeddocker 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:
1docker compose down -vWarning
-v flag 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`.
Join the discussion on Docker Compose: Run a Multi-Service Stack (Web + Postgres + Redis)
Likes, comments, and replies are available for authenticated readers with verified email addresses.


