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)