DEV Community

Cover image for Docker Compose: Run a Multi-Service Stack (Web + Postgres + Redis)
Shubham Sharma
Shubham Sharma

Posted on Originally published at techdevmantra.com

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:

compose-stack/
  docker-compose.yml
  .env.example
  app/
    package.json
    server.js
  db/
    init.sql
Enter fullscreen mode Exit fullscreen mode

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:
Enter fullscreen mode Exit fullscreen mode

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:

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();
Enter fullscreen mode Exit fullscreen mode

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()
);
Enter fullscreen mode Exit fullscreen mode
# .env.example  (copy to .env, which you never commit)
POSTGRES_USER=demo
POSTGRES_PASSWORD=change_me_in_a_real_project
POSTGRES_DB=demo
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Check what is running:

docker compose ps
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode
{
  "message": "API is talking to Postgres and Redis over the Compose network",
  "redis_visits": 1,
  "postgres_hit_id": 1,
  "postgres_total_hits": 1
}
Enter fullscreen mode Exit fullscreen mode
curl -s localhost:8080/hello
Enter fullscreen mode Exit fullscreen mode
{
  "message": "API is talking to Postgres and Redis over the Compose network",
  "redis_visits": 2,
  "postgres_hit_id": 2,
  "postgres_total_hits": 2
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
2
Enter fullscreen mode Exit fullscreen mode
docker compose exec postgres psql -U demo -d demo -c "SELECT id, path FROM hits ORDER BY id;"
Enter fullscreen mode Exit fullscreen mode
 id |  path
----+--------
  1 | /
  2 | /hello
(2 rows)
Enter fullscreen mode Exit fullscreen mode

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:

docker compose logs api
Enter fullscreen mode Exit fullscreen mode
api-1  | connected to postgres and redis
api-1  | api listening on port 3000
Enter fullscreen mode Exit fullscreen mode

Tear it down

Stop and remove the whole stack in one command:

docker compose down
Enter fullscreen mode Exit fullscreen mode
 Container compose-stack-api-1       Removed
 Container compose-stack-postgres-1  Removed
 Container compose-stack-redis-1     Removed
 Network compose-stack_default       Removed
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)