Building Robust CI/CD Pipelines with GitHub Actions and AWS
Continuous Integration and Continuous Deployment (CI/CD) is the backbone of modern software development. A well-designed pipeline automates the journey from code commit to production deployment, ensuring quality, consistency, and speed. In this comprehensive guide, I'll share how to build production-grade CI/CD pipelines using GitHub Actions and AWS.
Why GitHub Actions?
GitHub Actions has become my go-to CI/CD platform for several reasons:
- Native GitHub Integration: No context switching between code and pipelines
- Extensive Marketplace: Thousands of pre-built actions
- Matrix Builds: Test across multiple environments simultaneously
- Self-Hosted Runners: Run on your own infrastructure when needed
- Cost-Effective: Generous free tier for public and private repositories
- YAML-Based: Infrastructure as Code for your pipelines
Pipeline Architecture
Multi-Stage Pipeline Design
A robust CI/CD pipeline should have these stages:
āāāāāāāāāāāāāāā
ā Commit ā
āāāāāāāā¬āāāāāāā
ā
ā¼
āāāāāāāāāāāāāāā
ā Build ā ā Compile, lint, type-check
āāāāāāāā¬āāāāāāā
ā
ā¼
āāāāāāāāāāāāāāā
ā Test ā ā Unit, integration, security tests
āāāāāāāā¬āāāāāāā
ā
ā¼
āāāāāāāāāāāāāāā
ā Package ā ā Create artifacts, Docker images
āāāāāāāā¬āāāāāāā
ā
ā¼
āāāāāāāāāāāāāāā
ā Deploy ā ā Deploy to staging/production
āāāāāāāā¬āāāāāāā
ā
ā¼
āāāāāāāāāāāāāāā
ā Verify ā ā Smoke tests, health checks
āāāāāāāā¬āāāāāāā
ā
ā¼
āāāāāāāāāāāāāāā
ā Monitor ā ā Track metrics, alerts
āāāāāāāāāāāāāāā
GitHub Actions Workflow Implementation
Basic Structure
name: CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
workflow_dispatch: # Manual trigger
env:
NODE_VERSION: '20.x'
AWS_REGION: 'us-east-1'
jobs:
build:
name: Build and Test
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for better analysis
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install Dependencies
run: npm ci
- name: Run Linting
run: npm run lint
- name: Run Type Checking
run: npm run type-check
- name: Run Unit Tests
run: npm run test:unit -- --coverage
- name: Upload Coverage Reports
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
flags: unittests
Advanced Build Stage with Caching
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Cache Dependencies
uses: actions/cache@v3
with:
path: |
~/.npm
node_modules
.next/cache
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
- name: Install Dependencies
run: npm ci
- name: Build Application
run: npm run build
env:
NEXT_PUBLIC_API_URL: ${{ secrets.API_URL }}
NEXT_PUBLIC_ENV: production
- name: Upload Build Artifacts
uses: actions/upload-artifact@v3
with:
name: build-output
path: |
.next/
out/
retention-days: 7
Testing Strategy
Comprehensive Test Suite
test:
name: Test Suite
runs-on: ubuntu-latest
needs: build
strategy:
matrix:
test-type: [unit, integration, e2e]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
- name: Install Dependencies
run: npm ci
- name: Run ${{ matrix.test-type }} Tests
run: npm run test:${{ matrix.test-type }}
env:
TEST_ENV: ci
- name: Upload Test Results
if: always()
uses: actions/upload-artifact@v3
with:
name: test-results-${{ matrix.test-type }}
path: test-results/
Security Scanning
security:
name: Security Scanning
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Dependency Audit
run: npm audit --production --audit-level=high
- name: Snyk Security Scan
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --severity-threshold=high
- name: OWASP Dependency Check
uses: dependency-check/Dependency-Check_Action@main
with:
project: 'my-project'
path: '.'
format: 'HTML'
- name: Trivy Container Scan
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
format: 'sarif'
output: 'trivy-results.sarif'
- name: Upload Trivy Results to GitHub Security
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: 'trivy-results.sarif'
AWS Deployment Strategies
Static Site Deployment to S3 + CloudFront
deploy-production:
name: Deploy to Production
runs-on: ubuntu-latest
needs: [test, security]
if: github.ref == 'refs/heads/main'
environment:
name: production
url: https://www.example.com
steps:
- uses: actions/checkout@v4
- name: Download Build Artifacts
uses: actions/download-artifact@v3
with:
name: build-output
path: out/
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
role-session-name: GitHubActionsDeployment
- name: Sync to S3
run: |
aws s3 sync out/ s3://${{ secrets.S3_BUCKET }}/ \
--delete \
--cache-control "public, max-age=31536000, immutable" \
--exclude "*.html" \
--exclude "*.json"
# HTML files with shorter cache
aws s3 sync out/ s3://${{ secrets.S3_BUCKET }}/ \
--exclude "*" \
--include "*.html" \
--include "*.json" \
--cache-control "public, max-age=0, must-revalidate"
- name: Invalidate CloudFront Cache
run: |
aws cloudfront create-invalidation \
--distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \
--paths "/*"
- name: Deployment Summary
run: |
echo "### Deployment Successful! š" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- **Environment**: Production" >> $GITHUB_STEP_SUMMARY
echo "- **S3 Bucket**: ${{ secrets.S3_BUCKET }}" >> $GITHUB_STEP_SUMMARY
echo "- **CloudFront**: Invalidated" >> $GITHUB_STEP_SUMMARY
echo "- **URL**: https://www.example.com" >> $GITHUB_STEP_SUMMARY
Container Deployment to ECS
deploy-ecs:
name: Deploy to ECS
runs-on: ubuntu-latest
needs: [test, security]
steps:
- uses: actions/checkout@v4
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build Docker Image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
ECR_REPOSITORY: my-app
IMAGE_TAG: ${{ github.sha }}
run: |
docker build \
--build-arg NODE_ENV=production \
--build-arg BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') \
--build-arg VCS_REF=${{ github.sha }} \
-t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG \
-t $ECR_REGISTRY/$ECR_REPOSITORY:latest \
.
- name: Scan Docker Image
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ steps.login-ecr.outputs.registry }}/my-app:${{ github.sha }}
format: 'sarif'
output: 'trivy-image-results.sarif'
- name: Push to ECR
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
ECR_REPOSITORY: my-app
IMAGE_TAG: ${{ github.sha }}
run: |
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest
- name: Update ECS Task Definition
id: task-def
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: task-definition.json
container-name: my-app
image: ${{ steps.login-ecr.outputs.registry }}/my-app:${{ github.sha }}
- name: Deploy to ECS
uses: aws-actions/amazon-ecs-deploy-task-definition@v1
with:
task-definition: ${{ steps.task-def.outputs.task-definition }}
service: my-app-service
cluster: production-cluster
wait-for-service-stability: true
Blue-Green Deployment Pattern
blue-green-deploy:
name: Blue-Green Deployment
runs-on: ubuntu-latest
steps:
- name: Deploy to Blue Environment
run: |
# Deploy new version to blue environment
aws ecs update-service \
--cluster production \
--service app-blue \
--task-definition app:${{ github.sha }}
- name: Wait for Blue Stability
run: |
aws ecs wait services-stable \
--cluster production \
--services app-blue
- name: Run Smoke Tests on Blue
run: |
curl -f https://blue.example.com/health || exit 1
npm run test:smoke -- --url=https://blue.example.com
- name: Switch Traffic to Blue
run: |
# Update Route53 or ALB target group
aws elbv2 modify-listener \
--listener-arn ${{ secrets.ALB_LISTENER_ARN }} \
--default-actions \
Type=forward,TargetGroupArn=${{ secrets.BLUE_TARGET_GROUP }}
- name: Monitor Metrics
run: |
# Wait and monitor CloudWatch metrics
sleep 300
# Check error rates, latency, etc.
- name: Rollback on Failure
if: failure()
run: |
# Revert to green environment
aws elbv2 modify-listener \
--listener-arn ${{ secrets.ALB_LISTENER_ARN }} \
--default-actions \
Type=forward,TargetGroupArn=${{ secrets.GREEN_TARGET_GROUP }}
Environment Management
Using GitHub Environments
deploy-staging:
name: Deploy to Staging
runs-on: ubuntu-latest
needs: [test]
environment:
name: staging
url: https://staging.example.com
steps:
- name: Deploy to Staging
run: echo "Deploying to staging..."
deploy-production:
name: Deploy to Production
runs-on: ubuntu-latest
needs: [deploy-staging]
environment:
name: production
url: https://www.example.com
# Production deployments require manual approval
# Configure in GitHub repository settings
steps:
- name: Deploy to Production
run: echo "Deploying to production..."
Reusable Workflows
# .github/workflows/deploy.yml
name: Reusable Deploy Workflow
on:
workflow_call:
inputs:
environment:
required: true
type: string
s3-bucket:
required: true
type: string
secrets:
aws-role-arn:
required: true
cloudfront-distribution-id:
required: true
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- uses: actions/checkout@v4
- name: Deploy to ${{ inputs.environment }}
run: |
# Deployment logic here
aws s3 sync out/ s3://${{ inputs.s3-bucket }}/
# .github/workflows/main.yml
name: Main Pipeline
on:
push:
branches: [main]
jobs:
deploy-staging:
uses: ./.github/workflows/deploy.yml
with:
environment: staging
s3-bucket: staging-bucket
secrets:
aws-role-arn: ${{ secrets.STAGING_AWS_ROLE }}
cloudfront-distribution-id: ${{ secrets.STAGING_CF_ID }}
deploy-production:
needs: deploy-staging
uses: ./.github/workflows/deploy.yml
with:
environment: production
s3-bucket: production-bucket
secrets:
aws-role-arn: ${{ secrets.PROD_AWS_ROLE }}
cloudfront-distribution-id: ${{ secrets.PROD_CF_ID }}
Monitoring and Notifications
Slack Integration
notify:
name: Send Notifications
runs-on: ubuntu-latest
needs: [deploy-production]
if: always()
steps:
- name: Deployment Success Notification
if: needs.deploy-production.result == 'success'
uses: slackapi/slack-github-action@v1
with:
payload: |
{
"text": "ā
Production Deployment Successful",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Production Deployment Successful* ā
\n*Commit*: ${{ github.sha }}\n*Author*: ${{ github.actor }}\n*URL*: https://www.example.com"
}
}
]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
- name: Deployment Failure Notification
if: needs.deploy-production.result == 'failure'
uses: slackapi/slack-github-action@v1
with:
payload: |
{
"text": "ā Production Deployment Failed",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Production Deployment Failed* ā\n*Commit*: ${{ github.sha }}\n*Author*: ${{ github.actor }}\n*Check*: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
}
}
]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
CloudWatch Metrics
post-deployment-monitoring:
name: Post-Deployment Monitoring
runs-on: ubuntu-latest
needs: [deploy-production]
steps:
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Check CloudWatch Alarms
run: |
# Get alarm states
ALARMS=$(aws cloudwatch describe-alarms \
--alarm-name-prefix "prod-" \
--state-value ALARM \
--query 'MetricAlarms[*].AlarmName' \
--output text)
if [ -n "$ALARMS" ]; then
echo "::error::Active alarms detected: $ALARMS"
exit 1
fi
- name: Publish Custom Metrics
run: |
aws cloudwatch put-metric-data \
--namespace "CI/CD" \
--metric-name DeploymentSuccess \
--value 1 \
--dimensions Environment=production,Pipeline=github-actions
Performance Optimization
Parallel Job Execution
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm run lint
test-unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm run test:unit
test-integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm run test:integration
build:
needs: [lint, test-unit, test-integration]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm run build
Conditional Execution
on:
push:
paths:
- 'src/**'
- 'package*.json'
- '.github/workflows/**'
jobs:
deploy:
if: |
github.ref == 'refs/heads/main' &&
!contains(github.event.head_commit.message, '[skip ci]')
# ... deployment steps
Best Practices Summary
ā DO's
- Use Secrets Management: Store sensitive data in GitHub Secrets
- Implement Caching: Cache dependencies and build artifacts
- Enable Branch Protection: Require status checks before merge
- Use Matrix Builds: Test across multiple environments
- Automate Everything: From linting to deployment
- Monitor Pipeline Performance: Track execution times
- Version Control Workflows: Treat pipelines as code
- Use Reusable Workflows: DRY principle for workflows
ā DON'Ts
- Don't Hardcode Secrets: Use GitHub Secrets or external vaults
- Don't Skip Tests: Testing is not optional
- Don't Deploy Directly to Production: Use staging first
- Don't Ignore Failed Steps: Fix issues immediately
- Don't Over-Parallelize: Balance speed with resource usage
- Don't Skip Security Scans: Vulnerabilities are costly
Key Takeaways
- Automation is Essential: Manual deployments are error-prone
- Security First: Scan code, dependencies, and containers
- Test Thoroughly: Multiple test types catch different issues
- Monitor Everything: Deployment is not the end
- Fast Feedback: Developers need quick pipeline results
- Rollback Strategy: Plan for failures
- Documentation: Comment your workflows
Conclusion
A robust CI/CD pipeline is fundamental to modern software delivery. GitHub Actions combined with AWS services provides a powerful, flexible platform for automation. By following these practices, you'll achieve:
- Faster Time to Market: Automated deployments reduce manual work
- Higher Quality: Comprehensive testing catches bugs early
- Better Security: Automated scanning prevents vulnerabilities
- Increased Confidence: Reproducible, tested deployments
- Team Productivity: Developers focus on code, not deployment
The investment in pipeline engineering pays dividends through reduced incidents, faster recovery, and happier developers.
Questions about implementing CI/CD? Feel free to reach out or check out my other DevOps articles on Terraform and Kubernetes!