44. CI/CD Pipeline
About this chapter
Automate your release process using CI/CD pipelines to build, test, containerize, and deploy your API automatically on every code push.
- CI/CD fundamentals: Continuous integration and continuous deployment benefits
- GitHub Actions: Building workflows triggered by code changes
- Build automation: Compiling and building applications automatically
- Test automation: Running tests as part of the pipeline
- Docker integration: Building and pushing container images
- Deployment automation: Automatically deploying to staging and production
Learning outcomes:
- Understand CI/CD concepts and benefits
- Create GitHub Actions workflows
- Automate builds and tests
- Build and push Docker images in CI/CD
- Deploy automatically to environments
- Handle secrets securely in pipelines
44.1 Why CI/CD?
Without CI/CD (manual process):
Developer pushes code
↓
Manual: Build locally
↓
Manual: Run tests locally
↓
Manual: Push to registry
↓
Manual: Deploy to server
↓
Manual: Verify in production
↓
Something breaks? Repeat manually
Slow, error-prone, inconsistent.
With CI/CD (automated pipeline):
Developer pushes code
↓ (automatic)
Build
↓ (automatic)
Run tests
↓ (automatic)
Run code quality checks
↓ (automatic)
Build Docker image
↓ (automatic)
Push to registry
↓ (automatic)
Deploy to staging
↓ (automatic)
Run smoke tests
↓ (automatic)
Deploy to production
↓ (automatic)
Notify team of success
Fast, consistent, reliable. Pipelines catch issues before they reach production.
Benefits:
- Speed: 5-minute deploy vs 1-hour manual process
- Consistency: Same steps every time
- Reliability: Tests catch bugs before production
- Traceability: Every deployment tracked and audited
- Scalability: Deploy multiple services simultaneously
- Confidence: Automated checks increase confidence in code quality
44.2 GitHub Actions Overview
GitHub Actions is GitHub’s built-in CI/CD platform.
How it works:
.github/workflows/ (YAML files defining pipelines)
├── build.yml (build and test on push)
├── deploy-staging.yml (deploy to staging)
└── deploy-prod.yml (deploy to production)
When you push to GitHub, workflows run automatically.
Pricing:
- Free tier: 2,000 actions minutes/month (plenty for small projects)
- Paid: $0.008/minute after free tier
44.3 Build and Test Pipeline
Create workflow file:
.github/workflows/build.yml
Workflow content:
name: Build and Test
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
build:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: commandapi_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: testpass
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
# Check out code
- name: Checkout code
uses: actions/checkout@v4
# Setup .NET
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0'
# Restore dependencies
- name: Restore dependencies
run: dotnet restore
# Build project
- name: Build
run: dotnet build --configuration Release --no-restore
# Run unit tests
- name: Run unit tests
run: dotnet test CommandAPI.Tests/CommandAPI.Tests.csproj \
--configuration Release \
--no-build \
--logger "trx;LogFileName=test-results.trx"
env:
ConnectionStrings__PostgreSqlConnection: >-
Host=localhost;
Database=commandapi_test;
Username=postgres;
Password=testpass
# Run integration tests
- name: Run integration tests
run: dotnet test CommandAPI.IntegrationTests/CommandAPI.IntegrationTests.csproj \
--configuration Release \
--no-build \
--logger "trx;LogFileName=integration-test-results.trx"
env:
ConnectionStrings__PostgreSqlConnection: >-
Host=localhost;
Database=commandapi_test;
Username=postgres;
Password=testpass
# Upload test results
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: test-results
path: '**/test-results.trx'
# Code coverage
- name: Generate code coverage
run: dotnet test CommandAPI.Tests/CommandAPI.Tests.csproj \
--configuration Release \
/p:CollectCoverage=true \
/p:CoverageFormat=opencover \
/p:CoverageFileName=coverage.xml
# Upload coverage to Codecov
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
files: './coverage.xml'
fail_ci_if_error: false
# Notify on failure
- name: Notify on failure
if: failure()
run: echo "Build failed! Check the logs above."
44.4 Docker Build and Push
Build and push Docker image:
name: Build and Push Docker Image
on:
push:
branches: [main]
release:
types: [published]
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
# Set up Docker Buildx for multi-platform builds
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
# Login to Docker Hub
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
# Get version from git tag or use timestamp
- name: Get version
id: version
run: |
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
else
VERSION=$(date +%Y%m%d-%H%M%S)
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
# Build and push Docker image
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
context: .
push: true
tags: |
${{ secrets.DOCKER_USERNAME }}/commandapi:${{ steps.version.outputs.version }}
${{ secrets.DOCKER_USERNAME }}/commandapi:latest
cache-from: type=gha
cache-to: type=gha,mode=max
Set secrets in GitHub:
- Go to repository settings
- Secrets and variables → Actions
- Add secrets:
DOCKER_USERNAMEDOCKER_PASSWORD
44.5 Deployment to Staging
Deploy to staging environment:
name: Deploy to Staging
on:
push:
branches: [develop]
workflow_dispatch: # Allow manual trigger
jobs:
deploy-staging:
runs-on: ubuntu-latest
environment: staging
steps:
- name: Checkout code
uses: actions/checkout@v4
# Deploy to Azure App Service
- name: Deploy to Azure App Service
uses: azure/webapps-deploy@v2
with:
app-name: commandapi-staging
publish-profile: ${{ secrets.AZURE_PUBLISH_PROFILE_STAGING }}
package: '.'
# Or deploy Docker image
- name: Deploy Docker image to Azure Container Instances
uses: azure/container-instances-deploy-action@v1
with:
resource-group: myresourcegroup
name: commandapi-staging
image: ${{ secrets.REGISTRY_LOGIN_SERVER }}/commandapi:latest
registry-login-server: ${{ secrets.REGISTRY_LOGIN_SERVER }}
registry-username: ${{ secrets.REGISTRY_USERNAME }}
registry-password: ${{ secrets.REGISTRY_PASSWORD }}
environment-variables:
ASPNETCORE_ENVIRONMENT: Staging
ConnectionStrings__PostgreSqlConnection: ${{ secrets.STAGING_DB_CONNECTION }}
JwtSecret: ${{ secrets.STAGING_JWT_SECRET }}
# Smoke test
- name: Run smoke tests
run: |
sleep 10 # Wait for deployment
curl -f https://staging-api.example.com/health || exit 1
curl -f https://staging-api.example.com/api/commands || exit 1
# Notify Slack
- name: Notify Slack
if: success()
uses: slackapi/slack-github-action@v1
with:
webhook-url: ${{ secrets.SLACK_WEBHOOK }}
payload: |
{
"text": "✅ Deployed to staging: ${{ github.sha }}"
}
44.6 Production Deployment
Controlled production deployment:
name: Deploy to Production
on:
release:
types: [published]
workflow_dispatch: # Manual approval trigger
jobs:
deploy-production:
runs-on: ubuntu-latest
environment:
name: production
url: https://api.example.com
steps:
- name: Checkout code
uses: actions/checkout@v4
# Get release version
- name: Get version
id: version
run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
# Deploy with blue-green strategy
- name: Deploy to production (blue-green)
run: |
# Deploy new version as "green"
docker pull ${{ secrets.REGISTRY }}/commandapi:${{ steps.version.outputs.version }}
docker run -d \
--name commandapi-green \
-p 8081:8080 \
${{ secrets.REGISTRY }}/commandapi:${{ steps.version.outputs.version }}
# Test new version
sleep 5
curl -f http://localhost:8081/health || exit 1
# Switch traffic to new version
docker stop commandapi-blue
docker rename commandapi-green commandapi-blue
# Update old container
docker run -d \
--name commandapi-green \
-p 8082:8080 \
${{ secrets.REGISTRY }}/commandapi:latest
# Post-deployment validation
- name: Post-deployment health checks
run: |
for i in {1..30}; do
if curl -f https://api.example.com/health; then
echo "Health check passed"
exit 0
fi
echo "Waiting for API to be healthy..."
sleep 2
done
exit 1
# Notify success
- name: Notify deployment success
if: success()
uses: slackapi/slack-github-action@v1
with:
webhook-url: ${{ secrets.SLACK_WEBHOOK }}
payload: |
{
"text": "🚀 Production deployment successful: v${{ steps.version.outputs.version }}"
}
# Notify failure
- name: Notify deployment failure
if: failure()
uses: slackapi/slack-github-action@v1
with:
webhook-url: ${{ secrets.SLACK_WEBHOOK }}
payload: |
{
"text": "❌ Production deployment failed: v${{ steps.version.outputs.version }}"
}
44.7 Matrix Testing (Multiple Versions)
Test against multiple .NET versions:
name: Test Multiple .NET Versions
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
dotnet-version: ['9.0', '10.0']
steps:
- uses: actions/checkout@v4
- name: Setup .NET ${{ matrix.dotnet-version }}
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ matrix.dotnet-version }}
- name: Build and test
run: |
dotnet build
dotnet test
44.8 Conditional Steps
Run steps based on conditions:
steps:
# Only on main branch
- name: Deploy to production
if: github.ref == 'refs/heads/main'
run: ./deploy-prod.sh
# Only on pull request
- name: Comment on PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v6
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '✅ Build passed!'
})
# Only if previous step failed
- name: Notify team
if: failure()
run: echo "Pipeline failed!"
# On schedule (e.g., nightly builds)
- name: Nightly build
if: github.event_name == 'schedule'
run: dotnet build -c Release
44.9 Managing Secrets
Secrets in GitHub:
- Go to Settings → Secrets and variables → Actions
- Click “New repository secret”
- Add secrets (not visible in logs):
DOCKER_USERNAMEDOCKER_PASSWORDAZURE_PUBLISH_PROFILEJWT_SECRETDATABASE_PASSWORD
Use secrets in workflows:
env:
ConnectionStrings__PostgreSqlConnection: >-
Host=localhost;
Database=commandapi;
Username=postgres;
Password=${{ secrets.DB_PASSWORD }}
steps:
- name: Build
run: dotnet build
env:
API_KEY: ${{ secrets.API_KEY }}
Never log secrets:
# ❌ WRONG - Logs the secret
- name: Test
run: echo "API_KEY=${{ secrets.API_KEY }}"
# ✓ CORRECT - Doesn't log secret
- name: Test
run: dotnet test
env:
API_KEY: ${{ secrets.API_KEY }}
44.10 Deployment Strategies
Rolling deployment (no downtime):
steps:
- name: Deploy rolling
run: |
# Deploy 1 new instance while keeping old running
# Gradually replace old instances with new
# Traffic shifts gradually
Blue-green deployment (instant rollback):
Blue (current): Running on :8080
Green (new): Deploy to :8081, test
Switch: Route traffic to :8081
Rollback: Route back to :8080 if issues
Canary deployment (gradual rollout):
Send 10% traffic to new version
↓
Monitor metrics (errors, latency)
↓
If good, send 25% traffic
↓
If good, send 50% traffic
↓
If good, send 100% traffic
↓
If issues, rollback to 0%
44.11 Monitoring and Alerts
Add health check monitoring:
name: Production Health Monitoring
on:
schedule:
- cron: '*/5 * * * *' # Every 5 minutes
jobs:
health-check:
runs-on: ubuntu-latest
steps:
- name: Check API health
run: |
curl -f https://api.example.com/health || exit 1
curl -f https://api.example.com/api/commands || exit 1
- name: Alert on failure
if: failure()
uses: slackapi/slack-github-action@v1
with:
webhook-url: ${{ secrets.SLACK_WEBHOOK }}
payload: |
{
"text": "🚨 Production API health check failed!"
}
44.12 Best Practices
1. Keep builds fast (< 10 minutes):
# Cache dependencies
- name: Cache NuGet packages
uses: actions/setup-dotnet@v4
with:
cache: true # Automatically caches .NET dependencies
2. Fail fast on critical issues:
# Run linting/analysis first (fast)
- name: Code quality
run: dotnet format --verify-no-changes
# Then run tests (slower)
- name: Tests
run: dotnet test
3. Test thoroughly before production:
Push to main
↓ Build + Test
↓ Deploy to staging
↓ Smoke tests
↓ Manual approval
↓ Deploy to production
4. Use environment protection:
In repository settings, require approval before deploying to production:
environment:
name: production
# Requires manual approval before running
5. Keep audit logs:
All deployments tracked and auditable:
# View deployment history in Actions tab
# See who deployed what, when, and why
44.13 What’s Next
You now have:
- ✓ Understanding CI/CD benefits
- ✓ GitHub Actions workflows
- ✓ Build and test pipeline
- ✓ Docker image build and push
- ✓ Deployment to staging
- ✓ Production deployment with blue-green strategy
- ✓ Matrix testing across versions
- ✓ Conditional steps and gates
- ✓ Secret management
- ✓ Deployment strategies (rolling, blue-green, canary)
- ✓ Health monitoring and alerts
- ✓ Best practices
Next: Advanced Topics—API versioning, real-time updates with SignalR, and beyond.