Skip to content
C Codeloom
Docker

Docker Compose for Multi-Container Apps

Learn Docker Compose by building a small two-service app — a web API and a Postgres database — defined in a single compose.yaml file and started with one command.

·8 min read · By Yash Kesharwani
Beginner 12 min read

What you'll learn

  • What Docker Compose is and when to reach for it
  • How to write a compose.yaml file that describes multiple services
  • How service-to-service networking works automatically
  • How to use environment variables, volumes, and named volumes
  • The everyday docker compose commands

Prerequisites

A single container is fine for a single process. Real applications are rarely a single process — they usually combine a web server, a database, a cache, a background worker, and sometimes more. Starting six containers by hand, with the right ports and flags, gets old quickly. Docker Compose is the tool that fixes this.

What Compose is

Compose lets you describe a multi-container application in a single YAML file (compose.yaml), then start, stop, and rebuild the whole thing with one command. Each top-level entry under services: is a container Compose will manage for you.

Two things are worth knowing up front:

  • docker compose is built in. It is now a plugin to the main Docker CLI, invoked as docker compose (two words). The old standalone docker-compose binary is deprecated — use the new form.
  • Compose is for development and small deployments. Production at scale typically uses an orchestrator like Kubernetes, but for local development, prototypes, and modest single-host setups, Compose is excellent.

A worked example: web API plus database

We will build a small Express API that reads a counter from Postgres, increments it on each request, and returns the new value.

Create a folder called compose-demo with this layout:

compose-demo/
  api/
    Dockerfile
    package.json
    server.js
  compose.yaml

api/package.json

{
  "name": "compose-demo-api",
  "version": "1.0.0",
  "main": "server.js",
  "scripts": { "start": "node server.js" },
  "dependencies": {
    "express": "^4.19.2",
    "pg": "^8.11.5"
  }
}

api/server.js

const express = require('express');
const { Pool } = require('pg');

const pool = new Pool({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  port: 5432,
});

async function init() {
  await pool.query(`
    CREATE TABLE IF NOT EXISTS counters (
      id INT PRIMARY KEY,
      value INT NOT NULL
    );
  `);
  await pool.query(`
    INSERT INTO counters (id, value) VALUES (1, 0)
    ON CONFLICT (id) DO NOTHING;
  `);
}

const app = express();

app.get('/', async (req, res) => {
  const result = await pool.query(
    'UPDATE counters SET value = value + 1 WHERE id = 1 RETURNING value'
  );
  res.json({ count: result.rows[0].value });
});

init()
  .then(() => app.listen(3000, '0.0.0.0', () =>
    console.log('API listening on 3000')))
  .catch((err) => {
    console.error('Failed to initialise DB:', err);
    process.exit(1);
  });

api/Dockerfile

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]

This is the same Dockerfile shape from the previous post. Nothing new yet.

Writing the compose file

Now the interesting file — compose.yaml at the project root:

services:
  api:
    build: ./api
    ports:
      - "3000:3000"
    environment:
      DB_HOST: db
      DB_USER: app
      DB_PASSWORD: secret
      DB_NAME: appdb
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: appdb
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  db-data:

Read this top-down. Two services, api and db. Let us unpack what each line does.

The api service

  • build: ./api — instead of pulling a prebuilt image, build one from the Dockerfile in the api/ folder. Compose will run the equivalent of docker build ./api.
  • ports: ["3000:3000"] — publish container port 3000 on host port 3000, same as -p 3000:3000.
  • environment: — set environment variables inside the container. The DB_HOST value is db — more on that in a moment.
  • depends_on: — start the db service before this one, and wait for the database’s healthcheck to pass.

The db service

  • image: postgres:16-alpine — use the official Postgres 16 image.
  • environment: — the Postgres image reads these variables at first start to create the user, password, and database for us.
  • volumes: db-data:/var/lib/postgresql/data — mount a named volume at Postgres’s data directory so the database survives docker compose down and restarts.
  • healthcheck: — Compose runs pg_isready periodically. The api service uses this to wait until Postgres is genuinely ready, not just started.

