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/healthendpoints, a production-minded Dockerfile, adocker-compose.ymlwith 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 stopcorrectly. - 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
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.
docker --versiondocker compose version# Expected: both print a version (the exact numbers vary) docker run --rm hello-world# Expected: "Hello from Docker!"Create the project:
mkdir my-docker-app && cd my-docker-appnpm init -ynpm install express redisnpm pkg set main="src/index.js"npm pkg set scripts.start="node src/index.js"mkdir srcCreate src/index.js:
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 foreverconst 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 usableapp.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 |
TipThe
/healthendpoint is not decoration. Kubernetes, ECS and load balancers all call a health route to decide whether to send traffic to a container.
Common MistakeRunning
dockerand 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 withsudo 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:
# Official Node.js LTS image on Alpine Linux (small footprint)FROM node:24-alpine # All following commands run from /app inside the imageWORKDIR /app # Copy only the dependency manifests first so this layer stays cachedCOPY package*.json ./ # Install production dependencies using the exact lock file versionsRUN npm ci --omit=dev # Copy the application code last, because it changes most oftenCOPY src/ ./src/ ENV NODE_ENV=production # Documentation only: the app listens on 3000EXPOSE 3000 # Run as the unprivileged user that ships with the node imageUSER node # Exec form, so Node receives SIGTERM directlyCMD ["node", "src/index.js"]Create .dockerignore so local files never enter the image:
node_modules.git.gitignore.env*.logREADME.mdDockerfiledocker-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 |
SecurityContainers run as root by default.
USER nodelimits the damage if the app is exploited, and.dockerignorekeeps.envfiles and credentials out of the image.
Common MistakeWriting
CMD npm start(shell form). npm sits between Docker and Node, swallows SIGTERM, anddocker stophangs 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.
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:v1docker 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:
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.
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:
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:docker compose up -d --builddocker 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:
docker compose downdocker compose up -dcurl 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) |
SecurityRedis has no
portsentry 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 MistakeSetting
REDIS_URLtoredis://localhost:6379inside Compose. From the webapp container,localhostis 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:
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
# 1. Change only source code, then rebuildecho "// tweak" >> src/index.jstime docker build -t my-docker-app:v2 .# Expected: npm ci shows CACHED, only COPY src and later steps rerun # 2. Change dependencies, then rebuildnpm install lodashtime docker build -t my-docker-app:v3 .# Expected: npm ci reruns because package.json changed # Undo the experimentnpm uninstall lodashNow 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.
# syntax=docker/dockerfile:1 # Stage 1: install production dependencies, reusing the npm download cacheFROM node:24-alpine AS depsWORKDIR /appCOPY package*.json ./RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev # Stage 2: the small image that actually runsFROM node:24-alpine AS runtimeWORKDIR /appENV NODE_ENV=productionCOPY --from=deps /app/node_modules ./node_modulesCOPY package*.json ./COPY src/ ./src/EXPOSE 3000USER node # Docker marks the container unhealthy if /health stops answeringHEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD wget -qO- http://localhost:3000/health || exit 1 CMD ["node", "src/index.js"]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 |
TipUse
docker historyto find which layer is largest. Slimming an image starts with knowing where the megabytes are.
Common MistakeCopying 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.
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 heredocker rm -f hardenedScan the image. Docker Desktop includes Docker Scout, which lists known vulnerabilities in the base image and packages:
docker scout quickview my-docker-app:v4# Expected: a summary of critical, high, medium and low findingsNow 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:
docker login -u YOUR_DOCKERHUB_USERNAME# Paste the access token when prompted docker tag my-docker-app:v4 YOUR_DOCKERHUB_USERNAME/my-docker-app:v1docker tag my-docker-app:v4 YOUR_DOCKERHUB_USERNAME/my-docker-app:latestdocker push YOUR_DOCKERHUB_USERNAME/my-docker-app:v1docker push YOUR_DOCKERHUB_USERNAME/my-docker-app:latestIf you build on an Apple Silicon Mac, your image is arm64, and most cloud servers are amd64. Build for both so it runs anywhere:
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:
docker rmi YOUR_DOCKERHUB_USERNAME/my-docker-app:v1docker run --rm -d -p 3001:3000 --name from-hub YOUR_DOCKERHUB_USERNAME/my-docker-app:v1curl -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 |
SecurityNever 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.
# 1. Both services are up and Redis is healthydocker compose up -d --builddocker compose ps# Expected: webapp running, redis running (healthy) # 2. The app works and talks to Rediscurl http://localhost:3000/# Expected: JSON with a "visits" number that increases on each call # 3. Health reflects the dependencycurl http://localhost:3000/health# Expected: {"status":"healthy","redis":"connected",...} # 4. Environment variables change behaviour without rebuildingdocker 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 userdocker compose exec webapp whoami# Expected: node # 6. Secrets and junk stayed out of the imagedocker compose exec webapp ls -a /app# Expected: node_modules, package.json, package-lock.json, src# Not expected: .git, .env, Dockerfile # 7. Shutdown is gracefultime docker compose stop webapp# Expected: finishes in about a second, not 10 seconds # 8. Clean updocker 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.