Docker Compose: run a multi-container app with one file

Stop typing long docker run commands. One compose.yaml starts your whole stack, and one command stops it. Tested example included.

A compose file connecting a web container and a Redis container

Most real applications are more than one process: a web server, a database, a cache. Starting each with a long docker run command is slow and easy to get wrong. Docker Compose lets you describe the whole stack in one file and start, stop and rebuild it with one command. This article assumes you know basic Docker (images, containers, Dockerfile). The example below was run with Docker Compose v2 before publishing.

The idea

You write a compose.yaml that lists services (containers), their images, ports, volumes and settings. Compose creates a private network so the services can reach each other by service name, and runs everything together.

A working example

A web server (nginx) and a Redis cache. Save as compose.yaml, and put any index.html in a site folder next to it:

services:
  web:
    image: nginx:alpine
    ports:
      - "8099:80"
    volumes:
      - ./site:/usr/share/nginx/html:ro
    depends_on:
      cache:
        condition: service_healthy
    restart: unless-stopped

  cache:
    image: redis:7-alpine
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - cache-data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

volumes:
  cache-data:

Run it

$ docker compose up -d
 Container demo-cache-1  Started
 Container demo-cache-1  Healthy
 Container demo-web-1    Started

$ docker compose ps
SERVICE   IMAGE            STATUS                    PORTS
cache     redis:7-alpine   Up 13 seconds (healthy)   6379/tcp
web       nginx:alpine     Up 6 seconds              0.0.0.0:8099->80/tcp

$ curl localhost:8099
<h1>Hello from Compose</h1>

$ docker compose exec cache redis-cli set greeting hello
OK

$ docker compose down          # removes containers and network, keeps the volume
$ docker compose up -d
$ docker compose exec cache redis-cli get greeting
hello

$ docker compose down -v       # also removes the volume (data is gone)

(Container names depend on your folder or project name, and image sizes and times vary.)

What each part does

  • services: each key (web, cache) is one container. Its name is also its hostname on the Compose network, so a web app can reach Redis at cache:6379 with no IP address.
  • image: which image to run. Use build: . instead to build from your own Dockerfile.
  • ports: "8099:80": host port 8099 maps to container port 80. Only publish ports you need. Redis has no ports entry, so it is reachable only by other services, which is safer.
  • volumes: ./site:/usr/share/nginx/html:ro is a bind mount of a local folder, read-only (:ro). cache-data:/data is a named volume managed by Docker, which stores Redis data outside the container.
  • healthcheck: a command Compose runs repeatedly to decide if the service is healthy.
  • depends_on with condition: service_healthy: start web only after cache is healthy. Plain depends_on only waits for the container to start, not to be ready. That difference causes many "connection refused at startup" bugs.
  • restart: unless-stopped: restarts the container after a crash or a server reboot, unless you stopped it yourself.

Everyday commands

Command Use
docker compose up -d start the stack in the background
docker compose ps show status
docker compose logs -f web follow one service's logs
docker compose exec cache sh open a shell inside a running service
docker compose up -d --build rebuild images after a code change
docker compose down stop and remove containers and network, keep volumes
docker compose down -v also delete volumes (deletes data)
docker compose config validate the file and show the final result

Data and volumes: the important lesson

In the run above we stored a key, ran down and up, and the key was still there, because it lives in the named volume. Containers are disposable; volumes are where data lives. down -v removes the volume, so use it only when you really want to erase data. Never keep a database in a container without a volume.

Configuration and secrets

  • Put settings in an .env file next to compose.yaml and reference them as ${VARIABLE} in the file. Never commit .env with real passwords to Git; add it to .gitignore and commit an .env.example instead.
  • Avoid hard-coding passwords in compose.yaml.
  • Pin image versions (redis:7-alpine, not latest) so an update does not surprise you.

Common problems

  • Port already in use: change the left side of ports ("8100:80").
  • Service cannot connect to another: use the service name, not localhost. Inside a container, localhost means that container itself.
  • Changes to code not showing: rebuild with --build, or use a bind mount in development.
  • Starts before the database is ready: use a health check with condition: service_healthy, and make your app retry connections.
  • Lost data after down -v: that flag deletes volumes. Back up first.

Compose in production?

Compose is excellent for local development, testing and small single-server deployments. It does not spread containers across many machines or heal them automatically across servers. For that, people use an orchestrator such as Kubernetes. For one small server, Compose with restart: unless-stopped, volumes and backups is a reasonable choice.

Takeaway: describe your stack once in compose.yaml, use service names for networking, use health checks for startup order, keep data in volumes, and keep secrets out of the file.


Linux & DevOps series, Part 3 (everyday operations): 1 of 5.

Keep reading