Skip to main content

Containerise a Node.js Web Application with Docker

Package a Node.js and Redis app with a Dockerfile, run it with Docker Compose, and shrink the image with multi-stage builds and layer caching.

Blueprint Walkthrough

Architecture Overview & Problem Statement

This project containerises a Node.js Express app that counts visits in Redis. You write a Dockerfile that builds the app into an image, run it as a container, then use Docker Compose to start the app and Redis together with a health check and a persistent volume. You finish by shrinking the build with a multi-stage Dockerfile and publishing the image to Docker Hub.

What you will build, learn and show

  • Build: a Node.js API with / and /health endpoints, a production-minded Dockerfile, a docker-compose.yml with two services, and a published Docker Hub image.
  • Learn: how images, layers and containers relate, why instruction order controls build speed, how containers reach each other by service name, how to run as a non-root user, and how to handle docker stop correctly.
  • Portfolio deliverable: a repository with the app, Dockerfile, Compose file and a README, plus a public image on Docker Hub that anyone can run with one command.
  • Before you start: Docker Desktop (or Docker Engine on Linux) and Node.js 22 or 24. No Kubernetes or cloud account is needed.

The problem Docker solves

Teams at companies like Zerodha and Swiggy ship many services written by different people. Without containers, each service depends on whatever Node.js version and system libraries happen to exist on a laptop or server, so "it works on my machine" becomes a daily problem. Docker packages the app with its exact runtime and dependencies into an image (a read-only template). Running that image creates a container, an isolated process that behaves the same on your laptop, in CI and in production.

The architecture you will build

flowchart LR A[Source code
and Dockerfile]:::slate -->|docker build| B[Docker
image]:::purple B -->|docker run| C[webapp container
port 3000]:::green U[Browser
localhost:3000]:::blue --> C C -->|REDIS_URL| D[redis container
and volume]:::orange classDef blue fill:#326CE5,stroke:#1E4FB5,color:#FFFFFF classDef green fill:#10B981,stroke:#047857,color:#FFFFFF classDef orange fill:#F97316,stroke:#C2410C,color:#FFFFFF classDef red fill:#EF4444,stroke:#B91C1C,color:#FFFFFF classDef purple fill:#8B5CF6,stroke:#6D28D9,color:#FFFFFF classDef slate fill:#475569,stroke:#1E293B,color:#FFFFFF classDef soft fill:#F1F5F9,stroke:#94A3B8,color:#1E293B

Only the web app is published to your machine. Redis stays on the private Compose network, which is how you would expose a real service.

Milestone 1: Install Docker and Build the Node.js App

This milestone installs Docker and creates an Express app that stores a visit counter in Redis.

An app that talks to Redis gives you something real to containerise: two processes that must find each other, a health endpoint that reports dependency status, and a shutdown path. First confirm Docker works.

Bash
docker --version
docker compose version
# Expected: both print a version (the exact numbers vary)
docker run --rm hello-world
# Expected: "Hello from Docker!"

Create the project:

Bash
mkdir my-docker-app && cd my-docker-app
npm init -y
npm install express redis
npm pkg set main="src/index.js"
npm pkg set scripts.start="node src/index.js"
mkdir src

Create src/index.js:

