Introduction to Docker Compose
By Flavio Copes
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:
- 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:
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:
- using
localhostinstead of a service name between containers - assuming start order means a database is ready
- forgetting that
down -vremoves persistent data - mounting host
node_modulesover 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, then Docker containers and Dockerfiles.
Docker first steps walks through your first image if you have not built one yet.
Want me to talk about your product? You can sponsor this site.
Related posts about docker: