Containerise a Node.js Web Application with Docker

Build your first Docker container by packaging a Node.js Express app, writing a Dockerfile, and running it locally with Docker Compose.

Domains & Technologies

Domains
CI-CD
Technologies
DOCKER

Blueprint Walkthrough

Architecture Overview

This project teaches you the most fundamental skill in modern DevOps — packaging an application into a Docker container. You will take a simple Node.js web server, write a Dockerfile that describes how to build the container image, and run it using Docker Compose.

Every DevOps engineer uses Docker daily. Before you can deploy to Kubernetes, ECS, or any cloud platform, you need to understand how containers work from the ground up. This project builds that foundation.

YAML
Your Node.js App (code files)
|
v
[ Dockerfile ]
(instructions to
build the image)
|
v
[ Docker Image ]
(snapshot of your
app + dependencies)
|
v
[ Docker Container ]
(running instance
of the image)
|
v
http://localhost:3000
(your app is live!)
Problem Solved

Before Docker, the most common problem in software teams was — it works on my laptop but not on the server. Each developer had slightly different versions of Node.js, different operating systems, and different environment configurations. The server had a different setup again.

Docker solves this by packaging your application together with everything it needs — the exact Node.js version, all npm dependencies, and environment variables — into a single portable image. That image runs identically on your laptop, your colleague's laptop, and the production server. No more environment mismatch.

Step-by-Step Implementation Guide

Step 1: Install Docker and Create the Application

First install Docker Desktop from the official website. Docker Desktop includes everything you need — the Docker Engine, Docker Compose, and a GUI to see your containers.

Bash
## Verify Docker is installed correctly
docker --version
## Expected: Docker version 24.x.x or higher
docker compose version
## Expected: Docker Compose version v2.x.x or higher

Create your project directory and a simple Node.js application:

Bash
mkdir my-docker-app && cd my-docker-app

Create package.json:

JSON
{
"name": "my-docker-app",
"version": "1.0.0",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js"
},
"dependencies": {
"express": "^4.18.2"
}
}

Create src/index.js:

