Docker Compose: Running Multi-Container Applications

K
Kai··4 min read

A real app rarely has just one container. It's usually web + database + cache, one container each. Running each docker run by hand with all its -p, -v, --network, -e... flags is tedious and error-prone. Docker Compose solves it: declare everything in one file, run it with a single command.

What Compose is

Compose lets you describe an app's containers (called services), networks, and volumes in one YAML file (compose.yaml). Then docker compose up brings everything up; docker compose down tears everything down. The whole configuration lives in the file, can go into Git, and reproduces identically on another machine.

Compose is now an integrated subcommand: docker compose (with a space). The old version is docker-compose (with a hyphen) running as a separate binary — if you see older docs using the hyphenated form, understand it's the same tool.

A minimal compose file

Create compose.yaml for a web server and a Redis cache:

services:
  web:
    image: nginx:alpine
    ports:
      - "8088:80"
    depends_on:
      - cache
  cache:
    image: redis:alpine

Reading the file: two services, web and cache. web uses the nginx image, publishes port 8088→80, and depends_on: cache (start cache first). cache uses the redis image.

Run both:

docker compose up -d
 Container ..._cache-1  Started
 Container ..._web-1    Started

Check status:

docker compose ps
SERVICE   STATUS
cache     Up
web       Up

curl http://localhost:8088 returns the nginx page (HTTP 200). One command, and both containers are running with the right configuration.

The best part: services call each other by name

Compose auto-creates a user-defined network for the app (recall Article 7: a user-defined bridge has internal DNS). Every service in the same compose file sits on that network and calls the others by service name.

docker compose exec web ping -c 1 cache
PING cache (172.20.0.2): 56 data bytes
64 bytes from 172.20.0.2: seq=0 ttl=64 time=0.160 ms

web can reach cache just by the name cache — no need to know the IP. This is the most practical reason to use Compose: in web's code you configure the Redis host as cache, the database host as db... exactly the service names. No chasing IPs, no obsolete --link.

   Compose project
   ┌──────────────────────────────────────────┐
   │   private network (created by Compose)     │
   │                                            │
   │   [web]  ──calls "cache"──►  [cache]       │
   │     │                                      │
   │   :8088 (published to host)                │
   └──────────────────────────────────────────┘

A fuller compose file

A real example: your app (built from the Dockerfile in Article 5) + PostgreSQL with a volume holding data.

services:
  app:
    build: .                    # build from the Dockerfile in the current directory
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: "postgres://postgres:secret@db:5432/appdb"
    depends_on:
      - db

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: appdb
    volumes:
      - pgdata:/var/lib/postgresql/data   # volume holding data (Article 6)

volumes:
  pgdata:

A few points:

  • build: . instead of image: — Compose builds the image from the Dockerfile itself. Use image: to pull a ready-made image, build: to build your own.
  • environment passes environment variables into the container. Note DATABASE_URL points to the host db — the exact service name, thanks to internal DNS.
  • volumes at the service level mounts a volume; the volumes: block at the bottom declares the named volume pgdata (Article 6).
  • depends_on controls startup order, but note: it only waits for the other container to start, not for the service inside to be ready to accept connections. The database may still be starting up while the app is already running. A real app should retry the connection itself, or use a healthcheck (condition: service_healthy).

Common Compose commands

docker compose up -d           # bring up and run all services in the background
docker compose up -d --build   # rebuild the images before running
docker compose ps              # status of the services
docker compose logs -f         # follow the combined logs of all services
docker compose logs -f app     # logs of one service
docker compose exec app sh     # open a shell in the app service
docker compose down            # stop and remove containers + network
docker compose down -v         # also remove the volumes (data loss!)

🧹 Cleanup

Compose cleans up neatly in a single command — that's another of its conveniences:

docker compose down

This command removes the containers and network that Compose created. Volumes are kept by default (so you don't lose data); add -v if you want to remove volumes too:

docker compose down -v

Wrap-up

Docker Compose describes a whole multi-container app in one YAML file and manages it with up/down. It auto-creates a private network so services call each other by name — neatly solving the wiring that Article 7 had to do by hand. Compose is the standard tool for running a multi-component app on a single machine, both during development and on a small server.

Our image runs fine so far but may still be large and unoptimized. Article 9 covers how to make images smaller and safer with multi-stage builds and a few best practices — before we step into Docker Swarm to run across multiple machines.