CI/CD: GitHub Actions + AWS Deployments
Build production-grade CI/CD pipelines with GitHub Actions ā run tests, build Docker images, push to ECR, deploy Lambda with Terraform, and promote changes through dev, staging, and prod.
What is GitHub Actions?
GitHub Actions is a CI/CD platform built directly into GitHub. Every push, pull request, or release can trigger automated workflows ā run tests, build containers, deploy infrastructure, send notifications.
Why GitHub Actions over Jenkins/CircleCI?
- Zero infrastructure to manage (GitHub runs the workers)
- Native GitHub integration (PR status checks, environment gates)
- Free for public repos; generous free tier for private
- 2,000+ pre-built actions in the marketplace
- OIDC authentication with AWS ā no stored credentials needed
Core Concepts
# .github/workflows/ci.yml ā the smallest possible workflow
name: CI
on: # Trigger: when to run
push:
branches: [main]
pull_request:
branches: [main]
jobs: # Jobs run in parallel by default
test:
runs-on: ubuntu-latest # Runner: GitHub-hosted Ubuntu VM
steps: # Steps run sequentially within a job
- uses: actions/checkout@v4 # Checkout code
- uses: actions/setup-node@v4 # Install Node.js
with:
node-version: "22"
- run: npm ci # Install dependencies
- run: npm test # Run testsVocabulary:
| Term | Meaning |
|------|---------|
| Workflow | A YAML file in .github/workflows/ |
| Trigger | The event that starts the workflow (push, pull_request, schedule, etc.) |
| Job | A unit of work running on a single VM (parallel by default) |
| Step | One command or action within a job (sequential) |
| Action | A reusable step ā uses: owner/action@version |
| Runner | The VM executing the job (ubuntu-latest, windows-latest, macos-latest) |
| Artifact | Files passed between jobs (build output, test reports) |
| Environment | Named deployment target with protection rules and secrets |
Authenticating with AWS: OIDC (No Long-Lived Keys)
Never store AWS access keys in GitHub secrets. Use OpenID Connect (OIDC) to let GitHub Actions assume an IAM role directly. Keys are temporary and auto-rotated.
Step 1: Create OIDC Provider in AWS
# AWS CLI
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com \
--thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1Or with Terraform:
# iam_github.tf
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}Step 2: Create IAM Role with Trust Policy
resource "aws_iam_role" "github_actions_deploy" {
name = "github-actions-deploy"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
Federated = aws_iam_openid_connect_provider.github.arn
}
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringLike = {
"token.actions.githubusercontent.com:sub" = "repo:my-org/my-repo:*"
}
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
}
}]
})
}
resource "aws_iam_role_policy_attachment" "ecr_full" {
role = aws_iam_role.github_actions_deploy.name
policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryFullAccess"
}
resource "aws_iam_role_policy_attachment" "lambda_full" {
role = aws_iam_role.github_actions_deploy.name
policy_arn = "arn:aws:iam::aws:policy/AWSLambda_FullAccess"
}Step 3: Use OIDC in Workflow
permissions:
id-token: write # Required for OIDC
contents: read
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-actions-deploy
aws-region: us-east-1Complete Pipeline: Lambda Function Deployment
# .github/workflows/deploy-lambda.yml
name: Deploy Lambda
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
AWS_REGION: us-east-1
LAMBDA_FUNCTION: learnixo-prod-api
jobs:
# āāā Job 1: Test āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
- name: Install dependencies
run: pip install -r requirements.txt -r requirements-dev.txt
- name: Run tests
run: pytest tests/ -v --tb=short --cov=. --cov-report=xml
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
file: coverage.xml
# āāā Job 2: Lint and Security Scan āāāāāāāāāāāāāāāāāāāāāāāāā
quality:
name: Code Quality
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install tools
run: pip install ruff bandit
- name: Lint
run: ruff check .
- name: Security scan
run: bandit -r . -ll # Low severity and above
# āāā Job 3: Deploy to Dev (on push to main) āāāāāāāāāāāāāāāā
deploy-dev:
name: Deploy to Dev
needs: [test, quality] # Wait for both jobs above
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: dev # GitHub environment with secrets
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.DEV_DEPLOY_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Package Lambda
run: |
zip -r lambda.zip . -x ".git/*" -x "tests/*" -x "*.md"
- name: Deploy Lambda
run: |
aws lambda update-function-code \
--function-name learnixo-dev-api \
--zip-file fileb://lambda.zip \
--region ${{ env.AWS_REGION }}
- name: Wait for update
run: |
aws lambda wait function-updated \
--function-name learnixo-dev-api
- name: Smoke test
run: |
ENDPOINT=$(aws lambda get-function-url-config \
--function-name learnixo-dev-api \
--query FunctionUrl --output text)
curl -f "${ENDPOINT}health" || exit 1
# āāā Job 4: Deploy to Prod (manual approval) āāāāāāāāāāāāāāā
deploy-prod:
name: Deploy to Production
needs: deploy-dev
runs-on: ubuntu-latest
environment: prod # Set up approval gate in GitHub Settings
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.PROD_DEPLOY_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Package Lambda
run: zip -r lambda.zip . -x ".git/*" -x "tests/*"
- name: Deploy Lambda
run: |
aws lambda update-function-code \
--function-name learnixo-prod-api \
--zip-file fileb://lambda.zip
- name: Publish version + update alias
run: |
VERSION=$(aws lambda publish-version \
--function-name learnixo-prod-api \
--query Version --output text)
aws lambda update-alias \
--function-name learnixo-prod-api \
--name live \
--function-version "$VERSION"
echo "Deployed version $VERSION"Docker + ECR Pipeline
For containerised applications:
# .github/workflows/docker-ecr.yml
name: Build and Push to ECR
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
AWS_REGION: us-east-1
ECR_REGISTRY: 123456789.dkr.ecr.us-east-1.amazonaws.com
ECR_REPOSITORY: learnixo-api
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
outputs:
image-tag: ${{ steps.meta.outputs.tags }}
image-digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.DEPLOY_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Login to Amazon ECR
uses: aws-actions/amazon-ecr-login@v2
- name: Set up Docker Buildx (faster builds with cache)
uses: docker/setup-buildx-action@v3
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}
tags: |
type=sha,prefix=sha-
type=ref,event=branch
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.ref == 'refs/heads/main' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha # GitHub Actions cache
cache-to: type=gha,mode=max
- name: Print image digest
run: echo "Image digest: ${{ steps.build.outputs.digest }}"Terraform CI/CD
# .github/workflows/terraform.yml
name: Terraform
on:
push:
branches: [main]
paths: ["infrastructure/**"]
pull_request:
branches: [main]
paths: ["infrastructure/**"]
jobs:
terraform-plan:
name: Terraform Plan
runs-on: ubuntu-latest
defaults:
run:
working-directory: infrastructure/environments/prod
permissions:
id-token: write
contents: read
pull-requests: write # Post plan output as PR comment
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.8.0"
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.PROD_TERRAFORM_ROLE_ARN }}
aws-region: us-east-1
- name: Terraform Init
run: terraform init
- name: Terraform Validate
run: terraform validate
- name: Terraform Format Check
run: terraform fmt -check -recursive
- name: Terraform Plan
id: plan
run: terraform plan -no-color -out=tfplan 2>&1 | tee plan.txt
continue-on-error: true
- name: Post plan to PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const plan = fs.readFileSync('infrastructure/environments/prod/plan.txt', 'utf8');
const maxLength = 65000;
const truncated = plan.length > maxLength ? plan.slice(0, maxLength) + '\n...[truncated]' : plan;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `## Terraform Plan\n\`\`\`\n${truncated}\n\`\`\``
});
- name: Terraform Apply (main branch only)
if: github.ref == 'refs/heads/main' && steps.plan.outcome == 'success'
run: terraform apply -auto-approve tfplanReusable Workflows
Extract common steps into reusable workflows to avoid duplication:
# .github/workflows/reusable-lambda-deploy.yml
name: Reusable Lambda Deploy
on:
workflow_call:
inputs:
environment:
required: true
type: string
function_name:
required: true
type: string
secrets:
role_arn:
required: true
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.role_arn }}
aws-region: us-east-1
- name: Package and deploy
run: |
zip -r lambda.zip . -x ".git/*"
aws lambda update-function-code \
--function-name ${{ inputs.function_name }} \
--zip-file fileb://lambda.zip
aws lambda wait function-updated \
--function-name ${{ inputs.function_name }}# .github/workflows/deploy.yml ā calls the reusable workflow
name: Deploy
on:
push:
branches: [main]
jobs:
test:
uses: ./.github/workflows/reusable-test.yml
deploy-dev:
needs: test
uses: ./.github/workflows/reusable-lambda-deploy.yml
with:
environment: dev
function_name: learnixo-dev-api
secrets:
role_arn: ${{ secrets.DEV_DEPLOY_ROLE_ARN }}
deploy-prod:
needs: deploy-dev
uses: ./.github/workflows/reusable-lambda-deploy.yml
with:
environment: prod
function_name: learnixo-prod-api
secrets:
role_arn: ${{ secrets.PROD_DEPLOY_ROLE_ARN }}Secrets Management
# Access secrets in workflows
env:
API_KEY: ${{ secrets.API_KEY }} # From repo/org secrets
DB_URL: ${{ secrets.DB_URL }}
# Environment-specific secrets (scoped to an environment)
environment: prod
env:
DB_URL: ${{ secrets.PROD_DB_URL }} # Only accessible when env=prodSecret storage hierarchy:
- Repository secrets ā available to all workflows in the repo
- Environment secrets ā only available when the job uses that environment
- Organisation secrets ā available across multiple repos
- GitHub Actions Vault ā use AWS Secrets Manager / HashiCorp Vault for rotation
# Fetch secret from AWS Secrets Manager at runtime
- name: Get secrets from AWS
uses: aws-actions/aws-secretsmanager-get-secrets@v2
with:
secret-ids: |
STRIPE_KEY, prod/stripe/secret-key
SENDGRID_KEY, prod/sendgrid/api-key
parse-json-secrets: true
# Secrets are now available as env vars: STRIPE_KEY, SENDGRID_KEYEnvironment Protection Rules
Set up gates in GitHub Settings ā Environments:
Environment: prod
āāā Required reviewers: [senior-devs team] ā Human approval
āāā Wait timer: 5 minutes ā Mandatory delay
āāā Deployment branches: main only ā Only from main
āāā Secrets: PROD_DB_URL, PROD_ROLE_ARNThis ensures no one can deploy to prod without team approval.
Workflow Triggers Reference
on:
push:
branches: [main, "release/*"]
paths-ignore: ["**.md", "docs/**"] # Skip if only docs changed
pull_request:
types: [opened, synchronize, reopened]
branches: [main]
schedule:
- cron: "0 2 * * 1-5" # Weekdays at 2am UTC
workflow_dispatch: # Manual trigger with inputs
inputs:
environment:
type: choice
options: [dev, staging, prod]
required: true
release:
types: [published] # When a GitHub release is createdCost Optimisation
# 1. Cache dependencies
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ hashFiles('package-lock.json') }}
# 2. Skip CI for docs changes
jobs:
changed:
runs-on: ubuntu-latest
outputs:
code: ${{ steps.filter.outputs.code }}
steps:
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
code:
- 'src/**'
- 'package.json'
test:
needs: changed
if: needs.changed.outputs.code == 'true'
# ... rest of job
# 3. Concurrency: cancel outdated runs
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true # Cancel if a new push arrivesSummary
| Pattern | What It Does |
|---------|-------------|
| OIDC auth | No stored AWS keys ā GitHub assumes IAM role directly |
| Job dependencies | needs: [test, quality] ā serialize and gate jobs |
| Environments | Named stages with secrets and approval gates |
| Reusable workflows | workflow_call ā DRY CI across multiple repos |
| Concurrency groups | Cancel outdated PR runs automatically |
| Path filters | Skip CI when only docs change |
| Artifacts | Pass build output between jobs |
Next up: Project ā Full IaC Serverless Stack ā deploy everything from this course end to end.
Enjoyed this article?
Explore the Cloud & DevOps learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.