Most developers have been here. You clone a repo, install Node, Postgres, Redis, and whatever else the README demands. Half an hour later you are debugging why the database connection is refused, or why the Redis port conflicts with something already running on your machine. Then you pull a new branch, and the cycle starts over.
Docker Compose fixes this. One file, one command, every service spins up identically on every developer's machine. No more "works on my machine."
Prerequisites
- Docker Engine 24+ and Docker Compose v2 (check with
docker versionanddocker compose version) - A Node.js project (even a fresh one works)
- Basic terminal familiarity
Project Structure
Start with a minimal Node.js app that connects to PostgreSQL and Redis:
my-app/
src/
index.js
package.json
docker-compose.yml
.env
.dockerignore
{
"name": "my-app",
"version": "1.0.0",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"express": "^4.21.0",
"pg": "^8.13.0",
"ioredis": "^5.4.0"
}
}
// src/index.js
const express = require('express');
const { Pool } = require('pg');
const Redis = require('ioredis');
const app = express();
const redis = new Redis({ host: 'redis', port: 6379 });
const pool = new Pool({
host: 'postgres',
port: 5432,
database: process.env.DB_NAME || 'myapp',
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'postgres',
});
app.get('/', async (req, res) => {
await redis.set('hits', (await redis.get('hits') || 0) + 1);
const hits = await redis.get('hits');
const dbResult = await pool.query('SELECT NOW()');
res.json({
message: 'Hello from Docker Compose!',
hits,
serverTime: dbResult.rows[0].now,
});
});
app.listen(3000, () => console.log('Running on port 3000'));
Notice the database host is postgres, not localhost. Inside Docker Compose, services talk to each other by their service name. If you leave localhost here, your app will connect to itself and fail.
The docker-compose.yml
Here is the full file, then we break it down:
services:
app:
build: .
ports:
- "3000:3000"
volumes:
- ./src:/app/src
environment:
- DB_HOST=postgres
- DB_PORT=5432
- DB_NAME=myapp
- DB_USER=postgres
- DB_PASSWORD=postgres
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- devnet
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: myapp
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d myapp"]
interval: 5s
timeout: 3s
retries: 5
networks:
- devnet
redis:
image: redis:7-alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
networks:
- devnet
volumes:
pgdata:
networks:
devnet:
What each section does
app service. Builds the Dockerfile (covered below), maps port 3000, and mounts ./src into the container. That volume mount is what makes hot reload work. Edit a file locally, and the running process inside the container picks it up immediately.
postgres service. Uses the official Alpine image. The volumes section persists data between restarts, so you do not lose your database every time you run docker compose down. The healthcheck ensures the app does not try to connect before Postgres is ready.
redis service. Same pattern. Lightweight Alpine image, healthcheck with redis-cli ping.
depends_on with condition. This is the key part. Without condition: service_healthy, Docker just checks if the container started, not if the service inside is actually accepting connections. Your app would try to connect, fail, and crash.
The Dockerfile
You need a Dockerfile for the app service:
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
CMD ["npm", "run", "dev"]
No multi-stage build here. This is local development, not production. We keep it simple. The --watch flag in the dev script restarts the Node process when files change.
Add a .dockerignore to avoid sending unnecessary files to the build context:
node_modules
npm-debug.log
.git
.env
.env.*
Environment Variables
Keep secrets out of docker-compose.yml. Use a .env file in the project root:
# .env
DB_HOST=postgres
DB_PORT=5432
DB_NAME=myapp
DB_USER=postgres
DB_PASSWORD=postgres
Docker Compose reads this file automatically and injects the values into services that reference them with ${VARIABLE_NAME} syntax. If you prefer not to use a .env file, you can set the variables inline in docker-compose.yml or export them in your shell.
Running the Stack
# Build and start everything
docker compose up --build
# Run in background
docker compose up --build -d
# View logs
docker compose logs -f app
# Stop everything
docker compose down
# Stop and remove volumes (fresh database)
docker compose down -v
Run docker compose up --build for the first time. Subsequent starts without code changes to the Dockerfile skip the build step. Adding -d runs everything in the background.
docker compose down -v wipes the database. Useful when you need a clean slate. Without -v, your data persists across restarts.
Hot Reload in Action
The volume mount ./src:/app/src is doing the heavy lifting here. When you edit src/index.js on your host machine, the change is reflected inside the container immediately. Since the dev script uses node --watch, Node detects the file change and restarts.
Try this:
- Start the stack with
docker compose up - Open
src/index.js - Change the response message
- Save the file
- Hit
http://localhost:3000again
The new response shows up without restarting any container. The full rebuild-and-restart cycle takes under a second.
Connecting to Services from Your Host
The ports mapping makes services accessible outside Docker. Connect to Postgres from your local pgAdmin, Tableau, or psql:
psql -h localhost -p 5432 -U postgres -d myapp
Same for Redis:
redis-cli -h localhost -p 6379
This is useful for debugging. You can inspect the database state while the app is running without installing Postgres or Redis on your machine.
Common Pitfalls
Container starts but app crashes with connection refused. The app is trying to connect to localhost or 127.0.0.1. Inside a container, localhost refers to the container itself, not the host or other containers. Use the service name (postgres, redis) as the hostname.
Port already in use. If Postgres or Redis is already running on your host, the ports mapping fails. Either stop the local service or change the host port: "5433:5432 maps the container's 5432 to your host's 5433.
Slow first build. Docker downloads the base images on the first run. Subsequent builds use the cache and start much faster.
Data disappears after docker compose down. Without a named volume (like pgdata), data is stored in the container's writable layer and gets deleted when the container is removed. The volumes section in the Postgres service prevents this.
Health check fails. If pg_isready or redis-cli ping returns an error, the dependent services never start. Check that the database name and user in the healthcheck match the POSTGRES_DB and POSTGRES_USER values.
Extending the Setup
Once the basics work, consider these additions:
- Add a worker service (e.g., Bull queue consumer) by copying the app configuration and changing the
CMD - Mount TLS certificates for services that need them
- Add a reverse proxy like Nginx or Caddy in front of the app for HTTPS locally
- Use Docker Compose profiles to optionally run extra services:
docker compose --profile debug up
When to Stop Using This
Docker Compose works well for local development and simple deployments. It does not handle orchestration, auto-scaling, or rolling updates. When you need those, move to Kubernetes or a managed platform. But for daily development, Compose is hard to beat for simplicity.