Modern software delivery demands speed, reliability, and scalability. Continuous Integration and Continuous Deployment (CI/CD) pipelines enable teams to ship code faster while maintaining high quality and operational stability. When combined with Node.js, Docker, and Kubernetes (K8s), CI/CD becomes a powerful system for building cloud-native, production-ready applications.
This article provides a complete technical walkthrough of designing and implementing a CI/CD pipeline.
Before starting, you need to install these tools on your machine.
Required Downloads:
Node.js (v18 or higher) - Current LTS: v20.x
Download from: https://nodejs.org/
Choose the LTS version (20.x as of 2025)
Verify installation: node --version and npm --versionGit - Latest stable version
Download from: https://git-scm.com/downloads
Choose your operating system version
Verify installation: git --versionDocker Desktop - Latest version
Download from: https://www.docker.com/products/docker-desktop/
Install and start Docker Desktop
Verify installation: docker --version and docker-compose --versionGitHub Account
Sign up at: https://github.com
You'll need this for hosting your code and CI/CD pipelineVS Code
Download: https://code.visualstudio.com/
Verify Everything is Installed
Step 1: Set Up Git for Version Control
Configures Git on your machine so it knows who you are when you make commits and sets up proper project tracking.
One-time Git Configuration
git config --global user.name "Your Name"
git config --global user.email "you@example.com"
git config --global init.defaultBranch main
To displays all Git configuration settings that are currently applied for your environment use the command git config --list
Create and Initialize Project
To create and initialize the project, first create a new directory, navigate into it, and ensure the directory is initialized with a local Git repository using the below commands.
mkdir my-devops-project
cd my-devops-project
git init
Step 2: Build a Node.js Web App
Creates a web application using Node.js that can serve web pages and API endpoints.
Initialize Node.js Project
This command creates a package.json file that describes your project and manages dependencies.
npm init -y
The -y flag automatically responds βyesβ to all confirmation prompts during command execution.
Update package.json
This customizes the package.json with proper scripts and metadata for your DevOps project.
Create/edit package.json:
{
"name": "devops-project01",
"version": "1.0.0",
"description": "DevOps learning project with Node.js",
"main": "app.js",
"scripts": {
"start": "node app.js",
"test": "jest",
"dev": "node app.js",
"lint": "eslint ."
},
"keywords": ["devops", "nodejs", "docker"],
"author": "lotanna",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"devDependencies": {
"jest": "^29.7.0",
"eslint": "^8.57.0",
"supertest": "^7.1.4"
}
}
Create Application File
To create the application file, use the touch command to generate app.js, then insert the required code, which defines the following functionalities and outlines.
HTTP server that listens on port 3000
Serves different endpoints (/, /health, /info, /metrics)
Includes security headers and proper error handling
Provides graceful shutdown capability
Exports the server for testing
Create app.js:
// core modules
const http = require("http");
const url = require("url");
// environment configuration
const PORT = process.env.PORT || 3000;
const ENVIRONMENT = process.env.NODE_ENV || "development";
let requestCount = 0;
// helper: send JSON responses
function sendJSON(res, statusCode, data) {
res.statusCode = statusCode;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(data, null, 2));
}
// helper: send HTML responses
function sendHTML(res, statusCode, content) {
res.statusCode = statusCode;
res.setHeader("Content-Type", "text/html");
res.end(content);
}
// helper: send Prometheus metrics
function sendMetrics(res) {
const mem = process.memoryUsage();
const metrics = `
# HELP http_requests_total Total HTTP requests
# TYPE http_requests_total counter
http_requests_total ${requestCount}
# HELP app_uptime_seconds Application uptime in seconds
# TYPE app_uptime_seconds gauge
app_uptime_seconds ${process.uptime()}
# HELP nodejs_memory_usage_bytes Node.js memory usage
# TYPE nodejs_memory_usage_bytes gauge
nodejs_memory_usage_bytes{type="rss"} ${mem.rss}
nodejs_memory_usage_bytes{type="heapUsed"} ${mem.heapUsed}
nodejs_memory_usage_bytes{type="heapTotal"} ${mem.heapTotal}
nodejs_memory_usage_bytes{type="external"} ${mem.external}
`;
res.statusCode = 200;
res.setHeader("Content-Type", "text/plain");
res.end(metrics);
}
// main server
const server = http.createServer((req, res) => {
requestCount++;
const timestamp = new Date().toISOString();
const { pathname } = url.parse(req.url, true);
// logging
console.log(
`${timestamp} - ${req.method} ${pathname} - ${
req.headers["user-agent"] || "Unknown"
}`
);
// CORS headers
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
// security headers
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("X-Frame-Options", "DENY");
res.setHeader("X-XSS-Protection", "1; mode=block");
// route handling
switch (pathname) {
case "/":
sendHTML(
res,
200,
`
<!DOCTYPE html>
<html>
<head>
<title>DevOps Lab 2025</title>
<style>
body { font-family: Arial, sans-serif; max-width: 800px; margin: 50px auto; padding: 20px; }
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; border-radius: 8px; }
.endpoint { background: #f8f9fa; padding: 15px; margin: 10px 0; border-radius: 5px; border-left: 4px solid #007bff; }
</style>
</head>
<body>
<div class="header">
<h1>DevOps Lab 2025</h1>
<p>Modern Node.js application with CI/CD pipeline</p>
</div>
<h2>Available Endpoints:</h2>
<div class="endpoint"><strong>GET /</strong> - This welcome page</div>
<div class="endpoint"><strong>GET /health</strong> - Health check (JSON)</div>
<div class="endpoint"><strong>GET /info</strong> - System information</div>
<div class="endpoint"><strong>GET /metrics</strong> - Prometheus metrics</div>
<p>Environment: <strong>${ENVIRONMENT}</strong></p>
<p>Server time: <strong>${timestamp}</strong></p>
<p>Requests served: <strong>${requestCount}</strong> </p>
</body>
</html>`
);
break;
case "/health":
sendJSON(res, 200, {
status: "healthy",
timestamp,
uptime: process.uptime(),
environment: ENVIRONMENT,
version: "1.0.0",
node_version: process.version,
requests_served: requestCount,
});
break;
case "/info":
sendJSON(res, 200, {
platform: process.platform,
architecture: process.arch,
node_version: process.version,
memory_usage: process.memoryUsage(),
environment: ENVIRONMENT,
pid: process.pid,
uptime: process.uptime(),
});
break;
case "/metrics":
sendMetrics(res);
break;
default:
sendJSON(res, 404, {
error: "Not Found",
message: `Route ${pathname} not found`,
timestamp,
});
}
});
// graceful shutdown
function shutdown(signal) {
console.log(`\nReceived ${signal}, shutting down gracefully...`);
server.close(() => {
console.log("Server closed");
process.exit(0);
});
}
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
// start server
server.listen(PORT, () => {
console.log(`π Server running at http://localhost:${PORT}/`);
console.log(`Environment: ${ENVIRONMENT}`);
console.log(`Node.js version: ${process.version}`);
});
// export for testing
module.exports = server;
Install Dependencies
# Install testing and development tools
npm install --save-dev jest eslint supertest
# Install all dependencies (creates node_modules folder)
npm install
npm install --save-dev jest eslint supertest installs development-only tools for testing (Jest), code quality enforcement (ESLint), and HTTP/API testing (Supertest), and records them under devDependencies.
npm install installs all dependencies defined in package.json (both dependencies and devDependencies), creates the node_modules directory, and ensures version consistency using package-lock.json.
After running the code, you'll see:
- A node_modules/ folder with all installed packages
- A package-lock.json file that locks dependency versions
Step 3: Create Proper Tests
This step sets up automated testing so you can verify your application works correctly every time you make changes.
Create tests directory and test file
# Create a folder for your tests
mkdir tests
# Create the main test file
touch tests/app.test.js
Create test file
Copy this code into already created tests/app.test.js:
const request = require('supertest');
const server = require('../app');
describe('App Endpoints', () => {
afterAll(() => {
server.close();
});
test('GET / should return welcome page', async () => {
const response = await request(server).get('/');
expect(response.status).toBe(200);
expect(response.text).toContain('DevOps Lab 2025');
});
test('GET /health should return health status', async () => {
const response = await request(server).get('/health');
expect(response.status).toBe(200);
expect(response.body.status).toBe('healthy');
expect(response.body.timestamp).toBeDefined();
expect(typeof response.body.uptime).toBe('number');
});
test('GET /info should return system info', async () => {
const response = await request(server).get('/info');
expect(response.status).toBe(200);
expect(response.body.platform).toBeDefined();
expect(response.body.node_version).toBeDefined();
});
test('GET /metrics should return prometheus metrics', async () => {
const response = await request(server).get('/metrics');
expect(response.status).toBe(200);
expect(response.text).toContain('http_requests_total');
expect(response.text).toContain('app_uptime_seconds');
});
test('GET /nonexistent should return 404', async () => {
const response = await request(server).get('/nonexistent');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Not Found');
});
});
Create Jest configuration
Create a jest.config.js file, add the required configuration content, and save the file.
module.exports = {
testEnvironment: 'node',
collectCoverage: true,
coverageDirectory: 'coverage',
testMatch: ['**/tests/**/*.test.js'],
verbose: true
};
Step 4: GitHub Actions CI/CD Pipeline
This Creates an automated pipeline that runs tests and builds Docker images every time you push code to GitHub.
Create workflow directory
The .github/workflows directory is the local repository path where GitHub Actions workflows are defined and executed to manage CI/CD processes.
# Create the GitHub Actions directory structure
mkdir -p .github/workflows
Create CI/CD pipeline file
Create .github/workflows/ci.yml:
Now add the required configuration content and save the file.
name: CI/CD Pipeline
on:
push:
branches: [ main, develop ]
tags: [ 'v*' ]
pull_request:
branches: [ main ]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: Test & Lint
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20, 22]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linting
run: npm run lint
- name: Run tests
run: npm test
- name: Security audit
run: npm audit --audit-level=critical || echo "Audit completed with warnings"
build:
name: Build & Push Image
runs-on: ubuntu-latest
needs: test
if: github.event_name == 'push'
permissions:
contents: read
packages: write
security-events: write
outputs:
image-tag: ${{ steps.meta.outputs.tags }}
image-digest: ${{ steps.build.outputs.digest }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
platforms: linux/amd64,linux/arm64
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix={{branch}}-
type=raw,value=${{ github.run_id }}
type=raw,value=latest,enable={{is_default_branch}}
labels: |
org.opencontainers.image.title=DevOps Lab 2025
org.opencontainers.image.description=Modern Node.js DevOps application
- name: Build and push Docker image
id: build
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
target: production
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@0.24.0
with:
image-ref: ${{ steps.meta.outputs.tags }}
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
continue-on-error: true
- name: Upload Trivy scan results
uses: github/codeql-action/upload-sarif@v3
if: always() && hashFiles('trivy-results.sarif') != ''
with:
sarif_file: 'trivy-results.sarif'
deploy-staging:
name: Deploy to Staging
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/develop'
environment: staging
steps:
- name: Deploy to Staging
run: |
echo "π Deploying to staging environment..."
echo "Image: ${{ needs.build.outputs.image-tag }}"
echo "Digest: ${{ needs.build.outputs.image-digest }}"
# Add your staging deployment commands here (kubectl, helm, etc.)
deploy-production:
name: Deploy to Production
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main'
environment: production
steps:
- name: Deploy to Production
run: |
echo "π― Deploying to production environment..."
echo "Image: ${{ needs.build.outputs.image-tag }}"
echo "Digest: ${{ needs.build.outputs.image-digest }}"
# Add your production deployment commands here
Step 5: Dockerfile
This step defines the build instructions Docker uses to create a portable container image of the application, ensuring consistent execution across environments.
The Dockerfile performs the following functions:
- Uses multi-stage builds for smaller image size
- Installs curl for health checks
- Creates a non-root user for security
- Sets up proper file permissions
- Configures health checks
Create Dockerfile:
Input these commands into the Dockerfile
# Multi-stage build for optimized image
FROM node:20-alpine AS dependencies
# Update packages for security
RUN apk update && apk upgrade --no-cache
WORKDIR /app
# Copy package files first for better caching
COPY package*.json ./
# Install only production dependencies
RUN npm ci --only=production && npm cache clean --force
# Production stage
FROM node:20-alpine AS production
# Update packages and install necessary tools
RUN apk update && apk upgrade --no-cache && \
apk add --no-cache curl dumb-init && \
rm -rf /var/cache/apk/*
# Create non-root user with proper permissions
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodeuser -u 1001 -G nodejs
WORKDIR /app
# Copy dependencies from previous stage with proper ownership
COPY --from=dependencies --chown=nodeuser:nodejs /app/node_modules ./node_modules
# Copy application code with proper ownership
COPY --chown=nodeuser:nodejs package*.json ./
COPY --chown=nodeuser:nodejs app.js ./
# Switch to non-root user
USER nodeuser
# Expose port
EXPOSE 3000
# Health check with proper timing for Node.js startup
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
# Use dumb-init for proper signal handling in containers
ENTRYPOINT ["dumb-init", "--"]
# Start application
CMD ["npm", "start"]
Step 6: Essential Configuration Files
This creates configuration files that tell various tools what to ignore, how to behave, and what settings to use.
Create .dockerignore
Create .dockerignore:
Input these commands into .dockerignore and save.
# =========================
# Dependencies & Package Managers
# =========================
node_modules
npm-debug.log*
coverage
.nyc_output
# =========================
# Environment Variables
# =========================
.env
.env.local
.env.*.local
# =========================
# Version Control
# =========================
.git
.github
# =========================
# Logs
# =========================
logs
*.log
# =========================
# Editor & IDE Files
# =========================
.vscode
.idea
*.swp
*.swo
# =========================
# OS Files
# =========================
.DS_Store
Thumbs.db
# =========================
# Testing & Tooling
# =========================
tests/
jest.config.js
.eslintrc*
# =========================
# Documentation
# =========================
README.md
Create .gitignore
Create .gitignore:
Input these commands into already created .gitignore and save.
# ===============================
# Dependencies
# ===============================
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# ===============================
# Runtime Data
# ===============================
pids
*.pid
*.seed
*.pid.lock
# ===============================
# Coverage & Test Reports
# ===============================
coverage/
.nyc_output/
# ===============================
# Environment Variables
# ===============================
.env
.env.local
.env.*.local
# ===============================
# Logs
# ===============================
logs/
*.log
# ===============================
# IDE / Editor
# ===============================
.vscode/
.idea/
*.swp
*.swo
# ===============================
# OS Files
# ===============================
.DS_Store
Thumbs.db
Create environment template
Create .env.example:
Input these commands into already created .env.example and save
# ===============================
# Server Configuration
# ===============================
PORT=3000
NODE_ENV=production
# ===============================
# Logging Configuration
# ===============================
LOG_LEVEL=info
Create ESLint configuration
Create .eslintrc.js:
Input these commands into already created .eslintrc.js and save.
module.exports = {
env: {
node: true,
es2021: true,
jest: true
},
extends: ['eslint:recommended'],
parserOptions: {
ecmaVersion: 12,
sourceType: 'module'
},
rules: {
'no-console': 'off',
'no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }]
}
};
Step 7: Docker Compose for Development
This creates a Docker Compose file that makes it easy to run your application and any supporting services with a single command.
Create docker-compose.yml:
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=development
- PORT=3000
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
Step 8: Test Everything Locally
This shows you how to actually run and test your application locally before deploying it.
Install and Test Locally
# Install all dependencies from package.json
npm install
# Run your test suite to make sure everything works
npm test
# Start the application server
npm start
- Tests should pass with green checkmarks: β GET / should return welcome page
- Server starts and shows: π Server running at http://localhost:3000/
Test endpoints (in a new terminal window):
curl http://localhost:3000/ # Homepage
curl http://localhost:3000/health # Health check JSON
curl http://localhost:3000/info # System info JSON
curl http://localhost:3000/metrics # Prometheus metrics
Docker Commands
# Build image
docker build -t my-devops-app:latest .
# Run container
docker run -d \
--name my-devops-container \
-p 3000:3000 \
--restart unless-stopped \
my-devops-app:latest
# Check container status
docker ps
docker logs my-devops-container
# Test health check
curl http://localhost:3000/health
# Stop container
docker stop my-devops-container
docker rm my-devops-container
Build image
Docker Compose Commands
Docker compose is not used to test in production environment but locally.
# Start all services defined in docker-compose.yml
docker-compose up -d
# View real-time logs from all services
docker-compose logs -f
# Stop all services and clean up
docker-compose down
Start all services defined in docker-compose.yml

View real-time logs from all services

Stop all services and clean up

Step 9: Deploy to GitHub
This commits your code to Git and pushes it to GitHub so the automated CI/CD pipeline can start working.
Initial commit
# Add all files to Git staging area
git add .
# Create your first commit with a descriptive message
git commit -m "Initial commit: Complete DevOps setup with working CI/CD"
Connect to GitHub
Before running these commands:
- Go to GitHub.com and create a new repository called my-devops-project
- DO NOT initialize it with README, .gitignore, or license (we already have these)
- Copy the repository URL from GitHub
-
Replace YOUR_GITHUB_USERNAME below with your actual GitHub username
# Set main as the default branch git branch -M main # Connect to your GitHub repository (UPDATE THIS URL!) git remote add origin https://github.com/YOUR_GITHUB_USERNAME/my-devops-project.git # Push your code to GitHub for the first time git push -u origin main

Your code appears on GitHub, and the CI/CD pipeline starts running automatically.

Step 10: Kubernetes Deployment Configurations
This step creates Kubernetes configuration files that define how your application should run in staging and production environments.
Create directories
# Create directories for Kubernetes configurations
mkdir -p k8s/staging k8s/production
Create Staging Deployment
Create k8s/staging/deployment.yml
apiVersion: apps/v1
kind: Deployment
metadata:
name: devops-app-staging
namespace: staging
spec:
replicas: 2
selector:
matchLabels:
app: devops-app
environment: staging
template:
metadata:
labels:
app: devops-app
environment: staging
spec:
containers:
- name: app
image: ghcr.io/YOUR_GITHUB_USERNAME/my-devops-project:develop-latest
ports:
- containerPort: 3000
env:
- name: NODE_ENV
value: "staging"
- name: PORT
value: "3000"
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: devops-app-service-staging
namespace: staging
spec:
selector:
app: devops-app
environment: staging
ports:
- protocol: TCP
port: 80
targetPort: 3000
type: LoadBalancer
Create Production Deployment
Create k8s/production/deployment.yml
apiVersion: apps/v1
kind: Deployment
metadata:
name: devops-app-production
namespace: production
spec:
replicas: 3
selector:
matchLabels:
app: devops-app
environment: production
template:
metadata:
labels:
app: devops-app
environment: production
spec:
containers:
- name: app
image: ghcr.io/YOUR_GITHUB_USERNAME/my-devops-project:latest
ports:
- containerPort: 3000
env:
- name: NODE_ENV
value: "production"
- name: PORT
value: "3000"
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "200m"
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: devops-app-service-production
namespace: production
spec:
selector:
app: devops-app
environment: production
ports:
- protocol: TCP
port: 80
targetPort: 3000
type: LoadBalancer
Step 11: Complete Deployment Workflow
This step shows you how to use the complete CI/CD pipeline with proper branching strategy for staging and production deployments.
Branch-based Deployment Strategy
develop branch β Automatically deploys to staging environment
main branch β Automatically deploys to production environment
Pull requests β Run tests only (no deployment)
Deploy Changes
Deploy to staging:
# Create and switch to develop branch
git checkout -b develop
# Make your changes, then commit and push
git add .
git status
git commit -m "Add new feature"
git push origin develop

GitHub Actions automatically runs tests and deploys to staging.
Deploy to production:
# Switch to main branch
git checkout main
# Merge changes from develop
git merge develop
# Push to trigger production deployment
git push origin main
Monitor Deployments
You can execute this command to monitor the GitHub Actions workflow execution and verify the health status of the deployed application.
# Check GitHub Actions status
# Visit: https://github.com/YOUR_GITHUB_USERNAME/my-devops-project/actions
# Check your container registry
# Visit: https://github.com/YOUR_GITHUB_USERNAME/my-devops-project/pkgs/container/my-devops-project
# Test your deployed applications (once you have URLs)
curl https://your-staging-url.com/health # Staging health check
curl https://your-production-url.com/health # Production health check
This project implements a production grade CI/CD pipeline for a containerized Node.js application using GitHub Actions, Docker, and Kubernetes, demonstrating end-to-end DevOps engineering practices. The application exposes health, metrics, and system endpoints with graceful shutdown handling for Kubernetes environments.














































Top comments (0)