DEV Community

Cover image for DevOps by Doing: Setting Up a Complete Modern DevOps Environment — Part 2
John Ogbonna
John Ogbonna

Posted on

DevOps by Doing: Setting Up a Complete Modern DevOps Environment — Part 2

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 file

  • Setting up a .env.example for environment variables

  • Adding ESLint for code quality

  • Writing a Dockerfile to containerize our app

  • Creating a .dockerignore to slim images

  • Using 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
Enter fullscreen mode Exit fullscreen mode
  • Create ci.yml:
touch .github/workflows/ci.yml
Enter fullscreen mode Exit fullscreen mode
  • 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
Enter fullscreen mode Exit fullscreen mode

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

ci steps

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
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

Dockerfile Steps

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*
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Create .env.example

# Server Configuration
PORT=3000
NODE_ENV=production

# Logging
LOG_LEVEL=info
Enter fullscreen mode Exit fullscreen mode

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': '^_' }]
  }
};
Enter fullscreen mode Exit fullscreen mode

config files

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
Enter fullscreen mode Exit fullscreen mode

Start your app with:

docker-compose up --build
Enter fullscreen mode Exit fullscreen mode

(First stop the app if started with npm)

Stop it with:

docker-compose down
Enter fullscreen mode Exit fullscreen mode

docker

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
Enter fullscreen mode Exit fullscreen mode

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/

server running

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
Enter fullscreen mode Exit fullscreen mode

Results of test:
endpoint test

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

Create the Staging Environment in GitHub

  • Go to your repo Settings
    repo Settings

  • Go to Environments, click new environment.
    click new environment

  • Name it: staging

  • Save.
    staging

In VS Save and Commit Your Code Changes

# Stage changes
git add .github/workflows/ci.yml
Enter fullscreen mode Exit fullscreen mode
# Commit with a clear message
git commit -m "Fixed security scan to use build job image tag"
Enter fullscreen mode Exit fullscreen mode
# Push to GitHub
git push origin main
Enter fullscreen mode Exit fullscreen mode

push to github

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
Enter fullscreen mode Exit fullscreen mode
  • 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
Enter fullscreen mode Exit fullscreen mode
  • 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
Enter fullscreen mode Exit fullscreen mode

Update the k8s/production/deployment.yml as such
deployment.yml

✅ 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

production deployment

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
Enter fullscreen mode Exit fullscreen mode

🎯 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)