Skip to content
C Codeloom
DevOps

Docker Compose Tutorial: Run Multi-Service Apps Locally

A practical Docker Compose tutorial. Define services, networks, and volumes in one YAML file, then start and stop a full local stack with a single command.

·4 min read · By Codeloom
Beginner 11 min read

What you'll learn

  • What problem Docker Compose actually solves
  • How to structure a compose.yaml for a small stack
  • How service names become DNS hostnames automatically
  • How to wire environment variables and volumes
  • The everyday compose commands you will use daily

Prerequisites

  • Comfortable with the Linux command line

Once a project grows past a single container, the docker run flags start to feel ridiculous. You end up pasting long commands into a README and praying nobody mistypes them. Docker Compose replaces that with a single declarative file that describes every service, network, and volume in your stack. Run docker compose up and the whole thing starts.

What Compose actually is

Compose is a thin layer on top of the Docker Engine that reads a YAML file, usually named compose.yaml, and creates the containers, networks, and volumes it describes. It is bundled with modern Docker Desktop and available as a plugin for Docker Engine on Linux. Nothing magical happens under the hood — it is just an orchestrator for one host.

The mental model is: each top-level entry under services: becomes one container. Compose attaches them to a private network named after your project, so they can find each other by service name.

A first compose.yaml

Let us build a tiny stack: a Node API and a Postgres database.

services:
  api:
    image: node:20-alpine
    working_dir: /app
    command: node server.js
    volumes:
      - ./api:/app
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://app:secret@db:5432/app
    depends_on:
      - db

  db:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: app
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

Two things deserve attention. First, the API talks to the database at the hostname db, which is just the service name — Compose runs a DNS server inside the project network. Second, pgdata is a named volume, so the database survives docker compose down.

Starting and stopping

The day-to-day commands are short and consistent.

docker compose up -d        # start everything in the background
docker compose ps           # see what is running
docker compose logs -f api  # tail logs from one service
docker compose exec api sh  # open a shell inside the api container
docker compose down         # stop and remove containers and networks

down removes containers and the project network, but it keeps named volumes by default. Add -v if you also want the volumes gone — useful when you want a fresh database.

Environment variables and .env

Hardcoding passwords in compose.yaml is fine for a throwaway demo and a bad idea otherwise. Compose automatically loads a .env file from the same directory, and you can reference variables with ${NAME} syntax.

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD}

Then in .env:

DB_PASSWORD=supersecret

Add .env to .gitignore and commit a .env.example instead, so teammates know which keys to set.

Building images, not just pulling them

If your service needs a custom image, point Compose at a Dockerfile.

services:
  api:
    build:
      context: ./api
      dockerfile: Dockerfile
    ports:
      - "3000:3000"

docker compose build builds the image, docker compose up --build rebuilds before starting. Compose tags the result with a name based on the project and service.

Healthchecks and depends_on

depends_on controls start order, but it does not wait for a service to be ready. For that, add a healthcheck and use condition: service_healthy.

services:
  db:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      timeout: 3s
      retries: 5
  api:
    image: my-api:latest
    depends_on:
      db:
        condition: service_healthy

Now the API does not even start until Postgres reports ready, which removes a whole class of flaky-startup bugs.

Profiles for optional services

Sometimes you want a stack with optional pieces — say, a monitoring service that you only want during debugging. Profiles let you opt in.

services:
  prometheus:
    image: prom/prometheus
    profiles: ["monitoring"]

Run it with docker compose --profile monitoring up. Without the flag, Prometheus is skipped.

When to outgrow Compose

Compose is excellent for local development, CI test environments, and small single-host deployments. It is not a cluster orchestrator. The moment you need rolling updates across multiple machines, autoscaling, or self-healing across nodes, you are looking at Kubernetes or a managed equivalent. Until then, Compose keeps your stack reproducible with almost no ceremony.

A well-written compose.yaml is one of the highest-leverage files in a project. New contributors can clone the repo, run one command, and have a working stack in minutes. That is worth the half hour it takes to write properly.