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-artifactandactions/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 Settings → Environments (in the left sidebar under “Code and automation”)
- Click New environment
- Name it
stagingand 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 Settings → Environments
- Click New environment
- Name it
productionand 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 tags → Selected 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 │ │ │
└────┬─────┘ └─────┬──────┘ └────┬────┘
│