The networking magic

Compose automatically creates a network shared by all services in the file. Inside that network, each service is reachable from the others by its service name. That is why the API uses DB_HOST: db — not localhost, not an IP address, just db. Compose’s built-in DNS resolves it to the database container.

This is the single biggest reason Compose makes multi-container work pleasant.

Starting and stopping

From the compose-demo folder:

# Build images (if needed) and start everything in the background
docker compose up -d

# Watch logs from all services, live
docker compose logs -f

# Watch logs from one service only
docker compose logs -f api

Visit http://localhost:3000. You should see {"count": 1}. Refresh — {"count": 2}. The counter is persisting to Postgres.

To check service status:

docker compose ps

To stop everything:

docker compose stop          # stop containers, keep them
docker compose start         # restart stopped containers

docker compose down          # stop and remove containers + network
docker compose down -v       # also remove named volumes (deletes the database)

docker compose down is non-destructive for your named volumes by default. Add -v only when you want to throw away data — for example, to start fresh during development.

Try it yourself. With the stack running, run docker compose down (without -v). Then docker compose up -d again and visit localhost:3000. The count continues from where it left off — your named db-data volume preserved the data. Now run docker compose down -v and bring it back up. The count restarts at 1.

Rebuilding after code changes

docker compose up -d only rebuilds an image if Compose thinks it needs to. When you change application code, force a rebuild:

docker compose up -d --build

This rebuilds any services with a build: key and restarts containers that use the new image. Containers for unchanged services are left alone.

For very rapid iteration, you can also mount your source code into the container as a bind mount so changes appear instantly without a rebuild. Add to the api service:

    volumes:
      - ./api:/app
      - /app/node_modules

The second line is a small trick: an anonymous volume on /app/node_modules prevents the host’s (possibly empty) node_modules folder from clobbering the one installed inside the image. This pattern is common for Node, Python, and Ruby development.

Environment variables and .env files

Hard-coding DB_PASSWORD: secret in a YAML file is fine for a demo, but real projects use a .env file next to compose.yaml:

DB_USER=app
DB_PASSWORD=secret
DB_NAME=appdb

Then reference the values in compose.yaml:

    environment:
      DB_HOST: db
      DB_USER: ${DB_USER}
      DB_PASSWORD: ${DB_PASSWORD}
      DB_NAME: ${DB_NAME}

Compose reads .env automatically when you run docker compose up. Add .env to your .gitignore so secrets do not end up in version control.

Useful everyday commands

# Run a one-off command in a new container (does not start the service)
docker compose run --rm api node -e "console.log(2+2)"

# Open a shell in an already-running service container
docker compose exec api sh
docker compose exec db psql -U app -d appdb

# See the final, fully-resolved configuration
docker compose config

# Remove only stopped containers, keep volumes
docker compose rm

docker compose exec is the workhorse for debugging. Need to inspect database tables? docker compose exec db psql -U app -d appdb drops you into a psql prompt connected to the running database. No port mapping required.

Try it yourself. With the stack running, open a psql shell into the database and inspect the counters table:

docker compose exec db psql -U app -d appdb -c "SELECT * FROM counters;"

You should see the current count value. Then hit localhost:3000 a few more times in your browser and rerun the query — the value should increase.

Recap

You now know:

  • Docker Compose is the standard tool for running multi-container applications locally
  • One compose.yaml file describes every service, network, and volume
  • Services can reach each other on a built-in network by service name as the hostname
  • Named volumes keep databases and other stateful data alive across restarts
  • docker compose up -d, down, logs, exec, and --build cover most everyday needs
  • depends_on with a healthcheck is the reliable way to start dependent services in the right order

Next steps

The final post in this series goes a layer deeper into the building blocks you have already been using. We will look at how images are actually stored, what layers really are, how the build cache works, and the difference between bind mounts and named volumes — the knowledge that makes the rest of Docker click into place.

→ Next: Docker Images, Layers, and Volumes Explained

Questions or feedback? Email codeloomdevv@gmail.com.