Open this post in EchoOS →

Chapter 1.9 - Docker Compose: Whole Applications in One File

By now, running the pieces of an application means a stack of long docker run commands, a docker network create, and remembering the order to start them in. Docker Compose replaces all of that with one declarative file: you describe the services, networks and volumes, and docker compose up makes reality match.

It’s the standard tool for local development, CI test environments and single-host deployments. (For several hosts you’d move to Swarm, Chapter 1.11, or Kubernetes.)

Installing

Compose v2 is a plugin for the Docker CLI, invoked as docker compose (with a space).

docker compose version

If you find docker-compose (with a hyphen) in old scripts: that’s Compose v1, a separate Python tool that reached end of life in 2023. The file format is compatible; switch the command.

The example: proxy, app and cache

We’ll extend the app from Chapter 1.1 to count visits in Redis, and put Nginx in front of it:

flowchart TD
  b([Browser]) -- "localhost:8080" --> proxy
  subgraph P["Compose project: shop<br/>(network shop_default)"]
    proxy["proxy<br/>nginx:1.27"] -- "api:3000" --> api["api<br/>built from ./Dockerfile"]
    api -- "redis:6379" --> redis["redis<br/>redis:8.0"]
  end
  vol[("redis-data volume")] -. "/data" .-> redis

The app

server.js:

const express = require("express");
const os = require("os");
const { createClient } = require("redis");

const redis = createClient({ url: process.env.REDIS_URL });
redis.on("error", (err) => console.error("redis:", err.message));

const app = express();
app.get("/", async (req, res) => {
  const visits = await redis.incr("visits");
  res.send(`Hello from ${os.hostname()}, visit #${visits}\n`);
});

redis.connect().then(() => {
  app.listen(3000, () => console.log("listening on 3000"));
});

package.json adds the Redis client:

{
  "name": "shop-api",
  "version": "1.0.0",
  "main": "server.js",
  "dependencies": {
    "express": "^5.1.0",
    "redis": "^4.7.0"
  }
}

The Dockerfile and .dockerignore are unchanged from Chapter 1.1. For the proxy, nginx/default.conf:

server {
    listen 80;
    location / {
        proxy_pass http://api:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

The Compose file

compose.yml, next to the Dockerfile:

name: shop

services:
  proxy:
    image: nginx:1.27
    ports:
      - "8080:80"
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - api
    restart: unless-stopped

  api:
    build: .
    environment:
      REDIS_URL: redis://redis:6379
    depends_on:
      redis:
        condition: service_healthy
    restart: unless-stopped

  redis:
    image: redis:8.0
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - redis-data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
    restart: unless-stopped

volumes:
  redis-data:

Key by key:

Running it

docker compose up -d --build
curl http://localhost:8080
# Hello from 5c1f0e9ab2d3, visit #1
docker compose ps
docker compose logs -f api

Scaling

docker compose up -d --scale api=3
docker compose exec proxy nginx -s reload
for i in 1 2 3 4; do curl -s http://localhost:8080; done
# Hello from 5c1f0e9ab2d3, visit #2
# Hello from a93b77c01e5f, visit #3
# Hello from 0d4e61b8f7a2, visit #4
# Hello from 5c1f0e9ab2d3, visit #5

Three api containers now share the name api, and Docker’s DNS returns all three addresses. Nginx resolves api when it loads its config, so after scaling it needs a reload to pick up the new addresses; then it round-robins between them. The counter keeps climbing across containers, because the state lives in Redis, not in the app. That’s the whole point of stateless services. (Scaling only works because api publishes no port: three containers can’t all bind host port 8080.)

The commands you’ll use

Command What it does
docker compose up -d Create/update and start everything
docker compose ps Containers in this project, with health status
docker compose logs -f [service] Follow logs, optionally for one service
docker compose exec api sh A shell in a running service container
docker compose run --rm api npm test A new one-off container for a task (tests, migrations)
docker compose restart api Restart without recreating
docker compose pull Pull newer versions of the images
docker compose build Rebuild images (--no-cache for a clean build)
docker compose config Print the final file after variables and merges are applied
docker compose stop Stop, keeping the containers
docker compose down Remove the containers and network; keep volumes
docker compose down -v Also delete named volumes, and with them the data

exec versus run trips people up: exec runs a command in a container that’s already running, while run starts a fresh container from the service’s definition, which is what you want for one-off tasks that shouldn’t touch the running service.

Configuration without editing the file

Variables and .env

Compose substitutes ${VARIABLES} in the file from your shell and from a .env file next to it:

services:
  api:
    image: yourname/shop-api:${API_TAG:-latest}
    env_file: api.env

docker compose config shows the result of all substitutions, which is the fastest way to debug “why is it using the wrong value”.

Override files for development

Compose automatically merges compose.override.yml on top of compose.yml if it exists. Put development-only changes there:

# compose.override.yml (development only)
services:
  api:
    command: ["node", "--watch", "server.js"]
    volumes:
      - ./server.js:/app/server.js
    ports:
      - "3000:3000"

In production, deploy with only the base file: docker compose -f compose.yml up -d.

Profiles for optional tools

services:
  redis-ui:
    image: redis/redisinsight:latest
    ports:
      - "5540:5540"
    profiles: [tools]

Services with profiles: don’t start by default. docker compose --profile tools up -d adds them, which suits admin UIs and debugging tools you want sometimes, but not always.

A few habits that pay off

Chapter 1.10 takes these same commands to other machines: managing remote Docker hosts from your laptop with Docker contexts.