How to Set Up Docker Compose for Node.js: A Practical Guide for 2026
If you’ve ever lost a day debugging the classic “works on my machine” problem, you already understand why containerization became the default way we ship applications. But spinning up a single container for a Node.js service is only half the story. The moment you add a database, a cache, or a queue, you need orchestration — and that’s exactly where Docker Compose shines.
This tutorial walks you through how to set up Docker Compose for Node.js, from a bare-bones Express API to a multi-service stack with Postgres and Redis. We’ll cover real pitfalls I’ve hit in production, optimization tricks that cut build times in half, and patterns that scale beyond toy projects.
Why Bother With Docker Compose for Node.js?
Before we write a single line of YAML, let’s talk about the why. Running node index.js locally is easy. So why add Docker Compose to the mix?
Consistent environments across machines. Every developer on your team runs the exact same Node.js version, the exact same Postgres version, and the exact same environment variables. No more “it works on Node 20 but breaks on Node 22.”
One-command onboarding. A new teammate clones your repo, runs docker compose up, and gets a fully functional stack — API, database, cache, and all — in under a minute.
Easy multi-service orchestration. Modern Node.js apps rarely live in isolation. They talk to Postgres, Redis, S3-compatible storage, RabbitMQ, you name it. Compose lets you define all of that declaratively in a single file.
Production parity for testing. The closer your dev environment mirrors production, the fewer surprises you get on deploy day.
Prerequisites
Before we dive in, make sure you have the following installed and working on your machine.
Required Tools
- Docker Engine 24.x or newer with the built-in Compose plugin (the
docker composecommand, not the legacydocker-composePython script). On macOS and Windows, Docker Desktop 4.30+ ships with this. On Linux, install via your package manager and add thedocker-compose-pluginpackage. - Node.js 22 LTS or newer (optional but recommended — you’ll want it for local debugging outside containers).
- A code editor with YAML support. VS Code or Cursor both work great.
- Basic command-line comfort. You should know how to navigate directories and read logs.
Verify Your Setup
Run these commands to confirm everything is in place:
docker --version
# Expected output: Docker version 25.x.x or newer
docker compose version
# Expected output: Docker Compose version v2.x.x
If the second command fails, you’re likely using the old docker-compose Python script. Modern Compose v2 is written in Go and integrates directly with the Docker CLI. Upgrade Docker Desktop or install the plugin — it’s a meaningful speed improvement.
Project Structure
Let’s build a realistic Express API backed by Postgres and Redis. Here’s the structure we’ll work toward:
my-node-app/
├── src/
│ └── index.js
├── package.json
├── .dockerignore
├── Dockerfile
├── docker-compose.yml
└── .env.example
Keep this layout in mind as we go. We’ll populate each file with copy-paste-ready content.
Step 1: Initialize the Node.js Application
Let’s start with the application itself. Create a project directory and initialize it:
mkdir my-node-app && cd my-node-app
npm init -y
npm install express pg redis dotenv
Now create src/index.js with a minimal but realistic Express server that connects to Postgres and Redis:
// src/index.js
require('dotenv').config();
const express = require('express');
const { Pool } = require('pg');
const redis = require('redis');
const app = express();
const PORT = process.env.PORT || 3000;
// Postgres connection
const pool = new Pool({
host: process.env.POSTGRES_HOST,
port: process.env.POSTGRES_PORT || 5432,
user: process.env.POSTGRES_USER,
password: process.env.POSTGRES_PASSWORD,
database: process.env.POSTGRES_DB,
});
// Redis client (Redis v4+ syntax)
const redisClient = redis.createClient({
url: `redis://${process.env.REDIS_HOST}:${process.env.REDIS_PORT || 6379}`,
});
redisClient.on('error', (err) => console.error('Redis error:', err));
app.use(express.json());
app.get('/health', async (req, res) => {
try {
const pgResult = await pool.query('SELECT NOW()');
const pong = await redisClient.ping();
res.json({
status: 'ok',
postgres: pgResult.rows[0].now,
redis: pong,
});
} catch (err) {
res.status(500).json({ status: 'error', message: err.message });
}
});
(async () => {
await redisClient.connect();
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
})();
This gives us something meaningful to containerize — a health endpoint that actually verifies connectivity to both Postgres and Redis.
Step 2: Create the Dockerfile
The Dockerfile defines how your Node.js image is built. Here’s an optimized, production-grade version:
# Dockerfile
FROM node:22-alpine AS base
# Set working directory
WORKDIR /app
# Install dependencies separately for better layer caching
COPY package*.json ./
RUN npm ci --omit=dev
# Copy application source
COPY . .
# Drop privileges — never run as root in production
USER node
EXPOSE 3000
CMD ["node", "src/index.js"]
Why This Dockerfile Works
A few design decisions worth explaining:
Alpine base image. node:22-alpine is around 50MB compared to 350MB+ for the full Debian-based image. Smaller images mean faster pulls and a smaller attack surface.
npm ci --omit=dev over npm install. The ci command is faster, more reliable in CI environments, and respects the lockfile strictly. Omitting dev dependencies keeps the image lean.
Layer caching. Copying package*.json separately from the rest of the source means dependency installs are cached and only re-run when your package.json or package-lock.json change. This single trick can cut rebuild times from minutes to seconds.
Non-root user. The node user is built into the official Node image. Running as root inside a container is a security smell, even in development.
Step 3: Create the .dockerignore File
Skipping .dockerignore is one of the most common mistakes I see. Without it, Docker copies your entire project directory — including node_modules, .git, and local .env files — into the build context. That’s both slow and dangerous.
# .dockerignore
node_modules
npm-debug.log
.env
.env.local
.git
.gitignore
Dockerfile
docker-compose*.yml
.DS_Store
coverage
.nyc_output
dist
build
The single biggest win here is excluding node_modules. If you don’t, Docker copies your host’s node_modules (compiled for your host architecture) into the image, which can break on different platforms and wastes a huge amount of build time.
Step 4: Write the docker-compose.yml File
This is the heart of the tutorial. Here’s a complete, production-minded docker-compose.yml for our three-service stack:
# docker-compose.yml
services:
app:
build:
context: .
target: base
container_name: my-node-app
restart: unless-stopped
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- PORT=3000
- POSTGRES_HOST=postgres
- POSTGRES_PORT=5432
- POSTGRES_USER=appuser
- POSTGRES_PASSWORD=secretpassword
- POSTGRES_DB=appdb
- REDIS_HOST=redis
- REDIS_PORT=6379
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
networks:
- app-network
postgres:
image: postgres:17-alpine
container_name: my-node-postgres
restart: unless-stopped
environment:
- POSTGRES_USER=appuser
- POSTGRES_PASSWORD=secretpassword
- POSTGRES_DB=appdb
ports:
- "5432:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
interval: 10s
timeout: 5s
retries: 5
networks:
- app-network
redis:
image: redis:7-alpine
container_name: my-node-redis
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- redis-data:/data
networks:
- app-network
volumes:
postgres-data:
redis-data:
networks:
app-network:
driver: bridge
Breaking Down the Configuration
Let me walk you through the non-obvious bits — the parts that separate a working setup from a brittle one.
Service names as hostnames. Inside the app-network, your Node container can reach Postgres at postgres:5432 and Redis at redis:6379. Compose’s built-in DNS handles this automatically. That’s why POSTGRES_HOST=postgres works without any additional configuration.
Healthchecks with depends_on conditions. The naive approach is to write depends_on: [postgres, redis] and hope for the best. But Compose v2 will start the dependencies and immediately proceed — Node will try to connect before Postgres has finished its initial bootstrap. Using condition: service_healthy with a proper healthcheck ensures your app only starts once Postgres is genuinely ready to accept connections.
Named volumes for persistence. Without volumes: - postgres-data:/var/lib/postgresql/data, your database is wiped every time you run docker compose down. Named volumes persist across container restarts and rebuilds.
Bridge networks for isolation. The custom app-network keeps your services isolated from other Compose stacks running on the same machine. This matters more than you’d think once you have multiple projects in flight.
Step 5: Start Everything Up
Now that all our files are in place, let’s bring the stack to life:
# Build and start all services in the background
docker compose up -d --build
# Watch the logs from all services
docker compose logs -f
# Check the status of each container
docker compose ps
The first build will take a minute or two because Docker has to pull the base images and install dependencies. Subsequent builds will use the layer cache and finish in seconds.
Once everything is up, test the health endpoint:
curl http://localhost:3000/health
You should get a response that looks something like:
{
"status": "ok",
"postgres": "2026-01-15T10:23:45.000Z",
"redis": "PONG"
}
If you see that JSON, congratulations — you’ve successfully set up Docker Compose for Node.js with Postgres and Redis backing it.
Development Workflow: Hot Reloading Without Rebuilds
The configuration above is great for staging or a smoke test, but during active development you want hot reloading — saving a file should immediately reflect in the running container without a rebuild.
Create a separate file called docker-compose.dev.yml:
# docker-compose.dev.yml
services:
app:
build:
context: .
dockerfile: Dockerfile.dev
volumes:
- .:/app
- /app/node_modules
command: npx nodemon src/index.js
environment:
- NODE_ENV=development
postgres:
# Inherits everything from docker-compose.yml
ports:
- "5433:5432" # Avoid conflicting with host Postgres
redis:
ports:
- "6380:6379" # Same idea
And a lightweight Dockerfile.dev:
# Dockerfile.dev
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npx", "nodemon", "src/index.js"]
Now run with the development override:
docker compose -f docker-compose.yml -f docker-compose.dev.yml up
Why the Anonymous /app/node_modules Volume?
This is one of the trickiest Docker concepts for newcomers. When you mount your local directory with - .:/app, you’re effectively overlaying the container’s filesystem with your host’s. That means the container’s node_modules get overwritten by whatever is (or isn’t) on your host.
The anonymous volume /app/node_modules tells Docker: “Keep this directory in a separate volume and don’t sync it with the host.” This preserves the dependencies installed inside the container, even when the rest of the app directory is mounted from your host.
Common Pitfalls and How to Avoid Them
I’ve shipped Node.js services in containers for years, and the same handful of issues keep biting developers. Here’s how to sidestep them.
Pitfall 1: Binding to localhost Inside the Container
Symptom: Your app runs fine locally but isn’t reachable from outside the container, even though you mapped the port.
Cause: In Node.js, app.listen(3000, 'localhost') binds to the loopback interface inside the container. From the host’s perspective, the port is closed.
Fix: Bind to 0.0.0.0 or omit the host argument entirely:
// Wrong — only reachable from inside the container
app.listen(3000, 'localhost');
// Right — reachable from outside
app.listen(3000, '0.0.0.0');
app.listen(3000); // This also works — defaults to all interfaces
Pitfall 2: Connecting to localhost for the Database
Symptom: Error: connect ECONNREFUSED 127.0.0.1:5432 when your app tries to reach Postgres.
Cause: Inside a container, localhost refers to the container itself, not your host machine. Postgres lives in a different container with its own network namespace.
Fix: Use the service name as the hostname. In our compose file, that’s POSTGRES_HOST=postgres. Compose’s internal DNS routes that to the right container automatically.
Pitfall 3: Race Conditions on Startup
Symptom: Your app crashes on boot with a database connection error, but works if you restart it manually.
Cause: Even with depends_on, the container for Postgres is “started” before Postgres has finished its initial setup. Your app tries to connect before Postgres is ready.
Fix: Use a healthcheck (we did this above) combined with condition: service_healthy. As a fallback, add retry logic in your application code:
const connectWithRetry = async (retries = 5, delay = 2000) => {
for (let i = 0; i < retries; i++) {
try {
await pool.query('SELECT 1');
console.log('Connected to Postgres');
return;
} catch (err) {
console.log(`Postgres not ready, retrying in ${delay / 1000}s...`);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw new Error('Failed to connect to Postgres');
};
Pitfall 4: Secret Management in YAML
Symptom: You push your compose file to GitHub and realize your database password is now public.
Cause: Putting secrets directly in docker-compose.yml makes them visible in version control.
Fix: Use a .env file with env_file:
services:
app:
env_file:
- .env
environment:
- POSTGRES_HOST=postgres
And in .env (which is in .gitignore):
POSTGRES_USER=appuser
POSTGRES_PASSWORD=a_real_secret_from_dotenv
POSTGRES_DB=appdb
For production, look into Docker Secrets or your orchestrator’s secrets manager (Vault, AWS Secrets Manager, GCP Secret Manager).
Pitfall 5: Forgetting to Prune Volumes
Symptom: Your disk fills up after a few months of Compose usage.
Cause: Named volumes survive docker compose down by design. Old test databases pile up.
Fix: Use docker compose down -v when you want a complete teardown, or periodically prune unused volumes:
# Remove this project's volumes along with containers
docker compose down -v
# Globally remove unused volumes (be careful!)
docker volume prune
Real-World Use Cases
Let’s talk about where this pattern actually shines in production environments.
Use Case 1: Local Development Stacks for Microservices
If your team is moving toward microservices, you’ll quickly find that running five separate services locally is a nightmare without Compose. Each service can have its own `