Multi-Service App with Docker Compose
Build a 4-service containerized app with health checks and production overlays.
Introduction
30 Second Summary
Every tech company runs multiple services that need to work together reliably. When one service crashes at 3am, you need confidence that it restarts itself and does not take the whole system down.
In this project, you will build a production-grade 4-service Docker stack featuring a Node.js API, PostgreSQL database, Redis cache, and Nginx reverse proxy. You will orchestrate everything with Docker Compose using health checks, custom networks, and production overlays.
What You'll Build
A containerized task management API with four interconnected services orchestrated by Docker Compose, demonstrating the exact container patterns used in production deployments.
By the end of this project, you'll have:
- A multi-stage Dockerfile that produces a minimal, secure production image under 200MB (compared to 1GB+ unoptimized).
- A fully orchestrated 4-service stack (API + PostgreSQL + Redis + Nginx) with health-check-driven startup ordering.
- A production overlay system that separates dev and prod configuration without duplicating Compose files.
- Secret Mission: Scale the API to multiple replicas and verify Nginx load-balances across them.
Are there any prerequisites?
This project assumes basic Docker experience (building images, running containers) and familiarity with the command line. You do not need prior experience with Docker Compose or multi-service architectures.
Set Up Project Structure and Application Code
Before you can containerize anything, you need an application to containerize. In this step, you will create a task management REST API with three supporting files that form the foundation of your multi-service stack.
The API itself is a lightweight Node.js app backed by PostgreSQL for data storage and Redis for caching. An Nginx reverse proxy sits in front, routing all incoming traffic to the API.
In this step, get ready to:
- Create the project directory structure with folders for the API and Nginx configuration.
- Write a minimal Node.js REST API with health, task CRUD, and Redis-backed visit counting endpoints.
- Create the Nginx reverse proxy configuration that routes traffic to the API service.
Create the project directory structure
Your project needs two subdirectories: one for the API source code and one for the Nginx configuration. You will create these from the terminal.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder with its subdirectories and move into it by running these commands:
mkdir docker-production-app
cd docker-production-app
mkdir api nginx
- Confirm the folders exist by running:
ls
You should see api and nginx in the output.
Don't see both folders?
Make sure you ran all three mkdir commands from inside the docker-production-app directory. If the project folder was created on your Desktop but you forgot to cd into it, the subdirectories landed in the wrong location.
help me figure out why my project folders weren't created correctly
- Open the project folder in VS Code. In VS Code, click File in the top menu bar.
- Click Open Folder.
- Select the docker-production-app folder on your Desktop, then click Open.
You should see the api and nginx folders in the VS Code Explorer sidebar on the left.
Write the Node.js REST API
The API is an Express application that connects to PostgreSQL for task storage and Redis for a visit counter. It also exposes a /health endpoint that Docker will use later to verify the container is running correctly.
- In VS Code, right-click the api folder in the Explorer sidebar.
- Click New File.
- Type package.json and press Enter.
- Define the project metadata and dependencies by pasting this code into package.json:
{
"name": "task-api",
"version": "1.0.0",
"description": "Production Docker demo API",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.21.0",
"pg": "^8.13.0",
"ioredis": "^5.4.0"
}
}
What does this file do?
- express is the web framework for handling HTTP requests.
- pg is the PostgreSQL client for Node.js.
- ioredis is the Redis client for Node.js.
- The start script tells Docker (and npm) how to launch the application.
- Save package.json by pressing Ctrl+S (Windows) or Cmd+S (macOS).
- Right-click the api folder again and click New File.
- Type server.js and press Enter.
- Set up Express and configure the database and cache connections by pasting this code into server.js:
const express = require("express");
const { Pool } = require("pg");
const Redis = require("ioredis");
const app = express();
app.use(express.json());
// PostgreSQL connection pool - reads config from environment variables
const pool = new Pool({
host: process.env.DB_HOST || "db",
port: process.env.DB_PORT || 5432,
database: process.env.DB_NAME || "taskdb",
user: process.env.DB_USER || "taskuser",
password: process.env.DB_PASSWORD || "taskpass",
});
// Redis client - used for visit counting
const redis = new Redis({
host: process.env.REDIS_HOST || "redis",
port: process.env.REDIS_PORT || 6379,
});
What does this code do?
This section imports the three packages from package.json and creates connection objects for PostgreSQL and Redis.
The fallback values ("db", "redis") are Docker Compose service names. When you run these containers together later, Docker resolves those names to the correct container IP addresses automatically.
You won't see a change yet. The next chunk adds the application logic that uses these connections.
- Below the Redis configuration, add the database initialization function and all route handlers by pasting this code:
// Create the tasks table if it doesn't exist yet
async function initDb() {
const client = await pool.connect();
try {
await client.query(`
CREATE TABLE IF NOT EXISTS tasks (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
completed BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT NOW()
)
`);
} finally {
client.release();
}
}
// Health check endpoint - Docker uses this to verify the container is ready
app.get("/health", async (req, res) => {
try {
await pool.query("SELECT 1");
await redis.ping();
res.json({ status: "healthy", hostname: require("os").hostname() });
} catch (err) {
res.status(503).json({ status: "unhealthy", error: err.message });
}
});
// GET all tasks and increment the visit counter
app.get("/tasks", async (req, res) => {
const visits = await redis.incr("visit_count");
const result = await pool.query("SELECT * FROM tasks ORDER BY created_at DESC");
res.json({ tasks: result.rows, visits: parseInt(visits) });
});
// POST a new task
app.post("/tasks", async (req, res) => {
const { title } = req.body;
if (!title) return res.status(400).json({ error: "Title is required" });
const result = await pool.query(
"INSERT INTO tasks (title) VALUES ($1) RETURNING *",
[title]
);
res.status(201).json(result.rows[0]);
});
// DELETE a task by ID
app.delete("/tasks/:id", async (req, res) => {
await pool.query("DELETE FROM tasks WHERE id = $1", [req.params.id]);
res.status(204).send();
});
// Start the server after initializing the database
const PORT = process.env.PORT || 3000;
initDb().then(() => {
app.listen(PORT, "0.0.0.0", () => {
console.log(`API running on port ${PORT}`);
});
});
What does this code do?
- initDb() creates the tasks table in PostgreSQL if it does not already exist.
- /health pings both PostgreSQL and Redis to confirm the app can reach its dependencies. Docker will call this endpoint to decide if the container is healthy.
- /tasks supports GET (list all), POST (create), and DELETE (remove by ID) operations.
- redis.incr("visit_count") increments a counter in Redis every time someone fetches tasks. This proves Redis caching works once the stack is running.
- The server listens on port 3000 and only starts after the database table is ready.
- Save server.js.
You will not be able to run this file yet because it depends on PostgreSQL and Redis containers that do not exist yet. That is expected.
Seeing red underlines in VS Code?
VS Code may show warnings about missing modules (express, pg, ioredis). This is normal because you have not run npm install locally. Docker will handle dependency installation inside the container in the next step.
help me understand why VS Code shows errors for my Node.js imports
✔️ Awesome, I've got everything!
Great. Double check you have saved both api/package.json and api/server.js.
ⓧ I'd like to double check the full code
Here is the complete api/package.json:
{
"name": "task-api",
"version": "1.0.0",
"description": "Production Docker demo API",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.21.0",
"pg": "^8.13.0",
"ioredis": "^5.4.0"
}
}
Here is the complete api/server.js:
const express = require("express");
const { Pool } = require("pg");
const Redis = require("ioredis");
const app = express();
app.use(express.json());
const pool = new Pool({
host: process.env.DB_HOST || "db",
port: process.env.DB_PORT || 5432,
database: process.env.DB_NAME || "taskdb",
user: process.env.DB_USER || "taskuser",
password: process.env.DB_PASSWORD || "taskpass",
});
const redis = new Redis({
host: process.env.REDIS_HOST || "redis",
port: process.env.REDIS_PORT || 6379,
});
async function initDb() {
const client = await pool.connect();
try {
await client.query(`
CREATE TABLE IF NOT EXISTS tasks (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
completed BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT NOW()
)
`);
} finally {
client.release();
}
}
app.get("/health", async (req, res) => {
try {
await pool.query("SELECT 1");
await redis.ping();
res.json({ status: "healthy", hostname: require("os").hostname() });
} catch (err) {
res.status(503).json({ status: "unhealthy", error: err.message });
}
});
app.get("/tasks", async (req, res) => {
const visits = await redis.incr("visit_count");
const result = await pool.query("SELECT * FROM tasks ORDER BY created_at DESC");
res.json({ tasks: result.rows, visits: parseInt(visits) });
});
app.post("/tasks", async (req, res) => {
const { title } = req.body;
if (!title) return res.status(400).json({ error: "Title is required" });
const result = await pool.query(
"INSERT INTO tasks (title) VALUES ($1) RETURNING *",
[title]
);
res.status(201).json(result.rows[0]);
});
app.delete("/tasks/:id", async (req, res) => {
await pool.query("DELETE FROM tasks WHERE id = $1", [req.params.id]);
res.status(204).send();
});
const PORT = process.env.PORT || 3000;
initDb().then(() => {
app.listen(PORT, "0.0.0.0", () => {
console.log(`API running on port ${PORT}`);
});
});
Create the Nginx reverse proxy configuration
A reverse proxy sits between external traffic and your API. Instead of exposing the Node.js server directly, Nginx receives all requests on port 80 and forwards them to the API on port 3000.
- In VS Code, right-click the nginx folder in the Explorer sidebar.
- Click New File.
- Type nginx.conf and press Enter.
- Define the reverse proxy routing by pasting this configuration into nginx.conf:
upstream api_backend {
server api:3000;
}
server {
listen 80;
server_name localhost;
location / {
proxy_pass http://api_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /nginx-health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
}
What does this configuration do?
- upstream api_backend defines the backend server group. The name api is the Docker Compose service name. Docker resolves it to the container's IP.
- location / forwards all requests to the API and passes along the original client headers.
- location /nginx-health returns a simple 200 response. Docker will use this endpoint to health-check the Nginx container itself.
- Save nginx.conf.
In the VS Code Explorer, confirm you now see three files: api/package.json, api/server.js, and nginx/nginx.conf.
Missing a file or folder?
Check that you created the file inside the correct subfolder. If nginx.conf ended up in the project root instead of inside nginx/, drag it into the nginx folder in the Explorer sidebar.
help me fix my project file structure
✔️ Awesome, I've got everything!
Great. Confirm you have saved nginx/nginx.conf and your Explorer shows both subfolders with all three files.
ⓧ I'd like to double check the full code
Here is the complete nginx/nginx.conf:
upstream api_backend {
server api:3000;
}
server {
listen 80;
server_name localhost;
location / {
proxy_pass http://api_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /nginx-health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
}
Your application code is ready. Next up, you will write a multi-stage Dockerfile that turns this code into a minimal, secure production image.
Build a Multi-Stage Dockerfile
Your application code and Nginx config are in place. Now you need to package the API into a container image that is small, fast to deploy, and secure.
A typical Docker image built on a full operating system can easily reach 400MB or more. Production teams use multi-stage builds to shrink that dramatically. The idea is simple: use a heavy image with build tools in one stage, then copy only the finished artifacts into a tiny runtime image.
In this step, get ready to:
- Write a multi-stage Dockerfile with a build stage and a production stage.
- Harden the production stage with a non-root user and proper file ownership.
- Create a .dockerignore file and compare image sizes between single-stage and multi-stage approaches.
Write the build stage of the Dockerfile
A multi-stage Dockerfile uses multiple FROM statements. Each FROM starts a new stage. The first stage installs dependencies using the full Node.js image (which includes npm and build tools). The second stage copies only the finished node_modules into a minimal Alpine image.
- In VS Code, right-click the api folder in the Explorer sidebar.
- Select New File and name it Dockerfile (no extension).
- Add the build stage by pasting this code into the file:
# Stage 1: Install dependencies
FROM node:24 AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --only=production
What does this code do?
This is the build stage. It uses the full node:24 image (~390MB) which includes all the tools needed to compile native npm packages.
- FROM node:24 AS build names this stage build so the next stage can reference it.
- COPY package.json package-lock.json* ./ copies only the dependency manifests (the * handles cases where no lock file exists yet).
- RUN npm ci --only=production installs production dependencies in a clean, reproducible way.
Why npm ci instead of npm install?
The npm ci command deletes any existing node_modules and installs from the lock file exactly. This guarantees every build produces the same dependency tree. In contrast, npm install can resolve slightly different versions between builds.
Add the production stage with security hardening
The production stage starts from node:24-alpine (~55MB), a stripped-down Linux image that contains only the Node.js runtime. By default, Docker containers run as root. Running as root inside a container means an attacker who escapes the app process has full filesystem access.
- Add the production stage below the build stage in api/Dockerfile:
# Stage 2: Production image
FROM node:24-alpine AS production
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=build --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --chown=appuser:appgroup . .
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "server.js"]
What does this code do?
This production stage creates a secure, minimal image. Here is what each section accomplishes:
- RUN addgroup -S appgroup && adduser -S appuser -G appgroup creates a system user with no password and no home directory. The container runs as this user instead of root.
- COPY --from=build pulls node_modules from the build stage. The --chown=appuser:appgroup flag sets file ownership so the non-root user can read them.
- USER appuser switches all subsequent commands (and the final CMD) to run as the non-root user.
- HEALTHCHECK tells Docker how to test if the container is working. It uses wget (included in Alpine by default) to hit the /health endpoint every 30 seconds.
- Save api/Dockerfile.
Seeing a syntax warning in VS Code?
If VS Code underlines the HEALTHCHECK line, make sure the backslash (\) at the end of that line has no trailing spaces after it. The backslash must be the very last character before the newline.
Also confirm the second line starts with two spaces before CMD.
help me fix a syntax issue in my Dockerfile HEALTHCHECK line
✔️ Awesome, I've got everything!
Great. Double check you have saved api/Dockerfile before moving on.
ⓧ I'd like to double check the full code
# Stage 1: Install dependencies
FROM node:24 AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --only=production
# Stage 2: Production image
FROM node:24-alpine AS production
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=build --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --chown=appuser:appgroup . .
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "server.js"]
Create a .dockerignore and build the image
When Docker builds an image, it sends every file in the build context to the Docker daemon. A .dockerignore file tells Docker which files to skip. This prevents large or sensitive files from inflating your image.
- In VS Code, right-click the api folder and select New File.
- Name it .dockerignore (note the leading dot).
- Add these exclusion rules:
node_modules
npm-debug.log
.git
.gitignore
.env
README.md
What does this file do?
Each line is a pattern that Docker will skip when copying files into the build context.
- node_modules is excluded because the Dockerfile installs fresh dependencies from the lock file. Sending an existing node_modules would waste time and could introduce platform mismatches.
- .env is excluded so secrets never get baked into the image layer.
- Save api/.dockerignore.
Now build the production image and see how small it is compared to a full single-stage build.
- Open your terminal in VS Code by pressing Ctrl+` (backtick key).
- Build the multi-stage production image by running this command:
docker build -t task-api ./api
- Now build just the build stage to see how large the full image would be without multi-stage optimization:
docker build --target build -t task-api-build ./api
- Compare the two image sizes by running:
docker images task-api*
You should see two images listed. The task-api image (production, Alpine-based) should be roughly 150-200MB. The task-api-build image (full Debian-based) should be around 400MB or more.
Why is the production image so much smaller?
The production stage uses node:24-alpine as its base (~55MB compressed) instead of the full node:24 image (~390MB). It also skips build tools like gcc and make that are only needed during npm ci. Smaller images deploy faster and have a smaller attack surface.
Build failed with an error?
Check that your Dockerfile is saved inside the api folder (not the project root). Docker looks for the Dockerfile in the build context directory you specified (./api).
Also confirm Docker Desktop is running. You can verify by checking for the Docker whale icon in your system tray.
help me debug a Docker build failure for my multi-stage Dockerfile
Your production image is lean, secure, and ready to run. Next up, you will orchestrate this image alongside PostgreSQL, Redis, and Nginx using Docker Compose.
Orchestrate Services with Docker Compose and Health Checks
You have a production-ready Docker image for the API, but a real application needs more than one container. Your task manager needs a database, a cache, and a reverse proxy all working together.
Docker Compose lets you define and run multi-container applications from a single YAML file. In this step, you will orchestrate all four services and wire them together with health checks that guarantee each service is truly ready before its dependents start.
In this step, get ready to:
- Write a compose.yaml defining all four services with pinned image versions, a custom network, and a named volume.
- Configure health checks and startup ordering so services wait for their dependencies.
- Bring up the full stack and verify every service reports healthy.
Define the infrastructure services
Every multi-service stack has a foundation layer. Your API depends on PostgreSQL for persistent storage and Redis for caching. These two services must be running and healthy before the API starts.
- In VS Code, create a new file called compose.yaml in the root of your docker-production project folder (the same level as the api/ and nginx/ folders).
- Paste the following into compose.yaml to define the database and cache services:
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${DB_NAME:-taskdb}
POSTGRES_USER: ${DB_USER:-taskuser}
POSTGRES_PASSWORD: ${DB_PASSWORD:-taskpass}
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- app-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-taskuser} -d ${DB_NAME:-taskdb}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
redis:
image: redis:7-alpine
networks:
- app-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
start_period: 5s
What does this code do?
The db service uses postgres:16-alpine (a minimal PostgreSQL image) with environment variables that create the database and user on first start. The postgres-data volume ensures data survives container restarts.
The redis service uses redis:7-alpine for an in-memory cache. It needs no persistent volume because cached data is intentionally ephemeral.
Each service has a healthcheck block that tells Docker how to verify the service is ready. PostgreSQL uses pg_isready (a built-in utility) and Redis uses redis-cli ping.
Add the application services with startup ordering
The API and Nginx layers form the application tier. They depend on the infrastructure services being fully healthy before they attempt to connect.
- Add the following below the redis service block in compose.yaml:
api:
build:
context: ./api
target: production
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-taskdb}
DB_USER: ${DB_USER:-taskuser}
DB_PASSWORD: ${DB_PASSWORD:-taskpass}
REDIS_HOST: redis
REDIS_PORT: 6379
PORT: 3000
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
networks:
- app-network
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
interval: 30s
timeout: 3s
retries: 3
start_period: 10s
nginx:
image: nginx:1.30-alpine
ports:
- "8080:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
api:
condition: service_healthy
networks:
- app-network
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost/nginx-health"]
interval: 30s
timeout: 3s
retries: 3
start_period: 5s
What does depends_on with service_healthy do?
The depends_on block with condition: service_healthy creates a strict startup chain. Docker Compose will not start the api container until both db and redis pass their health checks.
Similarly, nginx waits for api to become healthy. The full startup order is: db and redis (in parallel) → api → nginx.
The api service uses build: context: ./api to build from your multi-stage Dockerfile, targeting the production stage. Nginx mounts your nginx.conf as read-only (:ro) and exposes port 8080 on your machine.
- Finally, add the volume and network declarations at the bottom of compose.yaml:
volumes:
postgres-data:
networks:
app-network:
driver: bridge
What are named volumes and custom networks?
The postgres-data named volume stores database files outside the container's filesystem. Even if you destroy and recreate containers, your data persists.
The app-network custom bridge network isolates your services from other Docker containers on the machine. Services on the same network can reach each other by service name (e.g., the API connects to db and redis using those exact names as hostnames).
- Save compose.yaml.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before continuing.
ⓧ I'd like to double check the full code
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${DB_NAME:-taskdb}
POSTGRES_USER: ${DB_USER:-taskuser}
POSTGRES_PASSWORD: ${DB_PASSWORD:-taskpass}
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- app-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-taskuser} -d ${DB_NAME:-taskdb}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
redis:
image: redis:7-alpine
networks:
- app-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
start_period: 5s
api:
build:
context: ./api
target: production
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-taskdb}
DB_USER: ${DB_USER:-taskuser}
DB_PASSWORD: ${DB_PASSWORD:-taskpass}
REDIS_HOST: redis
REDIS_PORT: 6379
PORT: 3000
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
networks:
- app-network
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
interval: 30s
timeout: 3s
retries: 3
start_period: 10s
nginx:
image: nginx:1.30-alpine
ports:
- "8080:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
api:
condition: service_healthy
networks:
- app-network
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost/nginx-health"]
interval: 30s
timeout: 3s
retries: 3
start_period: 5s
volumes:
postgres-data:
networks:
app-network:
driver: bridge
Create the environment file and bring up the stack
The compose.yaml references variables like ${DB_NAME:-taskdb}. Docker Compose reads these from a .env file in the same directory. The :- syntax provides defaults, but an explicit file makes credentials easy to manage and keeps them out of version control.
- Create a new file called .env in the root of your project folder (same level as compose.yaml) and paste the following:
DB_NAME=taskdb
DB_USER=taskuser
DB_PASSWORD=taskpass
What does this file do?
Docker Compose automatically loads .env files from the project directory. Every ${VARIABLE} reference in compose.yaml gets replaced with the corresponding value from this file.
In a real team, you would add .env to your .gitignore so credentials never reach version control.
- Save .env.
- Bring up the entire stack in detached mode by running this command in your terminal (make sure you are in the project root directory where compose.yaml lives):
docker compose up -d
Docker Compose will build your API image from the Dockerfile, pull the PostgreSQL, Redis, and Nginx images, create the network and volume, then start all containers in dependency order. This may take a minute or two on first run.
- Once the command finishes, verify all services are healthy by running:
docker compose ps
You should see all four services listed with (healthy) in the STATUS column. It may take 30-60 seconds after startup for all health checks to pass.
Health checks still showing as starting?
Wait 30-60 seconds and run docker compose ps again. Each service has a start_period during which failed checks don't count against the retry limit.
- Confirm the API responds through Nginx by opening your browser and navigating to http://localhost:8080/health.
You should see a JSON response with { "status": "healthy", "hostname": "..." } confirming that Nginx is proxying to your API, which is connected to both PostgreSQL and Redis.
Seeing connection refused or a blank page?
- Run docker compose logs api to check if the API container had startup errors (typos in environment variables are a common cause).
- Make sure port 8080 is not already in use by another application on your machine.
- Verify your .env file is in the same directory as compose.yaml and has no extra spaces around the = signs.
Help me debug why my Docker Compose services aren't starting healthy.
Your four-service stack is running with health-check-driven startup ordering. Next up, you will create a production overlay that adds resource limits, restart policies, and security hardening on top of this base configuration.
Create a Production Overlay
In the last step, you orchestrated a 4-service stack with health checks ensuring services start only when their dependencies are truly ready. The stack works perfectly for development.
Production environments need additional guardrails. A single service consuming unlimited memory can crash the entire host. Docker Compose solves this with an overlay pattern: a second file that layers production-specific settings on top of your base configuration without duplicating it.
In this step, get ready to:
- Write a production overlay with resource limits, restart policies, and log rotation.
- Harden the Nginx container with a read-only filesystem.
- Test the full production configuration and verify constraints are applied.
Write the production overlay for the data and API services
Docker Compose overlay files only need to define the settings you want to add or override. You don't repeat the full service definition. Docker merges the overlay on top of the base file at runtime.
- In VS Code, create a new file called compose.production.yaml in your project root (the same folder where compose.yaml lives).
- Add the production overrides for the db, redis, and api services by pasting the following code:
services:
db:
restart: always
deploy:
resources:
limits:
memory: 512M
cpus: "0.5"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
redis:
restart: always
deploy:
resources:
limits:
memory: 256M
cpus: "0.25"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
api:
restart: always
deploy:
resources:
limits:
memory: 512M
cpus: "0.5"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
What does this code do?
Each service gets three production-grade settings that the base compose.yaml intentionally left out:
- restart: always tells Docker to automatically restart the container if it crashes or if the host reboots.
- deploy.resources.limits caps the maximum memory and CPU each service can consume. The database and API get 512MB and half a CPU core. Redis gets 256MB and a quarter core.
- logging configures JSON-file log rotation. Each service keeps at most 3 log files of 10MB each, preventing logs from filling the disk over time.
Why overlays instead of profiles?
Docker Compose also supports profiles for toggling services. Overlays are the better fit here because they map directly to how teams manage environments in CI/CD pipelines.
The base file stays clean for local development. The production overlay adds constraints without touching the original. This pattern scales to staging, QA, and other environments by adding more overlay files.
- Save compose.production.yaml.
You won't see a change yet. The overlay file is just a configuration sitting on disk until you explicitly pass it to Docker Compose.
Add Nginx hardening to the production overlay
The Nginx container serves static responses and proxies traffic. It has no reason to write to its own filesystem. Making the root filesystem read-only prevents attackers from modifying files inside the container, even if they gain access.
- In compose.production.yaml, add the following nginx service block below the api service (after the last max-file: "3" line):
nginx:
restart: always
read_only: true
tmpfs:
- /var/cache/nginx
- /var/run
deploy:
resources:
limits:
memory: 128M
cpus: "0.25"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
What does this code do?
The nginx block gets the same restart, limits, and logging as the other services, plus two extra hardening settings:
- read_only: true mounts the container's root filesystem as read-only. No process inside the container can create or modify files.
- tmpfs mounts /var/cache/nginx and /var/run as in-memory writable directories. Nginx needs these two paths to store its cache and PID file at runtime.
- Save compose.production.yaml.
Confirm the file exists by running:
- Check that your file was saved by running:
ls compose.production.yaml
You should see compose.production.yaml listed in the output.
Don't see the file?
- Make sure you created it in the project root, not inside the api/ or nginx/ folder.
- Check for typos in the filename. It must be compose.production.yaml (not docker-compose.production.yaml).
Help me find my compose.production.yaml file
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
services:
db:
restart: always
deploy:
resources:
limits:
memory: 512M
cpus: "0.5"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
redis:
restart: always
deploy:
resources:
limits:
memory: 256M
cpus: "0.25"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
api:
restart: always
deploy:
resources:
limits:
memory: 512M
cpus: "0.5"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
nginx:
restart: always
read_only: true
tmpfs:
- /var/cache/nginx
- /var/run
deploy:
resources:
limits:
memory: 128M
cpus: "0.25"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
Test the production configuration
The overlay file is ready. Now you need to tell Docker Compose to merge both files together when starting the stack.
- First, stop the currently running development stack by running:
docker compose down
- Start the stack with both the base and production overlay by running:
docker compose -f compose.yaml -f compose.production.yaml up -d
What does this command do?
The -f flag specifies which Compose files to load. Docker reads them left to right and merges them together. Settings in compose.production.yaml override or extend matching keys in compose.yaml.
The -d flag runs everything in detached mode so your terminal stays free.
- Wait about 30 seconds for health checks to pass, then verify all services are healthy by running:
docker compose -f compose.yaml -f compose.production.yaml ps
You should see all 4 services listed with a status of healthy.
- Verify that resource limits are applied by running:
docker compose -f compose.yaml -f compose.production.yaml stats --no-stream
What should I see?
The --no-stream flag gives you a single snapshot instead of a live-updating view. You should see a table with columns for each container's CPU usage, memory usage, and memory limit.
Look at the MEM USAGE / LIMIT column. The database and API containers should show a limit of 512M. Redis should show 256M. Nginx should show 128M.
- Compare the resolved dev and prod configurations by running:
docker compose config
This shows your base development configuration. Now compare it to the merged production version:
- View the merged production configuration by running:
docker compose -f compose.yaml -f compose.production.yaml config
You should see the production output includes all the resource limits, restart policies, and logging configuration merged into each service definition.
Services not reaching healthy status?
- If a service shows as unhealthy, check its logs with docker compose -f compose.yaml -f compose.production.yaml logs <service-name>.
- Make sure your YAML indentation is correct. YAML is sensitive to spaces. Every nested level uses exactly 2 spaces.
- If you see a memory-related error, the container may be hitting its new resource limit during startup. Wait a moment and try again.
Help me debug why my production overlay services are unhealthy
Your production overlay is complete. You now have a clean separation between development and production configurations. Next up, you'll push this architecture further by scaling the API horizontally and watching Nginx load-balance traffic across multiple replicas.
Secret mission
Horizontal Scaling with Load Balancing
In production, a single API container eventually hits its throughput ceiling. Can you scale the API service to multiple replicas and prove that Nginx load-balances incoming traffic across all of them?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local machine using Docker Desktop, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Docker images (locally built API image, pulled base images for PostgreSQL, Redis, and Nginx).
- Docker volume (postgres-data) storing database files on your local disk.
- Docker network (app-network) connecting your services together.
Keep everything running
No action needed. Choose this if you're still actively experimenting with your stack or want to send more requests to test the API.
- Your containers continue running and consume RAM and CPU.
- Monitor resource usage at any time by running:
docker compose stats
- Docker Desktop stays in your system tray and manages all running containers.
Pause - I'll come back to this later
Shut down running containers to free up memory and CPU, but keep everything in place so you can resume quickly.
- Stop all containers without removing them by running:
docker compose stop
- Your containers, volumes, networks, and images remain on disk. No data is lost.
- When you're ready to resume, start everything back up by running:
docker compose start
- All services return to their previous state with your database data intact.
Delete - I don't want to use this again
Remove all project containers, volumes, networks, and locally-built images. This frees up the most disk space but means starting fresh if you return.
- Remove all Docker resources for this project by running:
docker compose down -v --rmi local
What does this command do?
- down stops and removes all containers and the app-network network.
- -v removes the postgres-data named volume (your database data is permanently deleted).
- --rmi local removes images that were built locally (your API image) but leaves pulled base images like postgres:16-alpine and redis:7-alpine on your machine.
- If you also want to remove the pulled base images, run:
docker image rm postgres:16-alpine redis:7-alpine nginx:1.30-alpine node:24 node:24-alpine
- Finally, delete the project folder from your Desktop. On macOS or Linux, run:
cd ~/Desktop
rm -rf docker-production-app
- On Windows, run:
cd ~\Desktop
rmdir /s /q docker-production-app
That's a Wrap!
That's a Wrap!
Nice work. You just built a production-grade containerized application stack using the exact Docker patterns that teams rely on in real deployments.
You've learned how to:
- Build a multi-stage Dockerfile that produces a minimal, secure production image with non-root execution and proper file ownership.
- Orchestrate a 4-service stack (Node.js API + PostgreSQL + Redis + Nginx) using Docker Compose with health-check-driven startup ordering, custom networks, and named volumes.
- Create a production overlay that separates dev and prod configuration with resource limits, restart policies, and log rotation.
- Secret Mission: Scale the API to multiple replicas and verify Nginx load-balances traffic across all containers automatically.
Ready to quiz yourself?