What this part covers
In Part 1, we built the foundation: Git setup, Node.js app, dependencies, and automated tests.
In Part 2, we’ll take the next big steps toward a production-ready setup by:
Creating a
.gitignore
fileSetting up a
.env.example
for environment variablesAdding
ESLint
for code qualityWriting a
Dockerfile
to containerize our appCreating a
.dockerignore
to slim imagesUsing Docker Compose for local development
Running & testing the app in Docker
Setting up GitHub repository + pushing code
Configuring GitHub Actions CI/CD pipeline for build + test
Deploying to GitHub (first automated deployment)
Monitoring build/deployment success
By the end of this article, you’ll have your project running in Docker, connected to GitHub, and building automatically via CI/CD.
Step 1: GitHub Actions CI/CD Pipeline
What this step does
We’re now moving from local development into automation. In this step, we’ll create a GitHub Actions pipeline that:
Runs linting, tests, and security audits on every push and pull request.
Builds and pushes Docker images to GitHub’s container registry (GHCR).
Runs a vulnerability scan against the built image.
Deploys automatically to staging (develop branch) and production (main branch).
This is the backbone of modern DevOps: CI/CD automation that ensures code is tested, packaged, secured, and ready for deployment — with no manual intervention required.
- Create workflow directory:
# Create the GitHub Actions directory structure
mkdir -p github/workflows
- Create ci.yml:
touch .github/workflows/ci.yml
- Create the pipeline definition
Inside .github/workflows/ci.yml
, add the following:
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
This setup:
- Ensures we only build/test on pushes to main or develop, pull requests to main, or tagged releases.
- Runs in Node 20 and 22 to validate across multiple versions
-
For the building and pushing of Docker Images, it only runs on push events and after tests pass it:
- Uses Buildx for multi-arch images (amd64 + arm64).
- Pushes to GitHub Container Registry.
- Adds labels and tags for traceability.
- Runs Trivy for vulnerability scanning.
-
Separates staging and production deployments by branch:
- develop → staging
- main → production
Why this matters
By the end of this step, you now have:
✅ Automated linting, tests, and audits on every change.
✅ Docker images built and pushed to GHCR automatically.
✅ Security scans integrated directly into CI/CD.
✅ Branch-based deployments ready for staging and production.
This is the minimum baseline for modern DevOps — continuous integration, containerized builds, automated deployments, and built-in security.
Step 2: Dockerfile
What this step does
The Dockerfile defines how to package your application into a container image that can run consistently anywhere — locally, in CI/CD pipelines, or in cloud environments.
In this step, we’ll create a production-ready Dockerfile that:
Uses multi-stage builds to keep images small.
Runs as a non-root user for security.
Sets up health checks for monitoring.
Includes signal handling for clean shutdowns.
This makes your app portable, secure, and reliable.
Create Dockerfile
Enter:
touch Dockerfile
In Dockerfile, Paste:
# 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"]
Dockerfile Explained
Dependencies Stage:
Based on node:20-alpine for a minimal footprint.
Installs only production dependencies.
Cleans up cache to keep size small.
Production Stage
Installs required tools (curl, dumb-init).
Creates a non-root user (nodeuser) with proper permissions.
Copies dependencies and app code with correct ownership.
Defines a health check endpoint (/health).
Uses dumb-init as the entrypoint for proper signal handling.
Why this matters
By containerizing your app with this Dockerfile, you gain:
✅ Consistency: Same environment across dev, CI/CD, and production.
✅ Security: Runs as non-root with patched Alpine base image.
✅ Reliability: Health checks ensure containers are restarted if unhealthy.
✅ Efficiency: Multi-stage builds keep images lean.
Step 3: Essential Configuration Files
What this step does
Configuration files are the unsung heroes of a DevOps project. They define what to ignore, how tools behave, and what settings your project expects.
In this step, we’ll set up:
.dockerignore
– to keep Docker images lean..gitignore
– to avoid committing unnecessary or sensitive files..env.example
– a template for environment variables..eslintrc.js
– to enforce coding standards.
Create .dockerignore
- This file ensures your Docker builds don’t copy unnecessary files into the image, making builds faster and images smaller
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
# 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
# Server Configuration
PORT=3000
NODE_ENV=production
# Logging
LOG_LEVEL=info
Create ESLint configuration:
create .eslintrc.js
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 4: Docker Compose for Development
What this step does
While a Dockerfile defines how to build your app’s container, Docker Compose makes it easy to run multiple services together (like your app + a database).
With one command, you can spin up your entire development environment.
- 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
Start your app with:
docker-compose up --build
(First stop the app if started with npm)
Stop it with:
docker-compose down
Step 4: Test Everything Locally
What this step does: 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
What you’ll see:
- 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
Use these to test your image and container directly (great for debugging fundamentals).
# 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
Docker Compose Commands
Use these for day-to-day development or when you have multiple services.
# 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
Step 5: Deploy to GitHub
What this step does: 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 a README, .gitignore, or license (we already created those).
Copy the repository URL from GitHub.
Replace YOUR_GITHUB_USERNAME in the commands below with your actual 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
What You’ll See
Your code appears in your new GitHub repository.
The CI/CD pipeline kicks in automatically (thanks to the GitHub Actions workflow you added earlier).
Step 6: Fix CI Security Scan Errors
The Problem:
When the pipeline first runs, the security scanner fails with this error:
failed to parse the image name: could not parse reference: ghcr.io/USERNAME/my-devops-project:latest
This happened because the workflow was hardcoding :latest
as the image tag. At the time of the scan, that tag didn’t exist yet in the GitHub Container Registry, so Trivy couldn’t find or parse the image reference.
The Fix:
Instead of scanning :latest, we need to scan the exact image tag that was built and pushed by the build job. GitHub Actions makes this available via the job output needs.build.outputs.image-tag.
Edit .github/workflows/ci.yml and replace the security-scan section with:
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: ${{ needs.build.outputs.image-tag }}
format: 'sarif'
output: 'trivy-results.sarif'
- name: Upload Trivy scan results
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: 'trivy-results.sarif'
Create the Staging Environment in GitHub
In VS Save and Commit Your Code Changes
# Stage changes
git add .github/workflows/ci.yml
# Commit with a clear message
git commit -m "Fixed security scan to use build job image tag"
# Push to GitHub
git push origin main
Once pushed, GitHub Actions will re-run the pipeline and Trivy will correctly scan the newly built image.
Step 7: Kubernetes Deployment Configurations
What this step does: Defines how your application should run in Kubernetes for both staging and production environments, including the number of replicas, resource limits, and health checks.
- Create directories
# Create directories for Kubernetes configurations
mkdir -p k8s/staging k8s/production
- Create Staging Deployment
Create k8s/staging/deployment.yml
:
⚠️ IMPORTANT: Update YOUR_GITHUB_USERNAME and YOUR_REPO_NAME
in the image URL.
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/YOUR_REPO_NAME: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
:
⚠️ IMPORTANT: Update YOUR_GITHUB_USERNAME
and YOUR_REPO_NAME
in the image URL.
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/YOUR_REPO_NAME: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
Update the k8s/production/deployment.yml
as such
✅ Key Notes:
Namespaces: staging and production isolate environments.
Replicas: Staging uses 2 replicas; production uses 3 for high availability.
Probes: livenessProbe and readinessProbe ensure containers are healthy before traffic is routed.
Resources (Production only): CPU/memory requests and limits prevent resource exhaustion.
Services: Both use LoadBalancer to expose the application externally.
Step 8: Complete Deployment Workflow
What this step does: Demonstrates the full CI/CD process with branch-based deployments to staging and production, along with monitoring.
1. Branch-based Deployment Strategy
Branch | Action |
---|---|
develop |
Automatically deploys to staging environment |
main |
Automatically deploys to production environment |
Pull requests | Run tests only (no deployment) |
2. 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 commit -m "Add new feature"
git push origin develop
What happens:
GitHub Actions automatically builds the image.
Runs security scans and tests.
Deploys the app to staging (staging namespace in Kubernetes).
3.Deploy to Production
When you’re ready to release:
# Switch to main branch
git checkout main
# Merge changes from develop
git merge develop
# Push to trigger production deployment
git push origin main
What happens:
GitHub Actions runs the full pipeline.
Deploys the app to production (default namespace in Kubernetes).
🔎 Monitor Deployments
Check GitHub Actions status
👉 https://github.com/YOUR_GITHUB_USERNAME/YOUR_REPO_NAME/actions
,
replace YOUR_GITHUB_USERNAME
and YOUR_REPO_NAME
accordingly
Check your container registry
👉https://github.com/YOUR_GITHUB_USERNAME/YOUR_REPO_NAME/pkgs/container/my-devops-project
replace YOUR_GITHUB_USERNAME
and YOUR_REPO_NAME
accordingly
Test your deployed applications
# Staging health check
curl https://your-staging-url.com/health
# Production health check
curl https://your-production-url.com/health
🎯 Conclusion
In this second part of DevOps by Doing, we took our project beyond the basics and built out a modern CI/CD pipeline with GitHub Actions, Docker, and Kubernetes.
*We covered how to: *
- ✅ Automate testing and linting across multiple Node.js versions
- ✅ Build and publish Docker images to GitHub Container Registry (GHCR)
- ✅ Deploy automatically to a staging environment from the
develop
branch - ✅ Secure production deployments on the
main
branch with environment protections
With these steps, you now have a complete DevOps workflow: from writing code, to testing, to building, to deploying in a controlled and repeatable way.
This isn’t just theory—it’s the exact type of pipeline used in modern production teams. By now, you should feel confident that every push to your repo can be tested, built, and deployed automatically. That’s the heart of DevOps by Doing.
Top comments (0)