JAVASCRIPT
const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;
// Health check endpoint
// Docker and Kubernetes use this to verify the app is alive
app.get('/health', (req, res) => {
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION || '1.0.0'
});
});
// Main endpoint
app.get('/', (req, res) => {
const name = process.env.APP_NAME || 'DevOps App';
res.json({
message: `Hello from ${name}!`,
environment: process.env.NODE_ENV || 'development'
});
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Remember

The /health endpoint is not just for testing. In production, Kubernetes, ECS, and load balancers all call this endpoint automatically to check if your container is alive. Always include it.

Step 2: Write Your First Dockerfile

A Dockerfile is a text file with instructions that Docker reads top to bottom to build your image. Think of it as a recipe — each line adds a layer to the image.

Create Dockerfile in your project root:

Dockerfile
## Stage 1: Start from an official Node.js base image
## alpine = lightweight Linux (only 5MB vs 900MB for full Ubuntu)
FROM node:20-alpine
## Set the working directory inside the container
## All subsequent commands run from this directory
WORKDIR /app
## Copy package files FIRST (before copying source code)
## Why? Docker caches each layer. If package.json does not change,
## Docker reuses the cached npm install layer — much faster builds
COPY package*.json ./
## Install dependencies inside the container
RUN npm install --only=production
## Now copy your source code
## This layer changes every time you edit code
COPY src/ ./src/
## Document which port the app listens on
## This is documentation only — does not actually expose the port
EXPOSE 3000
## Security: run as non-root user
## The node user comes pre-created in the official node image
USER node
## The command to start the application
CMD ["node", "src/index.js"]

Create .dockerignore to exclude files from the image:

Bash
node_modules
.git
.gitignore
*.log
README.md
Tip

The .dockerignore file works exactly like .gitignore. Always add node_modules here — you never want to copy your local node_modules into the image. The RUN npm install step inside Docker installs them fresh for the container's operating system.

Step 3: Build and Run Your Docker Image

Bash
## Build the image and tag it with a name
## The dot at the end means 'use the Dockerfile in the current directory'
docker build -t my-docker-app:v1 .
## Watch the build output — you will see each layer being processed
## Expected output includes:
## => [1/5] FROM node:20-alpine
## => [2/5] WORKDIR /app
## => [3/5] COPY package*.json ./
## => [4/5] RUN npm install
## => [5/5] COPY src/ ./src/
## => exporting to image
## List your images
docker images | grep my-docker-app
## Expected: my-docker-app v1 abc123 2 minutes ago ~150MB
## Run the container
## -d = detached (run in background)
## -p 3000:3000 = map port 3000 on your machine to port 3000 in container
## --name = give it a readable name
docker run -d -p 3000:3000 --name webapp my-docker-app:v1
## Verify it is running
docker ps
## Expected: webapp container showing status Up
## Test the application
curl http://localhost:3000
## Expected: {"message":"Hello from DevOps App!","environment":"development"}
curl http://localhost:3000/health
## Expected: {"status":"healthy","timestamp":"...","version":"1.0.0"}

Step 4: Add Docker Compose for Multi-Container Setup

Docker Compose lets you define and run multiple containers together. You will add a Redis container alongside your app — this is how real applications work in production.

Create docker-compose.yml:

YAML
version: '3.8'
services:
# Your Node.js application
webapp:
build: . # Build from the Dockerfile in current directory
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 # Start redis before the webapp
restart: unless-stopped
# Redis cache container
# Notice: no Dockerfile needed — we use the official image directly
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis-data:/data # Persist Redis data between restarts
restart: unless-stopped
## Named volume for Redis persistence
volumes:
redis-data:
Bash
## Stop the container you ran manually
docker stop webapp && docker rm webapp
## Start everything with Docker Compose
docker compose up -d
## Check both containers are running
docker compose ps
## Expected: webapp and redis both showing status running
## View logs from all containers
docker compose logs
## View logs from just the webapp, follow in real time
docker compose logs -f webapp
## Test the app is still working
curl http://localhost:3000
Remember

In Docker Compose, containers communicate using their service names as hostnames. Your webapp reaches Redis at redis://redis:6379 — the hostname redis resolves to the Redis container automatically. This is Docker's built-in DNS.

Step 5: Understand Image Layers and Caching

This step teaches you the most important Docker optimisation — layer caching. Run these commands and observe how much faster the second build is.

Bash
## Make a small change to your source code
echo "// updated" >> src/index.js
## Rebuild the image — time it
time docker build -t my-docker-app:v2 .
## Watch the output carefully:
## => CACHED [2/5] WORKDIR /app <- reused from cache
## => CACHED [3/5] COPY package*.json ./ <- reused from cache
## => CACHED [4/5] RUN npm install <- reused from cache (no package change)
## => [5/5] COPY src/ ./src/ <- rebuilt (source code changed)
## Now change package.json (add a new dependency)
npm install --save lodash
## Rebuild and observe
time docker build -t my-docker-app:v3 .
## This time npm install runs again because package.json changed
## The layers after the change are all rebuilt
## This is why we copy package.json BEFORE source code in the Dockerfile
Common Mistake

Beginners often write the Dockerfile like this: copy all files first, then run npm install. This means EVERY code change invalidates the npm install cache and npm install runs every single build. Always copy package*.json and run npm install BEFORE copying your source code.

Step 6: Push to Docker Hub

Docker Hub is the public registry for Docker images — like GitHub for code, but for container images.

Bash
## Create a free account at hub.docker.com
## Then log in from your terminal
docker login
## Enter your Docker Hub username and password
## Tag your image with your Docker Hub username
## Format: username/image-name:tag
docker tag my-docker-app:v1 YOUR_DOCKERHUB_USERNAME/my-docker-app:v1
docker tag my-docker-app:v1 YOUR_DOCKERHUB_USERNAME/my-docker-app:latest
## Push to Docker Hub
docker push YOUR_DOCKERHUB_USERNAME/my-docker-app:v1
docker push YOUR_DOCKERHUB_USERNAME/my-docker-app:latest
## Verify — pull the image from Docker Hub to confirm it was uploaded
docker rmi YOUR_DOCKERHUB_USERNAME/my-docker-app:v1
docker pull YOUR_DOCKERHUB_USERNAME/my-docker-app:v1
docker run -d -p 3001:3000 YOUR_DOCKERHUB_USERNAME/my-docker-app:v1
curl http://localhost:3001
## Expected: Same response — image runs identically from Docker Hub
Validation & Testing
Bash
## 1. Verify both containers are running
docker compose ps
## Expected: webapp (running) and redis (running)
## 2. Test the health endpoint
curl http://localhost:3000/health
## Expected: {"status":"healthy",...}
## 3. Test environment variable injection
docker run --rm \
-e APP_NAME="Zerodha Backend" \
-e APP_VERSION="2.5.0" \
-p 3001:3000 \
my-docker-app:v1
curl http://localhost:3001
## Expected: {"message":"Hello from Zerodha Backend!", ...}
## Different output from same image — environment variables change behaviour
## 4. Check image size is reasonable
docker images my-docker-app:v1
## Expected: Size around 150-200MB (alpine keeps it small)
## 5. Verify non-root user
docker run --rm my-docker-app:v1 whoami
## Expected: node (not root!)
## 6. Verify .dockerignore is working
docker run --rm my-docker-app:v1 ls /app
## Expected: package.json, package-lock.json, src/
## node_modules should NOT be present — installed fresh inside
## 7. Inspect image layers
docker history my-docker-app:v1
## Expected: Shows each layer with its size
## The npm install layer should be the largest
## 8. Clean up everything
docker compose down -v
## Expected: Stops containers and removes the redis-data volume
echo "Docker project complete!"