How to Set Up a Multi-Stage CI/CD Pipeline with GitHub Actions: Build, Test, and Deploy with Environment Approvals

Intermediate

Why Multi-Stage Pipelines Matter in Real-World DevOps

Here’s something I’ve seen repeatedly across teams in my career: a single-job CI/CD pipeline that builds, tests, and deploys all in one shot. It works — until the day someone pushes a change that passes unit tests but breaks production, and there was no gate, no approval, and no way to catch it before customers did.

A multi-stage pipeline solves this by breaking your delivery process into discrete phases — build, test, deploy to staging, deploy to production — where each stage acts as a quality gate. Combined with GitHub Actions’ environment protection rules, you get human approval steps that prevent unreviewed code from hitting production. This is the foundation of how mature engineering teams ship software safely.

In this guide, we’ll build a complete multi-stage pipeline from scratch. By the end, you’ll have a working workflow that:

  • Builds and packages your application
  • Runs unit and integration tests as separate jobs
  • Deploys to a staging environment automatically
  • Requires manual approval before deploying to production
  • Passes artifacts between stages efficiently

Let’s build this step by step.

Prerequisites

Before we start, make sure you have:

  • A GitHub repository (public or private — environment protection rules are available on all public repos and on private repos with GitHub Team or Enterprise plans)
  • Basic familiarity with YAML syntax
  • A simple application to deploy (we’ll use a Node.js app as our example, but the pipeline concepts apply to any language)

Understanding GitHub Actions Workflow Structure

Before we write any YAML, let’s understand the key concepts that make multi-stage pipelines work in GitHub Actions:

  • Jobs: Independent units of work that run on separate runners. Each job gets a fresh virtual machine.
  • needs: A keyword that creates dependencies between jobs, defining the order they execute in.
  • Artifacts: Files passed between jobs using actions/upload-artifact and actions/download-artifact. Since each job runs on a fresh machine, this is how you share build outputs.
  • Environments: Named deployment targets (like “staging” or “production”) that can have protection rules, including required reviewers and wait timers.

Think of it this way: jobs are the stages, needs defines the pipeline flow, artifacts carry data between stages, and environments add safety gates.

Step 1: Set Up GitHub Environments with Protection Rules

First, we’ll configure the environments in your GitHub repository. This is done through the GitHub UI, not code.

To create the staging environment:

  • Go to your repository on GitHub
  • Click SettingsEnvironments (in the left sidebar under “Code and automation”)
  • Click New environment
  • Name it staging and click Configure environment
  • For staging, we’ll keep it simple — no approval required. Optionally, add a wait timer of 0 minutes.
  • Click Save protection rules

To create the production environment:

  • Go back to SettingsEnvironments
  • Click New environment
  • Name it production and click Configure environment
  • Check Required reviewers and add one or more GitHub users or teams who must approve deployments
  • Optionally, set a wait timer (e.g., 5 minutes) to add a buffer before deployment starts after approval
  • Optionally, restrict which branches can deploy by selecting Deployment branches and tagsSelected branches and tags and adding main
  • Click Save protection rules

That branch restriction is one of my favorite features — it means even if someone modifies the workflow file on a feature branch, they can’t deploy to production from it.

Step 2: Create the Sample Application

Let’s create a minimal Node.js application to work with. This gives us something real to build, test, and deploy.

package.json

{
  "name": "multistage-pipeline-demo",
  "version": "1.0.0",
  "description": "Demo app for multi-stage CI/CD pipeline",
  "main": "src/app.js",
  "scripts": {
    "start": "node src/app.js",
    "test": "jest --verbose",
    "test:integration": "jest --testPathPattern=integration --verbose",
    "lint": "eslint src/"
  },
  "devDependencies": {
    "eslint": "^9.0.0",
    "jest": "^29.7.0"
  }
}

src/app.js

const http = require('http');

function getHealthStatus() {
  return { status: 'healthy', timestamp: new Date().toISOString() };
}

function add(a, b) {
  return a + b;
}

const server = http.createServer((req, res) => {
  if (req.url === '/health') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify(getHealthStatus()));
  } else {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('Hello from multi-stage pipeline demo!');
  }
});

if (require.main === module) {
  const PORT = process.env.PORT || 3000;
  server.listen(PORT, () => console.log(`Server running on port ${PORT}`));
}

module.exports = { add, getHealthStatus };

tests/unit/app.test.js

const { add, getHealthStatus } = require('../../src/app');

describe('Unit Tests', () => {
  test('add function returns correct sum', () => {
    expect(add(2, 3)).toBe(5);
    expect(add(-1, 1)).toBe(0);
  });

  test('health status returns healthy', () => {
    const status = getHealthStatus();
    expect(status.status).toBe('healthy');
    expect(status.timestamp).toBeDefined();
  });
});

tests/integration/api.test.js

const http = require('http');
const { getHealthStatus } = require('../../src/app');

describe('Integration Tests', () => {
  test('health status returns valid JSON structure', () => {
    const result = getHealthStatus();
    expect(result).toHaveProperty('status');
    expect(result).toHaveProperty('timestamp');
    expect(typeof result.status).toBe('string');
  });
});

Step 3: Build the Multi-Stage Pipeline

Now for the main event. Create the workflow file at .github/workflows/ci-cd-pipeline.yml in your repository. I’ll present the complete file, then we’ll walk through each stage.

