Introduction to Docker Compose

By

Introduction to Docker Compose: run multi-container apps from one file, with services, volumes, ports, and a Node plus Postgres example.

~~~

Docker Compose runs multi-container setups from a single file. You define every service once, then start the whole stack with one command. The file is usually named compose.yaml.

Compose is not another container runtime. Docker still builds images and runs containers. Compose describes how several containers fit together.

The problem

Say you have a Node app and a Postgres database. Running them with plain docker run means long commands, manual networking, and easy mistakes.

You need to remember ports, environment variables, volumes, and start order every time.

Compose fixes that. One compose.yaml file describes the full stack. A teammate can clone the repo and start the same services without rebuilding every command.

Start with one service

Let’s start with Postgres alone:

services:
  db:
    image: postgres:18
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: myapp
    ports:
      - '5432:5432'

Start it from the folder containing compose.yaml:

docker compose up

Compose creates a container, a default network, and the configuration needed by that service. Press Ctrl-C to stop it.

The port mapping has this shape:

HOST_PORT:CONTAINER_PORT

Our laptop can reach Postgres on localhost:5432. Other containers should use the service name db and the container port 5432 instead.

Add the Node app

Now let’s add a Node app:

services:
  app:
    build: .
    ports:
      - '3000:3000'
    environment:
      DATABASE_URL: postgres://postgres:secret@db:5432/myapp
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:18
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: myapp
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U postgres -d myapp']
      interval: 5s
      timeout: 3s
      retries: 5
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

A few things to notice:

The hostname db works because Compose creates a network and DNS for the services.

Do not use localhost in DATABASE_URL. Inside the app container, localhost means the app container itself. The database is another container, so its hostname is db.

The health check matters. The short depends_on: [db] syntax only controls start order. A running Postgres container may not be ready to accept connections yet.

Even a health check is not a complete availability guarantee. The database can fail later. The app still needs to handle failed connections.

Build the app image

The build: . line tells Compose to build from the Dockerfile in the current folder.

Here is a small Dockerfile for a Node app:

FROM node:24-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .

CMD ["npm", "start"]

Add a .dockerignore file too:

node_modules
.git
.env

The image installs its own Linux dependencies. Copying our host node_modules folder can introduce native binaries built for the wrong operating system.

Use image: my-app:latest instead of build when the image already exists in a registry.

Start, stop, and inspect the stack

Run the stack in the foreground:

docker compose up

Add -d to run it in the background:

docker compose up -d

Rebuild changed images before starting:

docker compose up --build

Compose still uses Docker’s build cache. Unchanged image layers remain fast.

Stop and remove the containers and default network:

docker compose down

Check the current state:

docker compose ps

View database logs:

docker compose logs db

Follow app logs as they arrive:

docker compose logs -f app

Run one-off commands

Run tests in a new app container:

docker compose run --rm app npm test

Open a shell in the running app container:

docker compose exec app sh

run creates a new one-off container. exec runs inside the existing service container. That difference matters when debugging files or process state.

Volumes for persistent data

Containers are replaceable. Their writable files disappear when the containers are removed.

The pgdata volume stores Postgres data outside the database container. The tables survive docker compose down and a later docker compose up.

Remove containers and named volumes with:

docker compose down -v

Be careful with -v. It removes the database data in this project. I use it when I deliberately want a clean local database.

Bind mounts for development

Rebuilding after every source change is slow. A bind mount can expose the project folder inside the container:

services:
  app:
    build: .
    command: npm run dev
    volumes:
      - .:/app
      - app_node_modules:/app/node_modules

volumes:
  app_node_modules:

The first mount maps our source code into /app. The second keeps the container’s dependencies separate from the host.

Bind mounts are convenient in development. I avoid them in production because the image should already contain the exact code it runs.

Modern Compose also supports file watching with docker compose up --watch. Watch rules can sync source files or rebuild a service. This helps when bind-mount performance is poor or rebuild behavior needs to be explicit.

Move configuration out of the file

Compose reads a .env file for variable interpolation. We can make the port and password configurable:

services:
  db:
    image: postgres:18
    ports:
      - '${POSTGRES_PORT:-5432}:5432'
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}

Then create a local .env file:

POSTGRES_PORT=5433
POSTGRES_PASSWORD=local-secret

Add .env to .gitignore when it contains credentials. Commit an .env.example with empty values so the required configuration stays documented.

Environment variables are not a strong production secret store. They can appear in container inspection output. Use the secret mechanism provided by your deployment platform.

Override development settings

Compose can merge several files. I like to keep the base service definition in compose.yaml and development-only mounts in compose.override.yaml.

Compose loads that conventional override automatically:

services:
  app:
    command: npm run dev
    volumes:
      - .:/app
      - app_node_modules:/app/node_modules

volumes:
  app_node_modules:

For an explicit test override, pass both paths:

docker compose -f compose.yaml -f compose.test.yaml up

Later files override or extend earlier files. Inspect the merged configuration with:

docker compose config

This command also catches invalid YAML and missing variables before containers start.

Common Compose mistakes

The same problems appear often:

Compose starts infrastructure. Application setup and recovery still belong to the application.

How I use Docker Compose

I use Compose when a project needs supporting services: a database, Redis, a mail catcher, or a local object store. The application may run in a container too, but it does not have to.

I keep the base file small. It defines services, networks, health checks, and persistent volumes. Development overrides add bind mounts and watch commands.

I would not add Compose to a project that only runs one Node process and needs no external service. A normal npm run dev command is easier. Compose earns its place when it removes real setup or keeps several services consistent.

Before you use Compose

Compose builds on core Docker concepts. If you are new, read Docker introduction, then Docker containers and Dockerfiles.

Docker first steps walks through your first image if you have not built one yet.

Tagged: Docker · All topics

Want me to talk about your product? You can sponsor this site.

~~~

Related posts about docker: