# Introduction to Docker Compose

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

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-08-28 | Topics: [Docker](https://flaviocopes.com/tags/docker/) | Canonical: https://flaviocopes.com/docker-compose/

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](https://flaviocopes.com/postgres-introduction/) 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:

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

Start it from the folder containing `compose.yaml`:

```bash
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:

```text
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:

```yaml
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:

- **services** lists the app and database containers
- **ports** maps host port 3000 to the app container
- **environment** sets configuration inside a container
- **depends_on** connects app startup to the database health check
- **healthcheck** tells Compose when Postgres accepts connections
- **volumes** keeps database data between container replacements

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:

```dockerfile
FROM node:24-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .

CMD ["npm", "start"]
```

Add a `.dockerignore` file too:

```text
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:

```bash
docker compose up
```

Add `-d` to run it in the background:

```bash
docker compose up -d
```

Rebuild changed images before starting:

```bash
docker compose up --build
```

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

Stop and remove the containers and default network:

```bash
docker compose down
```

Check the current state:

```bash
docker compose ps
```

View database logs:

```bash
docker compose logs db
```

Follow app logs as they arrive:

```bash
docker compose logs -f app
```

## Run one-off commands

Run tests in a new app container:

```bash
docker compose run --rm app npm test
```

Open a shell in the running app container:

```bash
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:

```bash
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:

```yaml
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:

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

Then create a local `.env` file:

```text
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:

```yaml
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:

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

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

```bash
docker compose config
```

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

## Common Compose mistakes

The same problems appear often:

- using `localhost` instead of a service name between containers
- assuming start order means a database is ready
- forgetting that `down -v` removes persistent data
- mounting host `node_modules` over container dependencies
- starting an old image without rebuilding it
- putting production secrets in a committed Compose file
- expecting Compose to run database migrations automatically

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](https://flaviocopes.com/docker-introduction/), then [Docker containers](https://flaviocopes.com/docker-containers/) and [Dockerfiles](https://flaviocopes.com/docker-dockerfiles/).

[Docker first steps](https://flaviocopes.com/docker-first-steps/) walks through your first image if you have not built one yet.