JAVASCRIPT
const express = require('express');
const { createClient } = require('redis');
const app = express();
const PORT = process.env.PORT || 3000;
const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
// Fail fast when Redis is down instead of queueing requests forever
const redis = createClient({ url: REDIS_URL, disableOfflineQueue: true });
redis.on('error', (err) => console.error('Redis error:', err.message));
redis.connect().catch((err) => console.error('Redis connect failed:', err.message));
app.get('/', async (req, res) => {
try {
const visits = await redis.incr('visits');
res.json({
message: `Hello from ${process.env.APP_NAME || 'DevOps App'}!`,
environment: process.env.NODE_ENV || 'development',
visits,
});
} catch (err) {
res.status(503).json({ error: 'Cache unavailable' });
}
});
// Orchestrators and load balancers call this to decide if the app is usable
app.get('/health', (req, res) => {
const ready = redis.isReady;
res.status(ready ? 200 : 503).json({
status: ready ? 'healthy' : 'degraded',
redis: ready ? 'connected' : 'disconnected',
version: process.env.APP_VERSION || '1.0.0',
timestamp: new Date().toISOString(),
});
});
const server = app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
// docker stop sends SIGTERM. Without a handler, Node as PID 1 ignores it
// and Docker waits 10 seconds before killing the container.
const shutdown = (signal) => {
console.log(`${signal} received, shutting down`);
server.close(async () => {
if (redis.isOpen) await redis.quit();
process.exit(0);
});
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
Command What it does
docker --version Confirms the Docker CLI is installed
docker compose version Confirms Compose v2 is available
docker run --rm hello-world Proves the Docker engine can pull and run an image
Tip

The /health endpoint is not decoration. Kubernetes, ECS and load balancers all call a health route to decide whether to send traffic to a container.

Common Mistake

Running docker and getting "Cannot connect to the Docker daemon". On macOS and Windows, open Docker Desktop and wait for it to say it is running. On Linux, start the service with sudo systemctl start docker.

Milestone 2: Write a Production-Minded Dockerfile

This milestone writes the Dockerfile that turns your source code into a runnable image.

A Dockerfile is read top to bottom, and each instruction creates a layer. Docker caches layers, so if an instruction and everything above it are unchanged, Docker reuses the cached layer. That is why dependency files are copied and installed before your source code: dependencies change rarely, code changes constantly.

Create Dockerfile:

Dockerfile
# Official Node.js LTS image on Alpine Linux (small footprint)
FROM node:24-alpine
# All following commands run from /app inside the image
WORKDIR /app
# Copy only the dependency manifests first so this layer stays cached
COPY package*.json ./
# Install production dependencies using the exact lock file versions
RUN npm ci --omit=dev
# Copy the application code last, because it changes most often
COPY src/ ./src/
ENV NODE_ENV=production
# Documentation only: the app listens on 3000
EXPOSE 3000
# Run as the unprivileged user that ships with the node image
USER node
# Exec form, so Node receives SIGTERM directly
CMD ["node", "src/index.js"]

Create .dockerignore so local files never enter the image:

Bash
node_modules
.git
.gitignore
.env
*.log
README.md
Dockerfile
docker-compose.yml
Instruction What it does
FROM Chooses the base image everything builds on
WORKDIR Sets the working directory for later instructions
COPY Copies files from your machine into the image
RUN Executes a command at build time and saves the result as a layer
USER Drops root privileges for the running container
CMD The command that starts when a container runs
Security

Containers run as root by default. USER node limits the damage if the app is exploited, and .dockerignore keeps .env files and credentials out of the image.

Common Mistake

Writing CMD npm start (shell form). npm sits between Docker and Node, swallows SIGTERM, and docker stop hangs for 10 seconds. Use the exec form ["node", "src/index.js"].

Milestone 3: Build and Run the Container

This milestone builds the image, starts a container and observes how it behaves when its dependency is missing.

docker build runs the Dockerfile and produces an image tagged with a name and version. docker run starts a container from it. The -p 3000:3000 flag maps port 3000 on your machine to port 3000 in the container, because containers have their own network and nothing is reachable unless you publish it.

Bash
docker build -t my-docker-app:v1 .
# Watch each instruction run as a numbered step
docker images my-docker-app
# Expected: my-docker-app v1 <id> a few seconds ago <size>
docker run -d -p 3000:3000 --name webapp my-docker-app:v1
docker ps
# Expected: webapp with STATUS "Up"
curl -i http://localhost:3000/health
# Expected: HTTP/1.1 503 and {"status":"degraded","redis":"disconnected",...}

The 503 is the correct result. The app is running, but there is no Redis for it to reach, and /health says so honestly. Read the logs to see why:

Bash
docker logs webapp
# Expected: repeated "Redis error: connect ECONNREFUSED 127.0.0.1:6379"

Inside a container, localhost means the container itself, not your laptop and not another container. The next milestone fixes this with Docker Compose.

Bash
docker stop webapp && docker rm webapp
# Expected: stops in about a second thanks to the SIGTERM handler
Command What it does
docker build -t name:tag . Builds an image from the Dockerfile in this folder
docker run -d -p HOST:CONTAINER Runs detached and publishes a port
docker ps Lists running containers
docker logs webapp Shows what the container wrote to stdout and stderr
docker stop and docker rm Stops and deletes a container
Common Mistake

"port is already allocated". Something else already uses port 3000. Stop it, or publish a different host port such as -p 3001:3000.

Milestone 4: Add Redis with Docker Compose

This milestone defines the app and Redis as two services in one Compose file and starts them together.

Docker Compose describes a multi-container application in YAML. Compose creates a private network where each service is reachable by its service name, so your app connects to redis://redis:6379. A health check on Redis plus depends_on with condition: service_healthy makes the app wait until Redis actually answers, not merely until its container starts.

Create docker-compose.yml:

YAML
services:
webapp:
build: .
ports:
- "3000:3000"
environment:
NODE_ENV: development
APP_NAME: DevOps Network App
APP_VERSION: 1.0.0
REDIS_URL: redis://redis:6379
depends_on:
redis:
condition: service_healthy
restart: unless-stopped
redis:
image: redis:8-alpine
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
restart: unless-stopped
volumes:
redis-data:
Bash
docker compose up -d --build
docker compose ps
# Expected: webapp running, redis running (healthy)
curl http://localhost:3000/
curl http://localhost:3000/
# Expected: "visits":1 then "visits":2
curl http://localhost:3000/health
# Expected: {"status":"healthy","redis":"connected",...}

Prove the data survives. Removing containers keeps the named volume, so the counter continues:

Bash
docker compose down
docker compose up -d
curl http://localhost:3000/
# Expected: "visits":3 (the count survived the restart)
Command What it does
docker compose up -d --build Builds images if needed and starts all services
docker compose ps Shows service status and health
docker compose logs -f webapp Follows one service's logs
docker compose down Removes containers and the network, keeps volumes
docker compose down -v Also deletes volumes (wipes the data)
Security

Redis has no ports entry on purpose. Services on the Compose network can reach it, but nothing on your machine's network can. Publishing a database port to the host is a common way databases end up exposed to the internet.

Common Mistake

Setting REDIS_URL to redis://localhost:6379 inside Compose. From the webapp container, localhost is the webapp itself. Use the service name: redis://redis:6379.

Milestone 5: Master Layer Caching and Multi-Stage Builds

This milestone measures how layer caching speeds up builds, then rewrites the Dockerfile as a multi-stage build.

Each instruction in the Dockerfile is a layer, and a changed layer invalidates every layer after it. Watch it happen:

flowchart LR A[Cached layers
base image, WORKDIR,
package files, npm ci]:::green --> B[COPY src
rebuilt]:::orange B --> C[Fast build after
a code edit]:::blue classDef blue fill:#326CE5,stroke:#1E4FB5,color:#FFFFFF classDef green fill:#10B981,stroke:#047857,color:#FFFFFF classDef orange fill:#F97316,stroke:#C2410C,color:#FFFFFF classDef red fill:#EF4444,stroke:#B91C1C,color:#FFFFFF classDef purple fill:#8B5CF6,stroke:#6D28D9,color:#FFFFFF classDef slate fill:#475569,stroke:#1E293B,color:#FFFFFF classDef soft fill:#F1F5F9,stroke:#94A3B8,color:#1E293B
Bash
# 1. Change only source code, then rebuild
echo "// tweak" >> src/index.js
time docker build -t my-docker-app:v2 .
# Expected: npm ci shows CACHED, only COPY src and later steps rerun
# 2. Change dependencies, then rebuild
npm install lodash
time docker build -t my-docker-app:v3 .
# Expected: npm ci reruns because package.json changed
# Undo the experiment
npm uninstall lodash

Now rewrite the Dockerfile as a multi-stage build: one stage installs dependencies, and the final stage copies only what it needs. For a plain JavaScript app the saving is small, but the pattern is essential the moment you add TypeScript or a build step, because compilers and dev dependencies stay out of the final image.

Dockerfile
# syntax=docker/dockerfile:1
# Stage 1: install production dependencies, reusing the npm download cache
FROM node:24-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
# Stage 2: the small image that actually runs
FROM node:24-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY package*.json ./
COPY src/ ./src/
EXPOSE 3000
USER node
# Docker marks the container unhealthy if /health stops answering
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1
CMD ["node", "src/index.js"]
Bash
docker build -t my-docker-app:v4 .
docker images my-docker-app
# Compare the sizes of v1 and v4
docker history my-docker-app:v4
# Shows each layer and its size
Technique Why it helps
Copy manifests before source Dependency layer stays cached across code edits
Multi-stage build Build tools never reach the final image
--mount=type=cache npm reuses downloads between builds
HEALTHCHECK docker ps shows healthy or unhealthy
Tip

Use docker history to find which layer is largest. Slimming an image starts with knowing where the megabytes are.

Common Mistake

Copying all files before npm ci. Every code edit then invalidates the install layer, and each build downloads every package again.

Milestone 6: Harden, Scan and Publish the Image

This milestone runs the container with tighter security, scans the image for known vulnerabilities, and publishes it to Docker Hub.

The app only needs to read its own files and listen on a port, so it can run with a read-only filesystem and no extra Linux capabilities. Locking it down costs nothing and limits what an attacker can do.

Bash
docker run -d --name hardened -p 3002:3000 \
--read-only --cap-drop=ALL --security-opt=no-new-privileges \
my-docker-app:v4
docker ps --filter name=hardened
# Expected: Up (the app needs no writable disk and no extra privileges)
# It reports "degraded" on /health because this run has no Redis, which is fine here
docker rm -f hardened

Scan the image. Docker Desktop includes Docker Scout, which lists known vulnerabilities in the base image and packages:

Bash
docker scout quickview my-docker-app:v4
# Expected: a summary of critical, high, medium and low findings

Now publish. Create a free account at hub.docker.com, then create an access token under Account Settings, Personal access tokens. Use the token instead of your password:

Bash
docker login -u YOUR_DOCKERHUB_USERNAME
# Paste the access token when prompted
docker tag my-docker-app:v4 YOUR_DOCKERHUB_USERNAME/my-docker-app:v1
docker tag my-docker-app:v4 YOUR_DOCKERHUB_USERNAME/my-docker-app:latest
docker push YOUR_DOCKERHUB_USERNAME/my-docker-app:v1
docker push YOUR_DOCKERHUB_USERNAME/my-docker-app:latest

If you build on an Apple Silicon Mac, your image is arm64, and most cloud servers are amd64. Build for both so it runs anywhere:

Bash
docker buildx build --platform linux/amd64,linux/arm64 \
-t YOUR_DOCKERHUB_USERNAME/my-docker-app:v1 --push .

Confirm the published image works from a clean state:

Bash
docker rmi YOUR_DOCKERHUB_USERNAME/my-docker-app:v1
docker run --rm -d -p 3001:3000 --name from-hub YOUR_DOCKERHUB_USERNAME/my-docker-app:v1
curl -i http://localhost:3001/health
# Expected: the same JSON (503 degraded without Redis is correct)
docker rm -f from-hub
Command What it does
--read-only Mounts the container's root filesystem read-only
--cap-drop=ALL Removes all extra Linux capabilities
--security-opt=no-new-privileges Stops processes gaining privileges
docker scout quickview Summarises known vulnerabilities in an image
docker buildx build --platform Builds for several CPU architectures
Security

Never put a Docker Hub password or token in a Dockerfile, a Compose file or an image layer. Anything copied into an image can be recovered from it.

Common Mistake

"exec format error" when running an image on a server. The image was built for a different CPU architecture than the server. Build with --platform linux/amd64, or publish a multi-architecture image as shown above.

Validation & Testing

Run these checks to confirm everything works.

Bash
# 1. Both services are up and Redis is healthy
docker compose up -d --build
docker compose ps
# Expected: webapp running, redis running (healthy)
# 2. The app works and talks to Redis
curl http://localhost:3000/
# Expected: JSON with a "visits" number that increases on each call
# 3. Health reflects the dependency
curl http://localhost:3000/health
# Expected: {"status":"healthy","redis":"connected",...}
# 4. Environment variables change behaviour without rebuilding
docker run --rm -e APP_NAME="Zerodha Backend" my-docker-app:v4 node -e "console.log(process.env.APP_NAME)"
# Expected: Zerodha Backend
# 5. The container runs as a non-root user
docker compose exec webapp whoami
# Expected: node
# 6. Secrets and junk stayed out of the image
docker compose exec webapp ls -a /app
# Expected: node_modules, package.json, package-lock.json, src
# Not expected: .git, .env, Dockerfile
# 7. Shutdown is graceful
time docker compose stop webapp
# Expected: finishes in about a second, not 10 seconds
# 8. Clean up
docker compose down -v
Check Expected result
Rebuild after a code-only edit npm ci step shows CACHED
docker images my-docker-app Image is well under 300 MB
docker history Largest layer is the dependency install
Docker Hub Public image with v1 and latest tags
Fresh pull and run Same behaviour as local

When all rows pass, the project is portfolio ready. Containers are the unit that CI pipelines build and Kubernetes runs, so the natural next steps are the GitHub Actions CI project and the Kubernetes deployment project.

Found a mistake, an outdated step or a broken link?