Back to blog
Cloud & DevOpsintermediate

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.

LearnixoApril 17, 202610 min read
GitHub ActionsCI/CDAWSTerraformDockerDevOpsECRLambda
Share:š•
GitHub Actions

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

YAML
# .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 tests

Vocabulary: | 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

Bash
# AWS CLI
aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --client-id-list sts.amazonaws.com \
  --thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1

Or with Terraform:

HCL
# 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

HCL
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

YAML
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-1

Complete Pipeline: Lambda Function Deployment

YAML
# .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:

YAML
# .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

YAML
# .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 tfplan

Reusable Workflows

Extract common steps into reusable workflows to avoid duplication:

YAML
# .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 }}
YAML
# .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

YAML
# 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=prod

Secret storage hierarchy:

  1. Repository secrets — available to all workflows in the repo
  2. Environment secrets — only available when the job uses that environment
  3. Organisation secrets — available across multiple repos
  4. GitHub Actions Vault — use AWS Secrets Manager / HashiCorp Vault for rotation
YAML
# 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_KEY

Environment 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_ARN

This ensures no one can deploy to prod without team approval.


Workflow Triggers Reference

YAML
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 created

Cost Optimisation

YAML
# 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 arrives

Summary

| 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?

Share:š•

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.