Dockerize a Node.js API for Production
Harden a Node.js API with multi-stage Docker builds, non-root users, and Compose.
Introduction
30 Second Summary
Most apps work perfectly on a developer's laptop but break the moment they hit a production server. Docker containers solve this by packaging your app with everything it needs to run identically anywhere.
In this project, you will take a Node.js API through the full Docker production-hardening journey. You start with a deliberately bad Dockerfile that has every production anti-pattern in the book, then fix it piece by piece, watching the image shrink from ~1GB to ~150MB while gaining security hardening and multi-service orchestration.
What You'll Build
You can run docker compose up and hit http://localhost/health through an Nginx reverse proxy, seeing a response that confirms your API, database, and cache are all connected and running.
By the end of this project, you'll have:
- A production Docker image that is 80%+ smaller than the naive version, runs as a non-root user, and includes a health check. You can compare sizes side-by-side and verify the security improvements yourself.
- A running multi-service Docker Compose stack (API + PostgreSQL + Redis + Nginx) with health-check dependencies, persistent volumes, and networking. One command brings the entire stack up or tears it down.
- A clear mental model of production architecture. You will know which services belong in containers and which should be managed cloud services when you deploy for real.
- Secret Mission: Scan your production image for vulnerabilities with Docker Scout and add resource limits to prevent container resource exhaustion.
Are there any prerequisites?
This project assumes basic Docker knowledge (you have run docker build and docker run before) and familiarity with JavaScript and Node.js.
You will need Docker Desktop, Cursor, and Node.js installed on your machine. The first step walks you through setup.
Before We Start
Before We Start...
Before you touch any code, let's lock in what you're about to build and why it matters.
A Docker image that "works" in development can be bloated, insecure, and slow to deploy in production. This project walks you through the difference and gives you a reusable workflow for hardening any app.
Set Up the Express API
To containerize an application for production, you first need an application worth containerizing. A bare "hello world" won't surface the real Docker challenges you face in production.
In this step, you will build a small Express API with a health endpoint and connections to PostgreSQL and Redis. This gives you the multi-service shape that 90% of real production apps use.
In this step, get ready to:
- Create a project directory and install dependencies.
- Write an Express server with health, items, and cache endpoints.
- Verify the server starts and responds on /health.
Create the project and install dependencies
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir docker-production-api
cd docker-production-api
- Create a file called package.json in your docker-production-api folder by running:
touch package.json
- Open your project in Cursor. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Cursor and press Enter to open it.
- In Cursor, click File in the top menu bar.
- Click Open Folder.
- Select the docker-production-api folder on your Desktop, then click Open.
- In the Explorer sidebar, click package.json to open it.
- Replace any existing content with this code:
{
"name": "docker-production-api",
"version": "1.0.0",
"description": "Production-ready Docker example",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "5.2.1",
"pg": "8.22.0",
"ioredis": "5.11.1"
}
}
What does this file do?
The package.json file defines your project's metadata and dependencies. express is the web framework, pg is the PostgreSQL client, and ioredis is the Redis client.
Pinning exact versions (like 5.2.1 instead of ^5.2.1) ensures everyone building your Docker image gets identical dependencies.
- Save package.json with Cmd+S (macOS) or Ctrl+S (Windows).
- Install the dependencies by running this command in your terminal:
npm install
- You should see output listing the three packages being installed, plus a node_modules folder appearing in the Explorer sidebar.
Seeing permission errors or command not found?
- Make sure your terminal is inside the docker-production-api folder. Run pwd to check your current directory.
- Confirm Node.js is installed by running node --version in your terminal.
Still stuck? Help me troubleshoot npm install failing in my Docker production API project.
Build the Express server
Your server needs three layers: connection configuration (how to reach Postgres and Redis), a health endpoint (so Docker can check if the container is alive), and data endpoints (so the app does real work).
- Create a new file called server.js in your docker-production-api folder. In Cursor, right-click in the Explorer sidebar and select New File.
- Name it server.js and add the following code:
const express = require('express');
const { Pool } = require('pg');
const Redis = require('ioredis');
const app = express();
app.use(express.json());
const port = process.env.PORT || 3000;
// Database connection
const pool = new Pool({
host: process.env.PGHOST || 'localhost',
port: process.env.PGPORT || 5432,
database: process.env.PGDATABASE || 'myapp',
user: process.env.PGUSER || 'postgres',
password: process.env.PGPASSWORD || 'postgres',
});
// Redis connection
const redis = new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379,
});
What does this code do?
- It imports Express, the PostgreSQL client (pg), and the Redis client (ioredis).
- Every connection detail comes from environment variables with local fallback defaults. This means the same code works on your machine AND inside a Docker container, just by changing env vars.
- Save server.js.
- Confirm the file exists by running:
ls
- You should see server.js, package.json, package-lock.json, and node_modules in the output.
- Add the database initialization function below the Redis connection in server.js:
// Initialize database table
async function initDb() {
try {
await pool.query(`
CREATE TABLE IF NOT EXISTS items (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
)
`);
console.log('Database initialized');
} catch (err) {
console.error('Database init error:', err.message);
}
}
What does this function do?
The initDb function creates an items table if it does not already exist. It uses CREATE TABLE IF NOT EXISTS so it is safe to call every time the server starts without duplicating the table.
- Add the health endpoint and server startup below initDb:
// Health endpoint
app.get('/health', async (req, res) => {
const health = { status: 'ok', timestamp: new Date().toISOString() };
try {
await pool.query('SELECT 1');
health.database = 'connected';
} catch (err) {
health.database = 'disconnected';
}
try {
await redis.ping();
health.cache = 'connected';
} catch (err) {
health.cache = 'disconnected';
}
res.json(health);
});
app.listen(port, '0.0.0.0', () => {
console.log(`Server running on port ${port}`);
initDb();
});
What does this code do?
- The /health endpoint pings both Postgres and Redis and reports their status. Docker will use this endpoint later to know if the container is alive.
- The app.listen call starts the server on port 3000 and calls initDb() to set up the database table.
- Save server.js and test that it starts by running:
node server.js
You will see connection error messages for Postgres and Redis because those services are not running locally yet. That is expected. The server itself still starts and listens on port 3000.
- Open a second terminal tab (Cmd+T in macOS Terminal, or click + in the Cursor terminal panel) and run:
curl http://localhost:3000/health
- You should see a JSON response containing "status":"ok" along with "database":"disconnected" and "cache":"disconnected".
Seeing ECONNREFUSED or no response?
- Make sure the first terminal is still running node server.js (you should see "Server running on port 3000" in that tab).
- If port 3000 is already in use, stop the other process or set a different port with PORT=3001 node server.js.
Need more help? Help me debug why curl to localhost:3000/health is failing.
- Go back to the terminal running the server and press Ctrl+C to stop it.
The core server works. Now you will add the data endpoints that read and write to Postgres and Redis.
- In server.js, find the app.listen block at the bottom of the file. Add the following code above the app.listen line:
// Items CRUD
app.get('/items', async (req, res) => {
try {
const result = await pool.query('SELECT * FROM items ORDER BY created_at DESC');
res.json(result.rows);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post('/items', async (req, res) => {
const { name } = req.body;
if (!name) return res.status(400).json({ error: 'name is required' });
try {
const result = await pool.query(
'INSERT INTO items (name) VALUES ($1) RETURNING *',
[name]
);
await redis.del('items:cache');
res.status(201).json(result.rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
What does this code do?
- GET /items fetches all items from Postgres, sorted by newest first.
- POST /items inserts a new item and clears the Redis cache so stale data is never served.
- Add the cache test endpoint below the items routes, still above the app.listen line:
// Cache test endpoint
app.get('/cache-test', async (req, res) => {
try {
const cached = await redis.get('items:cache');
if (cached) {
return res.json({ source: 'cache', data: JSON.parse(cached) });
}
const result = await pool.query('SELECT * FROM items ORDER BY created_at DESC');
await redis.set('items:cache', JSON.stringify(result.rows), 'EX', 60);
res.json({ source: 'database', data: result.rows });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
What does this code do?
The /cache-test endpoint demonstrates a cache-aside pattern. It checks Redis first. If the data is cached, it returns immediately. If not, it reads from Postgres, stores the result in Redis with a 60-second expiry, and returns the data.
- Save server.js.
Verify the full API starts
Before you run the server again, think about what you expect to happen. Will Postgres and Redis connect successfully, or will they fail?
- Start the server by running:
node server.js
You will see connection error messages for Postgres and Redis in the terminal. That is intentional. Those services are not running locally. Docker Compose will provide them in a later step.
- In your second terminal tab, run:
curl http://localhost:3000/health
- You should see a response like:
{"status":"ok","timestamp":"...","database":"disconnected","cache":"disconnected"}
The "status":"ok" confirms your Express server is running and the health endpoint responds. The disconnected statuses confirm the app gracefully handles missing services instead of crashing.
- Press Ctrl+C in the first terminal to stop the server.
Server crashes instead of reporting disconnected?
- Make sure every await pool.query and await redis.ping() call is wrapped in a try/catch block. Without try/catch, an unhandled promise rejection will crash the process.
- Check that the ioredis import uses a capital R: const Redis = require('ioredis').
Need help? Help me figure out why my Express server crashes on startup.
✔️ Awesome, I've got everything!
Great. Double check you have saved server.js and package.json in your docker-production-api folder.
ⓧ I'd like to double check the full code
Here is the complete package.json:
{
"name": "docker-production-api",
"version": "1.0.0",
"description": "Production-ready Docker example",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "5.2.1",
"pg": "8.22.0",
"ioredis": "5.11.1"
}
}
For server.js, confirm these sections are present in this order:
- Imports and connection configuration (express, pg, ioredis with env var defaults).
- The initDb function that creates the items table.
- The /health endpoint that pings Postgres and Redis.
- The /items GET and POST routes.
- The /cache-test endpoint with the cache-aside pattern.
- The app.listen call at the very bottom.
Your API is ready to be containerized. Next up, you will write a Dockerfile and see firsthand what happens when you take the naive approach to production Docker.
Build the Naive Dockerfile
Your Express API starts up and responds to requests locally. The next challenge is packaging it into a Docker container so it can run anywhere, on any machine, with a single command.
In this step, you'll write the most straightforward Dockerfile possible. It will build successfully and your app will run inside it. But "works" and "production-ready" are two very different things.
In this step, get ready to:
- Write a naive Dockerfile and build the image.
- Inspect the image size and running user.
- Identify production anti-patterns that make this image unsafe to ship.
Write and build the naive Dockerfile
The fastest way to containerize a Node.js app is to copy everything in, install dependencies, and run it. This approach gets your container working in seconds.
- Create a Dockerfile in your docker-production-api folder by running this command:
touch Dockerfile
- Confirm it was created by running:
ls
You should see Dockerfile listed alongside server.js and package.json.
Don't see Dockerfile?
Make sure you're in the docker-production-api folder. Run pwd to check your current directory.
Help me figure out why my Dockerfile wasn't created
- Open Dockerfile in Cursor and add the following code:
FROM node:22
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "server.js"]
What does this Dockerfile do?
- FROM node:22 pulls the full Node.js 22 base image. This is Debian-based and includes build tools, Python, and thousands of system packages.
- WORKDIR /app sets the working directory inside the container.
- COPY . . copies everything from your project folder into the image.
- RUN npm install installs dependencies inside the image.
- EXPOSE 3000 documents which port the app listens on.
- CMD ["node", "server.js"] tells Docker how to start the app when the container runs.
- Save Dockerfile.
- Build your Docker image by running this command:
docker build -t myapi:naive .
You'll see Docker downloading base image layers and installing your dependencies. This takes a minute or two the first time.
Build failed?
Make sure Docker Desktop is running. You should see the Docker whale icon in your menu bar.
If you see a "no such file or directory" error, confirm you're in the folder that contains your Dockerfile. The . at the end of the build command refers to the current directory.
Help me fix my Docker build error
- If your local Node.js server is still running from the previous step, stop it by pressing Ctrl+C in that terminal.
- Start a container from your new image by running:
docker run -d --name naive-test -p 3000:3000 myapi:naive
- Verify the container is running by visiting http://localhost:3000/health in your browser.
You should see a JSON response with "status":"ok". The database and cache fields show "disconnected" since those services aren't running in Docker yet.
Container not responding on port 3000?
If you get a connection refused error, check that port 3000 isn't still in use by your local Node.js process. Stop any process on that port and retry.
Run docker ps to confirm your container is listed and shows "Up" in the STATUS column.
Help me troubleshoot this port mapping issue
✔️ Awesome, I've got everything!
Great. Double check you've saved your Dockerfile and that your container is running.
ⓧ I'd like to double check the full code
Here's what your Dockerfile should look like:
FROM node:22
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "server.js"]
Inspect the damage
The container works. But let's look at what we actually shipped.
Before you run the next command, how large do you think this image is? A typical Node.js source file is a few kilobytes. How much space could a container possibly need?
- Check the image size by running:
docker images myapi:naive
Look at the SIZE column. The image is approximately 1GB. That's an entire operating system worth of packages, compilers, and tools your API will never use at runtime.
- Now check which user your container is running as:
docker exec naive-test whoami
The output is root. Your application has full superuser privileges inside the container.
docker exec returned an error?
If you see "No such container", the container may have stopped or wasn't named correctly. Run docker ps -a to list all containers and check the name matches naive-test.
If the container exited, restart it with docker start naive-test and try the exec command again.
Help me fix my docker exec error
Understand why this is dangerous in production
You now have concrete evidence of three problems. Let's understand why each one is a production red flag.
Why are these production red flags?
The ~1GB image size means slow deployments. Every time you push a new version, your CI/CD pipeline uploads a gigabyte. It also means a larger attack surface. More installed packages means more potential vulnerabilities for attackers to exploit.
Running as root means that if an attacker finds a way to execute code inside your container, they have superuser privileges. If they then escape the container (a real class of vulnerability), they land on the host as root.
Without a .dockerignore file, COPY . . sends everything into the image. That includes .env files with secrets, .git history, and the local node_modules folder (which gets installed again anyway, wasting build time and space).
To sum up: this image is bloated, insecure, and leaky. All three problems are fixable. That's exactly what the next step addresses.
Your naive image runs, but it's ~1GB, executes as root, and has no file boundaries. In the next step, you'll rebuild it from scratch with multi-stage builds, a non-root user, and a health check. The result will be 80% smaller and locked down for production.
Write the Production Dockerfile
In the last step, you saw the damage: a 1GB image running as root with no health monitoring. Those are three production red flags that make your container slow to deploy, vulnerable to attack, and invisible to orchestrators.
Now you'll fix all three by writing a production Dockerfile that uses multi-stage builds to slash image size, a non-root user for security hardening, and a HEALTHCHECK instruction so Docker knows when your app is alive.
In this step, get ready to:
- Create a .dockerignore to keep unnecessary files out of the build context.
- Write a multi-stage production Dockerfile with security hardening.
- Build the production image and verify the size reduction, non-root user, and health check.
Create a .dockerignore file
Before building, you need to tell Docker which files to ignore. A .dockerignore file works like .gitignore but for Docker builds.
- In Cursor, create a new file called .dockerignore in your docker-production-api folder and paste the following:
node_modules
npm-debug.log
.git
.gitignore
.env
.env.*
Dockerfile*
docker-compose*
compose*
*.md
.DS_Store
coverage
tests
What does this file do?
Every entry tells Docker to skip that file or pattern when copying your project into the image.
- node_modules is excluded because the Dockerfile installs fresh dependencies inside the image. Copying your local node_modules would add hundreds of megabytes for no reason.
- .git and .env are excluded because version history and secrets have no business inside a production image.
- Dockerfile* and compose* are excluded because build configuration files are not application code.
- Save .dockerignore.
- Confirm the file exists by running:
ls .dockerignore
You should see .dockerignore printed in the output.
Replace the Dockerfile with a multi-stage production build
A multi-stage build uses two FROM statements in one Dockerfile. The first stage installs dependencies, and the second stage copies only what your app needs to run.
- Open your existing Dockerfile in Cursor and delete all its contents.
- Paste Stage 1 (the builder stage) into the empty file:
# Stage 1: Install dependencies
FROM node:22-alpine3.24 AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
What does Stage 1 do?
- node:22-alpine3.24 is an Alpine Linux base image that is much smaller than the full node:22 image you used before.
- AS builder names this stage so Stage 2 can reference it.
- npm ci --only=production installs dependencies from the lockfile, skipping devDependencies. This keeps the output deterministic and lean.
- Now add Stage 2 directly below Stage 1 in the same file:
# Stage 2: Production image
FROM node:22-alpine3.24
WORKDIR /app
# Create non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# Copy only production dependencies from builder
COPY --from=builder /app/node_modules ./node_modules
# Copy application code
COPY server.js ./
COPY package.json ./
# Switch to non-root user
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "server.js"]
What does Stage 2 do?
- A fresh FROM starts a brand new image. Nothing from Stage 1 carries over unless you explicitly copy it.
- addgroup and adduser create a dedicated non-root user called appuser. If an attacker breaks into the container, they cannot escalate to root.
- COPY --from=builder grabs only the node_modules folder from Stage 1. All the build tools and cache from the install step stay behind.
- USER appuser switches the runtime user. Every command after this line, including CMD, runs as appuser instead of root.
- HEALTHCHECK tells Docker to ping /health every 30 seconds. If it fails 3 times in a row, Docker marks the container as unhealthy.
- Save your Dockerfile.
✔️ Awesome, I've got everything!
Great. Double check you've saved your Dockerfile with both stages present.
ⓧ I'd like to double check the full code
Your Dockerfile should contain Stage 1 (builder) at the top starting with FROM node:22-alpine3.24 AS builder, followed by Stage 2 starting with a second FROM node:22-alpine3.24. Scroll up and confirm both chunks are present, in order.
Build and verify the production image
Time to see if the production build delivers on its promises.
- First, stop and remove any container still running from the naive image by running:
docker rm -f $(docker ps -aq)
- Build the production image by running:
docker build -t myapi:prod .
You'll see Docker execute both stages. Stage 1 installs dependencies, then Stage 2 assembles the final image.
- Before you check the size, predict: how much smaller do you think the Alpine-based multi-stage image is compared to the naive 1GB image?
- Compare both images by running:
docker images myapi
You should see two rows: myapi:naive at roughly 1GB, and myapi:prod at roughly 150MB. That is an 80%+ size reduction.
Where did all that size go?
The full node:22 image includes a complete Debian operating system with compilers, man pages, and tools your app never uses. Alpine Linux strips all of that down to a minimal base. The multi-stage build then ensures only your production node_modules make it into the final layer.
- Run the production container by running:
docker run -d --name prod-api myapi:prod
- Verify the container is running as a non-root user by running:
docker exec prod-api whoami
You should see appuser printed. Compare that to the root you saw in the last step.
- Wait about 30 seconds for the first health check to run, then verify the HEALTHCHECK configuration by running:
docker inspect --format='{{json .State.Health}}' prod-api
You should see a JSON object with "Status":"healthy" confirming Docker is actively monitoring your container.
Seeing "starting" instead of "healthy"?
The HEALTHCHECK has a --start-period=10s grace period plus a 30-second interval before the first check runs. Wait 40 seconds and run the inspect command again.
If you see "Status":"unhealthy", the health endpoint is not responding. Run docker logs prod-api to check for startup errors.
Help me debug my Docker health check
Understand Docker layer caching
There is one more optimization baked into your Dockerfile that saves time on every future build.
Look at the order of COPY instructions in Stage 1:
COPY package.json package-lock.json ./
RUN npm ci --only=production
Why copy package.json before source code?
Docker caches each layer. If a layer's inputs have not changed, Docker reuses the cached result instead of re-running the instruction.
By copying package.json and package-lock.json separately from your source code, Docker only re-runs npm ci when your dependencies change. Editing server.js does not trigger a full dependency reinstall.
If you had used COPY . . like the naive Dockerfile, every code change would bust the cache and force a slow npm ci on every build.
- Test this by making a small edit to server.js (add a blank line at the end, then save) and rebuilding:
docker build -t myapi:prod .
Watch the output. You should see CACHED next to the npm ci layer. Docker skipped the dependency install because package.json did not change.
Layer caching in CI/CD
This ordering trick matters even more in CI/CD pipelines where builds run from scratch. A cached dependency layer can save minutes on every deployment.
Your production image is built, verified, and optimized. Next up, you'll wire multiple services together with Docker Compose so your API can talk to a real database and cache.
Orchestrate with Docker Compose
Your production image is lean, secure, and health-checked. But a real application is never just one container.
Your API needs a PostgreSQL database for persistence, a Redis cache for performance, and an Nginx reverse proxy in front of it all. Docker Compose lets you declare all four services, their networks, volumes, and startup ordering in one file, then bring everything up with a single command.
In this step, get ready to:
- Define a four-service stack (API, PostgreSQL, Redis, Nginx) in a single Compose file.
- Wire health-check dependencies so services start in the correct order.
- Verify the full stack works end-to-end through the Nginx reverse proxy.
Define the API and database services
A Compose file declares every service your app needs. Each service gets its own container, built from either a Dockerfile or a pre-built image.
- Create a file called compose.yaml in your project folder (the same folder that contains your Dockerfile).
- Add the api service definition by pasting this code:
services:
api:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- PGHOST=postgres
- PGPORT=5432
- PGDATABASE=myapp
- PGUSER=postgres
- PGPASSWORD=postgres
- REDIS_HOST=redis
- REDIS_PORT=6379
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- app-network
What does this service definition do?
- build tells Compose to build an image from your production Dockerfile instead of pulling a pre-built one.
- environment injects connection strings so the API finds Postgres and Redis by their service names (which Compose resolves to container IPs on the shared network).
- depends_on with condition: service_healthy means Compose waits until Postgres and Redis pass their health checks before starting the API.
- Below the api service (still inside compose.yaml), add the Postgres and Redis service definitions:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: myapp
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
networks:
- app-network
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
networks:
- app-network
What do the health checks do?
Each service has a healthcheck that Compose polls every 5 seconds. Postgres uses pg_isready -U postgres to confirm it can accept connections. Redis uses redis-cli ping to confirm it responds with PONG.
The pgdata volume on the Postgres service means your database survives container restarts. Without it, every docker compose down would wipe your data.
- Save compose.yaml.
- Validate the file parses correctly by running:
docker compose config
You should see the resolved configuration printed to your terminal with the api, postgres, and redis services listed.
Seeing a YAML syntax error?
- YAML is whitespace-sensitive. Make sure each indentation level uses exactly 2 spaces (no tabs).
- Check that services: appears at the very top of the file with no leading whitespace.
- Confirm all string values with special characters are wrapped in quotes (like "3000:3000").
Still stuck? Help me fix a YAML syntax error in my compose.yaml
Add the Nginx reverse proxy
In production, traffic never hits your API container directly. A reverse proxy sits in front, handling routing, load balancing, and security headers.
- Create the Nginx configuration directory and file by running:
mkdir nginx
- Create a file called default.conf inside the nginx folder you just created.
- Paste this configuration:
upstream api {
server api:3000;
}
server {
listen 80;
server_name _;
location / {
proxy_pass http://api;
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 /health {
proxy_pass http://api/health;
access_log off;
}
}
What does this Nginx config do?
The upstream block defines where traffic goes. server api:3000 uses Docker's internal DNS to resolve the api service name to its container IP.
The proxy_set_header lines forward the client's real IP and protocol to your API. The /health location disables access logging for health checks so your logs stay clean.
- Save nginx/default.conf.
- Now return to compose.yaml and add the Nginx service, volume declaration, and network definition below the redis service:
nginx:
image: nginx:1.27-alpine
ports:
- "80:80"
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
api:
condition: service_healthy
networks:
- app-network
volumes:
pgdata:
networks:
app-network:
driver: bridge
What does this add?
- The Nginx service mounts your local nginx/default.conf into the container as read-only (:ro). It only starts after the API is healthy.
- volumes: pgdata: declares the named volume so Docker manages the Postgres data directory.
- networks: app-network: creates a bridge network that isolates your services from the host and lets them communicate by service name.
- Save compose.yaml.
- Verify the complete configuration by running:
docker compose config
You should see all four services (api, nginx, postgres, redis) listed with their full resolved configuration.
Seeing a service reference error?
- If you see an error about an undefined service, check that depends_on references match the exact service names (postgres, redis, api).
- If you see a volumes error, make sure volumes: and networks: are at the top level (no indentation), not nested inside a service.
Need more help? Help me debug my compose.yaml configuration
Launch and test the full stack
Everything is wired. Time to bring up all four services and see them work together.
- Start the full stack by running:
docker compose up
What happens when you run this?
Compose pulls the Postgres, Redis, and Nginx images (first run only), builds your API image from the Dockerfile, creates the network and volume, then starts services in dependency order: Postgres and Redis first, then the API (once they are healthy), then Nginx (once the API is healthy).
You will see logs from all four services interleaved in your terminal. Wait until you see Server running on port 3000 and Database initialized from the api service.
Before you test the health endpoint, what do you expect the response to include now that Postgres and Redis are running?
- Open a new terminal tab (the compose logs are occupying the first one).
- Test the stack through the Nginx proxy by running:
curl http://localhost/health
You should see a response like this:
{"status":"ok","timestamp":"...","database":"connected","cache":"connected"}
Both database and cache now show connected. Your request hit Nginx on port 80, which forwarded it to the API on port 3000, which queried Postgres and pinged Redis.
Seeing 'disconnected' or connection refused?
- If database shows disconnected, the API may have started before Postgres was ready. Stop with Ctrl+C and run docker compose up again.
- If you get connection refused on port 80, Nginx may not have started yet. Wait 10-15 seconds for health checks to pass, then retry.
- If port 80 is already in use on your machine, stop the conflicting process or change the Nginx port mapping from "80:80" to "8080:80" in compose.yaml and access http://localhost:8080/health instead.
Still not working? Help me debug my Docker Compose stack
Now verify that data persists across restarts.
- Write a test item to the database by running:
curl -X POST http://localhost/items -H "Content-Type: application/json" -d '{"name":"persist-test"}'
- Confirm the item was created. You should see a JSON response with an id, name, and created_at field.
- Return to the terminal running Compose and stop the stack with Ctrl+C.
- Bring the stack back up by running:
docker compose up
- Once the services are healthy again, switch back to your second terminal tab and read back the data:
curl http://localhost/items
You should see your persist-test item in the response. The named volume kept your Postgres data intact through the restart.
Why does data survive?
The pgdata named volume stores Postgres data outside the container's filesystem. When you stop and restart, Docker reattaches the same volume. Without it, docker compose down would destroy your database every time.
✔️ Awesome, I've got everything!
Great. Make sure both compose.yaml and nginx/default.conf are saved.
ⓧ I'd like to double check the full code
Your compose.yaml should have these sections in order. Scroll up and confirm each one is present:
- services: api block (build, ports, environment, depends_on, networks).
- services: postgres block (image, environment, volumes, healthcheck, networks).
- services: redis block (image, healthcheck, networks).
- services: nginx block (image, ports, volumes, depends_on, networks).
- Top-level volumes: pgdata: declaration.
- Top-level networks: app-network: declaration with driver: bridge.
Your nginx/default.conf should contain:
upstream api {
server api:3000;
}
server {
listen 80;
server_name _;
location / {
proxy_pass http://api;
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 /health {
proxy_pass http://api/health;
access_log off;
}
}
Your four-service stack is running and resilient. Next up, you will learn which of these services actually belong in containers when you deploy to production, and which should be managed cloud services instead.
Define Production Architecture
Your 4-service stack is running, all services talking to each other through Docker networking. But right now, every service lives in a container. That is the biggest production Docker mistake.
In production, databases and caches should be managed services (like Amazon RDS or ElastiCache) that handle backups, failover, and scaling automatically. In this step, you will create a production override file that separates what belongs in a container from what belongs in a managed service.
In this step, get ready to:
- Create a production override file with resource limits and security settings.
- Document the production architecture mapping for each service.
- Verify the merged configuration works correctly.
Create the production override file
Docker Compose supports merging multiple files together using the -f flag. A base file (compose.yaml) defines the development stack, and a second file overrides specific values for production.
Why a separate override file?
You want the same service names and structure across environments, with only the settings that differ (ports, restart policies, resource limits) changing between them.
An override file keeps your base compose.yaml clean for local development while layering production hardening on top.
- Create a new file called compose.prod.yaml in your docker-production-api folder.
- Add the header comments and the api service configuration by pasting this code:
# Production overrides
# Usage: docker compose -f compose.yaml -f compose.prod.yaml up
#
# Production architecture mapping:
# - api container -> AWS ECS/Fargate (containerized)
# - nginx -> AWS ALB (managed load balancer, NOT containerized)
# - postgres -> Amazon RDS (managed database, NOT containerized)
# - redis -> Amazon ElastiCache (managed cache, NOT containerized)
#
# In production, only the api service runs as a container.
# All other services are replaced by managed cloud services.
# Connection strings are injected via environment variables at deploy time.
services:
api:
restart: unless-stopped
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
reservations:
memory: 256M
cpus: "0.5"
What does this code do?
- The header comments document how each local service maps to its production equivalent.
- restart: unless-stopped tells Docker to automatically restart the container if it crashes, unless you explicitly stop it.
- deploy.resources.limits caps the api container at 512MB of memory and 1 CPU core, preventing it from consuming all host resources.
- deploy.resources.reservations guarantees the container gets at least 256MB and 0.5 CPU, so other containers cannot starve it.
- Now add the remaining services below the api block in the same file:
postgres:
# In production: replaced by Amazon RDS
# This config is for staging/testing environments only
ports: []
restart: unless-stopped
redis:
# In production: replaced by Amazon ElastiCache
# This config is for staging/testing environments only
ports: []
restart: unless-stopped
nginx:
# In production: replaced by AWS ALB
restart: unless-stopped
What does ports: [] do?
Setting ports: [] removes any port mappings defined in the base compose.yaml. In production, Postgres and Redis should never be reachable from outside the Docker network.
Only the reverse proxy (Nginx, or in production, an Application Load Balancer) should accept external traffic.
- Save compose.prod.yaml.
- Confirm the file exists by running:
ls compose.prod.yaml
You should see compose.prod.yaml listed in the output.
Don't see the file?
- Make sure you created the file inside your docker-production-api folder, not in a subfolder.
- Check that the filename is exactly compose.prod.yaml with no extra extensions.
Why isn't my compose.prod.yaml showing up?
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before continuing.
ⓧ I'd like to double check the full code
Scroll up and confirm you have both chunks in order:
- The header comments and api service block (with deploy.resources.limits and reservations).
- The postgres, redis, and nginx service blocks (with ports: [] and restart: unless-stopped).
Together they form the complete compose.prod.yaml file.
Understand the production architecture mapping
The comments in compose.prod.yaml document a critical production decision. Only your stateless application code belongs in a container.
What gets containerized in production?
- api stays as a container, deployed to AWS ECS/Fargate. It is stateless. If it crashes, a new container spins up with the same code and connects to the same databases.
- postgres becomes Amazon RDS. A managed database handles automated backups, failover, patching, and scaling.
- redis becomes Amazon ElastiCache. A managed cache handles replication and automatic failover.
- nginx becomes an Application Load Balancer (ALB). A managed load balancer handles TLS termination and distributes traffic across multiple container instances.
The key rule: containerize stateless app code, use managed services for stateful infrastructure (databases, caches, message queues).
This pattern works because your server.js reads all connection details from environment variables like PGHOST and REDIS_HOST. In development those variables point to your local Compose services. In production they point to RDS and ElastiCache endpoints. Same code, different infrastructure.
Verify the merged configuration
Docker Compose can show you the final merged result of your base file combined with the production override. This lets you confirm that your overrides are applied correctly before deploying anything.
Before you run this, what do you expect to see for the postgres service's ports field?
- View the merged production configuration by running:
docker compose -f compose.yaml -f compose.prod.yaml config
The output shows the fully merged YAML that Docker Compose will use. Scroll through it and confirm these three things:
- The postgres and redis services have no ports listed (the ports: [] override removed them).
- The api service shows deploy.resources.limits with memory: 512M and cpus: "1.0".
- Every service has restart: unless-stopped set.
How does the merge work?
Docker Compose merges multiple files by adding missing entries and overriding conflicting ones. Simple attributes in the override file replace the base value. Lists like ports are appended by default, but setting ports: [] replaces the list with an empty one.
Seeing a YAML syntax error?
- YAML is indentation-sensitive. Make sure each nested key is indented with exactly 2 spaces (not tabs).
- Check that services: is at the top level (no indentation) and each service name is indented 2 spaces under it.
- Verify the cpus value is quoted: cpus: "1.0" (YAML interprets unquoted 1.0 as a float, which Compose rejects for this field).
Help me fix a YAML error in my compose.prod.yaml
You now have a production-ready Docker workflow. Your base compose.yaml serves local development, and compose.prod.yaml layers on the production hardening. The same application code runs identically in both environments because every connection detail is an environment variable.
Secret mission
Vulnerability Scanning and Resource Limits
Your production image builds and runs. But does it contain known vulnerabilities? And what stops a single container from consuming all your server's memory? In this bonus challenge, you scan your image for CVEs with Docker Scout and add resource constraints that prevent container resource exhaustion.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- Docker images (myapi:naive and myapi:prod) stored locally.
- Docker volume (pgdata) storing PostgreSQL data locally.
- Project folder (docker-production-api) on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively building or want to experiment further with your Docker setup.
- Your Docker images and volumes remain on disk. They use approximately 2GB of disk space but cost nothing.
- You can restart your stack anytime with docker compose up from the project folder.
Pause - I'll come back to this later
Shut down running containers to free up memory. Your images stay on disk so you can restart quickly later.
- Stop and remove all containers and the database volume by running:
cd ~/Desktop/docker-production-api
docker compose down --volumes
What does this command do?
This stops all running containers, removes the containers and networks, and deletes the named pgdata volume. Your Docker images stay on disk so rebuilds are instant when you come back.
- When you return, run docker compose up to start fresh. The database will be empty since the volume was removed.
Delete - I don't want to use this again
Remove all project resources and start fresh. This frees up approximately 2GB of disk space.
- Stop containers and remove volumes by running:
cd ~/Desktop/docker-production-api
docker compose down --volumes
- Remove both Docker images by running:
docker rmi myapi:naive myapi:prod
- Remove any remaining unused Docker resources (dangling images, unused networks) by running:
docker system prune -a --volumes
What does this command do?
This removes all stopped containers, all unused networks, all images not referenced by a container, and all volumes not referenced by a container. It is a full reset of your local Docker environment.
- Delete the project folder on macOS/Linux by running:
rm -rf ~/Desktop/docker-production-api
- On Windows, run this instead:
rmdir /s /q %USERPROFILE%\Desktop\docker-production-api
That's a Wrap!
That's a Wrap!
Nice work! You just took a Node.js API from a bloated, insecure development container to a production-hardened image running behind a full multi-service stack.
You've learned how to:
- Build a production Dockerfile with multi-stage builds, non-root execution, and a HEALTHCHECK that shrinks a ~1GB image down to ~150MB.
- Orchestrate a 4-service stack (API + PostgreSQL + Redis + Nginx) using Docker Compose with health-check dependencies, volumes, and networking.
- Apply the critical production architecture rule: containerize stateless app code and use managed services for databases and caches.
- Secret Mission: scanned your production image for CVEs with Docker Scout and configured resource limits to prevent container resource exhaustion.
Ready to quiz yourself?