Have you ever built a cool Node.js app on your local machine and thought, “Now what? How do I get this running in the cloud without manually pushing code every time?” That’s exactly where CI/CD (Continuous Integration and Continuous Deployment) comes to the rescue.
In this article, I’ll walk you through how I took a simple Node.js application from code to cloud using GitHub Actions, Docker, Kubernetes, and Azure Web App Services. We’ll set up a seamless pipeline that automatically builds, tests, and deploys changes the moment you push to GitHub—no more manual deployments, no more “it works on my machine” headaches.
By the end, you’ll see how easy it is to:
- Containerize your Node.js app with Docker 🐳
- Orchestrate deployments with Kubernetes ⚙️
- Automate CI/CD using GitHub Actions 🤖
- Deploy effortlessly to Azure Web Apps ☁️
Let’s dive in and turn your local project into a production-ready cloud app!
Prerequisites - Download These First!
Before starting, you need to install these tools on your machine:
- Node.js (v18 or higher) – Download (choose LTS version 20.x)
node --version
npm --version
- Git – Download
git --version
- Docker Desktop – Download
docker --version
docker-compose --version
- GitHub Account – Sign up here
- Code Editor (optional, recommended) – VS Code
Step 1: Set Up Git for Version Control
Configure Git:
git config --global user.name "Your Name"
git config --global user.email "you@example.com"
git config --global init.defaultBranch main
Output of command in VScode terminal:
Initialize your project:
mkdir my-devops-project
cd my-devops-project
git init
Output of command in VScode terminal:
Step 2: Build a Node.js Web App
Initialize project:
# Create package.json with default settings
npm init -y
Output of command in VScode terminal:
Update package.json with scripts, metadata, and dev tools (Jest
, ESLint
, Supertest
).
{
"name": "my-devops-project",
"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": "Your Name",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"devDependencies": {
"jest": "^29.7.0",
"eslint": "^8.57.0",
"supertest": "^7.1.4"
}
}
Then create app.js
with routes for /
, /health
, /info
, /metrics
, and graceful shutdown logic.
# Creates file
touch app.js
Copy and paste this into app.js
file:
// 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>Fullstack DevOps Lab 2025</h1>
<p>Modern Node.js application with CI/CD pipeline</p>
<p>Version: 1.0.0</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 dev tools
npm install --save-dev jest eslint supertest
# Install all dependencies
npm install
Output of command in VScode terminal:
Output of command in VScode terminal:
Step 3: Create Proper Tests
We are going to setup an automated testing that verify the application works correctly everytime a change is made.
Create a test directory and a test file tests/app.test.js
# Create a folder for your tests
mkdir tests
# Create the main test file
touch tests/app.test.js
Output of command in VScode terminal:
Copy and paste the code below into 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
Jest is a JavaScript testing framework developed by Facebook (now Meta). It’s widely used in Node.js and React projects for testing code.
Create a jest.config.js
file in the root folder for configuration.
touch jest.config.js
Output of command in VScode terminal:
Copy and paste the code below in the jest.config.js
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 .github/workflows/ci.yml
to:
- Run tests and linting
- Build & push Docker images to GitHub Container Registry
- Run vulnerability scans
- Deploy to staging/production based on branch
# Create the GitHub Actions directory structure
mkdir -p .github/workflows
# Create a ci.yml in .github/workflows directory
touch .github/workflows/ci.yml
Output of command in VScode terminal:
name: CI Pipeline
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
env:
NODE_VERSION: '20'
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
test:
name: Run Tests
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20]
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: npx eslint . --ext .js --ignore-pattern node_modules/
continue-on-error: true
- name: Run tests
run: npm test
- name: Run security audit
run: npm audit --audit-level=moderate || true
build:
name: Build Docker Image
runs-on: ubuntu-latest
needs: test
if: github.event_name == 'push'
permissions:
contents: read
packages: write
outputs:
image-digest: ${{ steps.build.outputs.digest }}
image-tag: ${{ steps.meta.outputs.tags }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- 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=sha,prefix={{branch}}-
type=raw,value=latest,enable={{is_default_branch}}
- 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
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 "Would deploy to staging server here"
# In real scenario, you'd use:
# - kubectl apply -f k8s/staging/
# - docker-compose -f docker-compose.staging.yml up -d
# - ssh staging-server "docker pull $IMAGE && docker-compose up -d"
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 "Image digest: ${{ needs.build.outputs.image-digest }}"
echo "Would deploy to production server here"
# In real scenario, you'd use:
# - kubectl apply -f k8s/production/
# - terraform apply
# - ansible-playbook deploy.yml
security-scan:
name: Security Scan
runs-on: ubuntu-latest
needs: build
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: 'ghcr.io/YOU_GITHUB_USERNAME/my-devops-project:latest' #All in lower case.
format: 'sarif'
output: 'trivy-results.sarif'
# env:
# TRIVY_USERNAME: ${{ github.actor }}
# TRIVY_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
Note: You'll need to replace YOU_GITHUB_USERNAME
with your GitHub username.
Step 5: Dockerfile
This creates instructions for Docker to build a container image of your application that can run on any machine.
Create a multi-stage Dockerfile that:
- Installs dependencies
- Uses non-root user for security
- Adds health checks
- Exposes port 3000
#Create a Dockerfile
touch Dockerfile
Copy and paste the codes below into 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
Lets creates configuration files that tell various tools what to ignore, how to behave, and what settings to use.
Create the following:
.dockerignore
.gitignore
.env.example
.eslintrc.js
Output of command in VScode terminal:
Create .dockerignore
, copy and paste the code below:
node_modules
npm-debug
.log*
.git
.github
.env
.env.local
.env.*.local
logs
*.log
coverage
.nyc_output
.vscode
.idea
*.swp
*.swo
.DS_Store
Thumbs.db
README.md
tests/
jest.config.js
.eslintrc*
Create .gitignore
, copy and paste the code below:
# Dependencies
node_modules/
npm-debug.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage
coverage/
.nyc_output
# Environment variables
.env
.env.local
.env.*.local
# Logs
logs
*.log
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
Create .env.example
, copy and paste the code below:
# Server
Configuration PORT=3000
NODE_ENV=production
# Logging
LOG_LEVEL=info
Create .eslintrc.js
, copy and paste the code below:
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
Now let's create a Docker Compose file that makes it easy to run your application and any supporting services with a single command.
# Create a docker-compose.yml file
touch docker-compose.yml
Create docker-compose.yml
to run the app with, copy and paste the code below:
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 demonstrates how to run and test your application locally before deploying it.
Run locally:
# Run your test suite to make sure everything works
npm test
Output of command in VScode terminal:
You should see all 5 tests pass successfully:
This starts the app in your local environment:
# Start the application server
npm start
What you'll see:
- Server starts running at http://localhost:3000/
Your browser should give this output:
Test endpoints (using terminal windows)
---
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:
Before running the Docker commands, ensure you've launched your Docker Desktop application.
This command builds the Docker image:
# Build image
docker build -t my-devops-app:latest .
Output of command in VScode terminal:
This shows the newly built container image my-devops-app:latest in Docker Desktop:
# Run container
docker run --name my-devops-container -d -p 3000:3000 --restart unless-stopped my-devops-app:latest
Output of command in VScode terminal:
This shows the running container (my-devops-container) on Docker Desktop:
From your browser (http://localhost:3000), you should see this:
# Check container status
docker ps
# Test health check in terminal
curl http://localhost:3000/health
# Stop container
docker stop my-devops-container
# Delete Container
docker rm my-devops-container
Output of command in VScode terminal:
Docker Compose Commands
Run with Docker Compose:
# Start all services defined in docker-compose.yml
docker-compose up -d
Output of command in VScode terminal:
After running the above syntax, it also runs the application and displays it in the browser via http://localhost:3000
# Stop all services and clean up
docker-compose down
Output of command in VScode terminal:
Step 9: Deploy to GitHub
We'll now commit the code and push it to GitHub, allowing the automated CI pipeline to begin processing.
# 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"
Output of command in VScode terminal:
IMPORTANT: Before running these commands:
- Go to GitHub.com and create a new repository, give it a name of your choice. I called mine "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
Output of command in VScode terminal:
# Push your code to GitHub for the first time
git push -u origin main
Output of command in VScode terminal:
What you'll see: Your code appears on GitHub, and the CI/CD pipeline starts running automatically.
Step 10: Deploy to Azure Web Apps via Azure Portal
We’ve built our Node.js app, containerized it with Docker, and set up GitHub Actions for CI/CD. Now it’s time to take it live by deploying to Azure Web Apps, a fully managed Platform-as-a-Service (PaaS) that runs your app in the cloud without you managing servers or clusters.
Why Azure Web Apps?
Zero server management → Microsoft handles scaling, updates, and runtime.
CI/CD ready → GitHub Actions can publish new versions automatically.
Scalable → Apps scale up or out based on load.
Secure & monitored → Logs, metrics, and diagnostics included by default.
Steps in the Azure Portal
- Log in to Azure Portal
-
Go to Azure Portal and sign in with your Azure account.
- Create a Resource Group
In the left-hand menu, search for Resource Groups.
Click Create → provide a name (e.g., my-devops-app-rg) and select a region (preferably one close to you).
- Create an App Service
- From the menu, search for App Services → click Create and select web app.
Fill in:
Resource Group: my-devops-app-rg
App Name: my-devops-lab-app (must be unique)
Publish: Code
Operating System: Linux
Region: Choose the same as your resource group
Pricing Plan: Start with B1 (Basic) for testing
Configure Deployment
In the Deployment tab, enable:
Option: Continuous deployment
Select Authorize and connect your GitHub account with the Azure App Service to enable access to your repository.
Select the following details:
Organization: your GitHub account username
Repository: your repository name
Branch: your branch main (e.g main or master)
Review + Create
Click Review + Create and wait for Azure to provision the service.
CI/CD Integration from Portal
Azure Portal can automatically create a GitHub Actions workflow for you:
Azure will generate a workflow file in .github/workflows/
This workflow builds your app and redeploys automatically whenever you push to GitHub
Select Actions to see the pipeline running:
Below depicts the Continuous Integration (CI) pipeline phase:
This depicts the Continuous Deployment (CD) pipeline phase:
Access Your App
This indicates our web app service has been successfully deployed. Select Go to Resource
Select Browse to access the application in your browser
This is the application live in the browser:
Wrapping Up
You just built a complete CI/CD pipeline for a Node.js application:
- Automated builds & tests with GitHub Actions
- Containerization with Docker
- Local orchestration with Docker Compose
- Cloud hosting on Azure Web Apps
This workflow eliminates manual deployments, ensures code quality, and makes your app production-ready. 🚀
Would you like me to also add screenshots and diagrams (pipeline flow, Kubernetes architecture) to make the Dev.to article more visually engaging?
Top comments (0)