Hands-On Project: Build a CI/CD Pipeline¶
Build a complete continuous integration and continuous deployment pipeline using GitHub Actions.
Estimated Time: 4-5 hours Difficulty: Intermediate Prerequisites: Git, Docker basics, a cloud account (AWS, Azure, or GCP)
Architecture Overview¶
GitHub Repository
|
v
GitHub Actions Workflow
|
+-- Build Stage (compile, lint, unit tests)
|
+-- Test Stage (integration tests, security scan)
|
+-- Build Docker Image --> Push to Container Registry
|
+-- Deploy to Staging --> Run smoke tests
|
+-- Manual Approval Gate
|
+-- Deploy to Production
Step 1: Source Repository Setup¶
Project Structure¶
my-app/
src/ # Application source code
tests/ # Test files
Dockerfile # Container build instructions
docker-compose.yml # Local development
terraform/ # Infrastructure as code
main.tf
variables.tf
environments/
staging.tfvars
production.tfvars
.github/
workflows/
ci-cd.yml # Main pipeline
Sample Dockerfile¶
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
EXPOSE 8080
USER node
CMD ["node", "dist/index.js"]
Step 2: GitHub Actions Workflow¶
Complete Pipeline Configuration¶
Create .github/workflows/ci-cd.yml:
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
# Stage 1: Build and Lint
build:
runs-on: ubuntu-latest
steps:
- 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: Lint
run: npm run lint
- name: Build
run: npm run build
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
# Stage 2: Test
test:
needs: build
runs-on: ubuntu-latest
steps:
- 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: Unit tests
run: npm test -- --coverage
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/
# Stage 3: Security Scan
security:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
severity: 'CRITICAL,HIGH'
# Stage 4: Build and Push Docker Image
docker:
needs: [test, security]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
outputs:
image-tag: ${{ steps.meta.outputs.tags }}
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- 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=sha,prefix=
type=raw,value=latest
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Stage 5: Deploy to Staging
deploy-staging:
needs: docker
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- name: Configure cloud credentials
# Use OIDC or stored credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
- name: Deploy to staging
run: |
# Example: update ECS service
aws ecs update-service \
--cluster staging-cluster \
--service my-app \
--force-new-deployment
- name: Wait for deployment
run: |
aws ecs wait services-stable \
--cluster staging-cluster \
--services my-app
- name: Run smoke tests
run: |
curl -f https://staging.example.com/health || exit 1
# Stage 6: Manual Approval + Deploy to Production
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment: production # Requires manual approval in GitHub settings
steps:
- uses: actions/checkout@v4
- name: Configure cloud credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_PROD_ROLE_ARN }}
aws-region: us-east-1
- name: Deploy to production
run: |
aws ecs update-service \
--cluster production-cluster \
--service my-app \
--force-new-deployment
- name: Wait for deployment
run: |
aws ecs wait services-stable \
--cluster production-cluster \
--services my-app
- name: Verify production
run: |
curl -f https://api.example.com/health || exit 1
Step 3: Configure Environments in GitHub¶
Staging Environment¶
- Go to repository Settings - Environments
- Create "staging" environment
- Add environment secrets (AWS_ROLE_ARN, etc.)
Production Environment¶
- Create "production" environment
- Enable "Required reviewers" and add approvers
- Optionally restrict to the
mainbranch - Add production-specific secrets
Step 4: Infrastructure with Terraform¶
Basic ECS Infrastructure¶
# terraform/main.tf
provider "aws" {
region = var.aws_region
}
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "ci-cd-project/terraform.tfstate"
region = "us-east-1"
}
}
resource "aws_ecs_cluster" "main" {
name = "${var.environment}-cluster"
}
resource "aws_ecs_service" "app" {
name = "my-app"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.app.arn
desired_count = var.desired_count
launch_type = "FARGATE"
network_configuration {
subnets = var.private_subnets
security_groups = [aws_security_group.app.id]
}
load_balancer {
target_group_arn = aws_lb_target_group.app.arn
container_name = "app"
container_port = 8080
}
}
Apply Infrastructure¶
# Initialize Terraform
cd terraform
terraform init
# Plan for staging
terraform plan -var-file=environments/staging.tfvars
# Apply
terraform apply -var-file=environments/staging.tfvars
Step 5: Alternative Deployment Targets¶
Kubernetes (EKS / AKS / GKE)¶
Replace the ECS deploy step with:
- name: Deploy to Kubernetes
run: |
kubectl set image deployment/my-app \
app=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
--namespace ${{ env.NAMESPACE }}
kubectl rollout status deployment/my-app \
--namespace ${{ env.NAMESPACE }} --timeout=300s
Azure Container Apps¶
- name: Deploy to Azure Container Apps
uses: azure/container-apps-deploy-action@v1
with:
imageToDeploy: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
containerAppName: my-app
resourceGroup: myRG
GCP Cloud Run¶
- name: Deploy to Cloud Run
run: |
gcloud run deploy my-app \
--image ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
--region us-central1 \
--platform managed
Step 6: Rollback Strategy¶
Automatic Rollback on Failed Health Check¶
- name: Deploy with rollback
run: |
aws ecs update-service --cluster production-cluster \
--service my-app --force-new-deployment
if ! aws ecs wait services-stable --cluster production-cluster --services my-app; then
echo "Deployment failed, rolling back..."
aws ecs update-service --cluster production-cluster \
--service my-app --task-definition $PREVIOUS_TASK_DEF
exit 1
fi
Verification Checklist¶
- Push to a feature branch triggers build and test only (no deploy)
- Merge to main triggers the full pipeline
- Docker image is built and pushed to the registry
- Staging deployment succeeds and smoke tests pass
- Production deployment requires manual approval
- Rollback works when deployment fails
- Secrets are stored in GitHub environment settings (not in code)
Cleanup¶
- Delete cloud resources (ECS cluster, load balancer, VPC)
- Remove Terraform state bucket
- Delete container images from the registry
- Remove GitHub environment configurations