Docker Compose Tutorial: Run Multi-Container Apps with One File

· 3 min read · Docker & Rancher Desktop Tutorials

What is Docker Compose #

Docker Compose describes a multi-container application in one YAML file and manages it with one command. Instead of typing several docker network create, docker volume create and long docker run commands in the right order, you write what you want and run docker compose up.

Compose comes with Docker and with Rancher Desktop when you use the dockerd engine. With containerd, nerdctl compose reads the same file.

Docker Compose application: one compose.yaml defines a web app, a PostgreSQL database with a named volume and a Redis cache on the default network, started with docker compose up
One file, three services, one network and one volume

The project #

We reuse the Node.js application of the Dockerfile tutorial and add a database and a cache. Update server.js to count visits in Redis and store them in PostgreSQL if you wish; for this tutorial the infrastructure is what matters, so the application only needs to read two environment variables: DATABASE_URL and REDIS_URL.

myapp/
  compose.yaml
  Dockerfile
  server.js
  package.json
  .env

compose.yaml #

The file is named compose.yaml (or compose.yml, or the older docker-compose.yml). The top-level version: key is obsolete: do not write it.

compose.yaml
services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/app
      REDIS_URL: redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    restart: unless-stopped

  db:
    image: postgres:17
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: app
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

  cache:
    image: redis:7-alpine

volumes:
  db-data:
.env
DB_PASSWORD=change-me

Compose reads .env automatically and substitutes ${DB_PASSWORD}. Add .env to .gitignore.

What each part does:

  • build: . builds the image from the Dockerfile in this directory. Use image: to pull a prebuilt one.
  • Networking: Compose creates a network for the project and every service joins it, reachable by its service name. The app connects to db:5432 and cache:6379. Only app publishes a port.
  • depends_on with condition: service_healthy waits until the healthcheck of db passes, not just until the container has started. Without it your app often starts before the database accepts connections. The doubled $$ stops Compose from interpolating the variable, so the shell inside the container expands it.
  • volumes: the named volume db-data keeps the database between runs.

The everyday commands #

$ docker compose up -d --build      # create, build if needed and start in the background
$ docker compose ps
NAME            IMAGE         SERVICE   STATUS                    PORTS
myapp-app-1     myapp-app     app       Up 20 seconds             0.0.0.0:3000->3000/tcp
myapp-cache-1   redis:7...    cache     Up 25 seconds             6379/tcp
myapp-db-1      postgres:17   db        Up 25 seconds (healthy)   5432/tcp
$ docker compose logs -f app
$ docker compose exec db psql -U app -d app
$ docker compose stop               # stop, keep the containers
$ docker compose down               # remove containers and networks
$ docker compose down -v            # ...and volumes (deletes the data!)

After editing the Dockerfile or the code, docker compose up -d --build rebuilds and recreates only the changed services. To validate the file and see the final configuration with all variables substituted:

$ docker compose config

Configuration: environment and secrets #

  • environment: sets values inline. env_file: .env.app loads a whole file.
  • Variables like ${DB_PASSWORD} come from your shell or the .env file. Use ${VAR:-default} for a default and ${VAR:?error message} to fail if it is missing.
  • For real secrets prefer Compose secrets, which mount a file under /run/secrets/:
services:
  db:
    image: postgres:17
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password

secrets:
  db_password:
    file: ./db_password.txt

Profiles: optional services #

Services with profiles start only when you ask for them, which keeps the default stack small:

services:
  adminer:
    image: adminer
    ports:
      - "8081:8080"
    profiles: ["debug"]
$ docker compose up -d                    # without adminer
$ docker compose --profile debug up -d    # with adminer

Override files: one base, many environments #

Compose merges compose.yaml with compose.override.yaml automatically. Keep the production-like definition in the first and your development tweaks (bind mounts, ports, debug) in the second. Use -f to choose files explicitly:

$ docker compose -f compose.yaml -f compose.prod.yaml up -d

Scale and limit #

$ docker compose up -d --scale app=3   # needs no fixed host port on the service
services:
  app:
    deploy:
      resources:
        limits:
          cpus: "0.50"
          memory: 256M

Compose in production? #

Compose is excellent for development, CI and single-host deployments. To run containers on a cluster use Kubernetes (Kubernetes in Rancher Desktop, EKS) or a managed service such as ECS with Fargate. The docker compose config output and tools such as Kompose can help you migrate.

Next steps #

Edit code and see it running without rebuilding by hand: Docker Compose Watch.

#Docker #Docker Compose