name: Multi-Stage CI/CD Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

permissions:
  contents: read

jobs:
  # ============================================
  # STAGE 1: Build and package the application
  # ============================================
  build:
    name: Build
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Build application
        run: |
          echo "Building application..."
          mkdir -p dist
          cp -r src dist/
          cp package.json dist/
          cp package-lock.json dist/
          echo "${{ github.sha }}" > dist/BUILD_SHA.txt
          echo "Build completed at $(date -u)" > dist/BUILD_INFO.txt

      - name: Upload build artifact
        uses: actions/upload-artifact@v4
        with:
          name: app-build
          path: dist/
          retention-days: 5

  # ============================================
  # STAGE 2a: Run unit tests
  # ============================================
  unit-tests:
    name: Unit Tests
    runs-on: ubuntu-latest
    needs: build

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run unit tests
        run: npm test

  # ============================================
  # STAGE 2b: Run integration tests
  # ============================================
  integration-tests:
    name: Integration Tests
    runs-on: ubuntu-latest
    needs: build

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run integration tests
        run: npm run test:integration

  # ============================================
  # STAGE 2c: Lint and static analysis
  # ============================================
  lint:
    name: Lint & Static Analysis
    runs-on: ubuntu-latest
    needs: build

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run linter
        run: npx eslint src/ || echo "Lint completed with warnings"

  # ============================================
  # STAGE 3: Deploy to Staging
  # ============================================
  deploy-staging:
    name: Deploy to Staging
    runs-on: ubuntu-latest
    needs: [unit-tests, integration-tests, lint]
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    environment:
      name: staging
      url: https://staging.example.com

    steps:
      - name: Download build artifact
        uses: actions/download-artifact@v4
        with:
          name: app-build
          path: dist/

      - name: Verify build artifact
        run: |
          echo "=== Build Info ==="
          cat dist/BUILD_INFO.txt
          echo "=== Build SHA ==="
          cat dist/BUILD_SHA.txt
          echo "=== Files ==="
          ls -la dist/

      - name: Deploy to staging
        run: |
          echo "Deploying to staging environment..."
          echo "Deploying commit: $(cat dist/BUILD_SHA.txt)"
          # Replace the lines below with your actual deployment commands.
          # Examples:
          #   aws s3 sync dist/ s3://my-staging-bucket/
          #   kubectl apply -f k8s/staging/
          #   rsync -avz dist/ user@staging-server:/app/
          echo "Staging deployment completed successfully!"

      - name: Run smoke tests against staging
        run: |
          echo "Running smoke tests against staging..."
          # Replace with actual health check:
          # curl --fail --retry 3 --retry-delay 5 https://staging.example.com/health
          echo "Smoke tests passed!"

  # ============================================
  # STAGE 4: Deploy to Production (requires approval)
  # ============================================
  deploy-production:
    name: Deploy to Production
    runs-on: ubuntu-latest
    needs: deploy-staging
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    environment:
      name: production
      url: https://www.example.com

    steps:
      - name: Download build artifact
        uses: actions/download-artifact@v4
        with:
          name: app-build
          path: dist/

      - name: Verify build artifact
        run: |
          echo "=== Verifying artifact integrity ==="
          echo "Expected SHA: ${{ github.sha }}"
          echo "Artifact SHA: $(cat dist/BUILD_SHA.txt)"
          if [ "$(cat dist/BUILD_SHA.txt)" != "${{ github.sha }}" ]; then
            echo "ERROR: Artifact SHA mismatch!"
            exit 1
          fi
          echo "Artifact integrity verified."

      - name: Deploy to production
        run: |
          echo "Deploying to production environment..."
          echo "Deploying commit: $(cat dist/BUILD_SHA.txt)"
          # Replace with your actual production deployment commands.
          # Examples:
          #   aws ecs update-service --cluster prod --service my-app --force-new-deployment
          #   kubectl --context production apply -f k8s/production/
          #   az webapp deploy --name my-app --src-path dist/
          echo "Production deployment completed successfully!"

      - name: Run production smoke tests
        run: |
          echo "Running production smoke tests..."
          # Replace with actual health check:
          # curl --fail --retry 5 --retry-delay 10 https://www.example.com/health
          echo "Production smoke tests passed!"

      - name: Notify team
        if: always()
        run: |
          if [ "${{ job.status }}" == "success" ]; then
            echo "✅ Production deployment successful for commit ${{ github.sha }}"
          else
            echo "❌ Production deployment failed for commit ${{ github.sha }}"
          fi
          # Add Slack/Teams notification here:
          # curl -X POST -H 'Content-type: application/json' \
          #   --data '{"text":"Deployment ${{ job.status }} for ${{ github.sha }}"}' \
          #   ${{ secrets.SLACK_WEBHOOK_URL }}

Step 4: Understanding the Pipeline Flow

Let’s visualize what happens when you push to main:

                    ┌──────────────┐
│ Build │
└──────┬───────┘

┌────────────┼────────────┐
▼ ▼ ▼
┌──────────┐ ┌────────────┐ ┌─────────┐
│Unit Tests│ │Integration │ │ Lint │
│ │ │ Tests │ │ │
└────┬─────┘ └─────┬──────┘ └────┬────┘

Leave a Comment

Your email address will not be published. Required fields are marked *