← Back to GuidesGUIDESelf-Hosting & Homelab

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

about 17 hours ago
❤️ 0 likes
💬 0 comments
dockerself-hostinglinux
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

Key takeaways Compose describes a multi-service app in one 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

Get the code. Every file in this post is in the docker-foundations repo, under `03-compose-stack/`. Clone it to follow along.

Prerequisites

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:

Plain Text
1compose-stack/2  docker-compose.yml3  .env.example4  app/5    package.json6    server.js7  db/8    init.sql

docker-compose.yml

YAML
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 version field as obsolete and ignores it. If you see it in older tutorials, you can delete it.
  • Service-name hostnames. The API's DATABASE_URL points at the host postgres, and REDIS_URL at redis. 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 no ports entry, 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 down and restarts. init.sql is 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:

JavaScript
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

SQL
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);
Bash
1# .env.example  (copy to .env, which you never commit)2POSTGRES_USER=demo3POSTGRES_PASSWORD=change_me_in_a_real_project4POSTGRES_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:

Bash
1cp .env.example .env2docker compose up -d

Compose creates the network and volume, then starts the services in dependency order:

Plain Text
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       Started

Check what is running:

Bash
1docker compose ps
Plain Text
1SERVICE    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/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:

Bash
1curl -s localhost:8080/
JSON
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}
Bash
1curl -s localhost:8080/hello
JSON
1{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:

Bash
1docker compose exec redis redis-cli GET visits
Plain Text
12
Bash
1docker compose exec postgres psql -U demo -d demo -c "SELECT id, path FROM hits ORDER BY id;"
Plain Text
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

`depends_on` is 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 few 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:

Bash
1docker compose logs api
Plain Text
1api-1  | connected to postgres and redis2api-1  | api listening on port 3000

Tear it down

Stop and remove the whole stack in one command:

Bash
1docker compose down
Plain Text
1 Container compose-stack-api-1       Removed2 Container compose-stack-postgres-1  Removed3 Container compose-stack-redis-1     Removed4 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:

Bash
1docker compose down -v

Warning

`down -v` deletes your data. The -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.

Comments (0)

Loading discussion...

Related guides