3. CI/CD with GitHub Actions

Manual deploys break the moment you skip a step. GitHub Actions automates the pipeline: test, build, push, deploy, every commit, every time.

The Chapter 28 deploy sequence -- authenticate Docker, build, tag, push, register a new task-definition revision, update the service, watch the rollout -- is the same sequence every time. The work in this section is making it run from a git push instead of a terminal, with the test suite as a quality gate in front and ECS deployment circuit breakers as a safety net behind.

The pipeline has two halves. CI (Continuous Integration) runs the tests on every commit so regressions surface within a minute or two of writing them, not during release week. CD (Continuous Deployment) takes whatever passed the tests and ships it through the same artefact-building, image-pushing, task-definition-updating sequence the manual workflow used, except automated and audited. The advantage isn't speed (5-8 minutes versus 15 isn't dramatic); it's that the sequence is recorded, identical every time, and gated by the test result instead of by whoever happens to be at a terminal.

GitHub Actions architecture

GitHub Actions is a CI/CD platform built into GitHub. When you push code to a repository, GitHub can automatically run workflows, sequences of jobs executing commands. Workflows are defined in YAML files stored in your repository at .github/workflows/. This approach means your CI/CD configuration is versioned alongside your code.

Workflows

The top-level concept. A workflow defines when to run (on push to main branch, on pull request, on schedule) and what to run (one or more jobs). Your News API will have a deployment workflow triggered by pushes to the main branch.

Jobs

Collections of steps that run on the same machine (called a runner). Jobs can run sequentially or in parallel. Your deployment workflow will have jobs for testing, building Docker images, and deploying to ECS. These jobs run sequentially because each depends on the previous job's success.

Steps

Individual commands or actions. A step might run shell commands (pytest to run tests), use pre-built actions (aws-actions/amazon-ecr-login to authenticate with ECR), or checkout code (actions/checkout). Steps in a job share the same filesystem and environment variables, enabling data passing between steps.

Runners

The machines that execute the workflow. GitHub provides hosted runners (Ubuntu, Windows, macOS) for free on public repositories and on a generous monthly quota for private ones. The workflow in this chapter uses Ubuntu, which ships with Docker, Python, and the AWS CLI already installed.

Managing AWS credentials securely

The workflow needs AWS credentials to push to ECR and update the ECS service. Putting access keys in the workflow YAML is a non-starter: the file is in git, every collaborator can read it, and once a secret hits git history it's effectively burned. The pattern that works: a dedicated IAM user with least-privilege permissions for exactly the deploy actions (no admin scope), plus its access keys stored in GitHub Secrets, which the workflow reads at runtime via ${{ secrets.NAME }} references.

Create an IAM user for GitHub Actions:

Terminal
# Create IAM user
aws iam create-user --user-name github-actions-deploy

# Create access key for programmatic access
aws iam create-access-key --user-name github-actions-deploy

# Save the AccessKeyId and SecretAccessKey from output
# You'll need these for GitHub Secrets

Create and attach permissions policy:

This policy grants the minimum permissions needed for deployment:

github-actions-policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "ecr:GetAuthorizationToken",
        "ecr:BatchCheckLayerAvailability",
        "ecr:GetDownloadUrlForLayer",
        "ecr:BatchGetImage",
        "ecr:PutImage",
        "ecr:InitiateLayerUpload",
        "ecr:UploadLayerPart",
        "ecr:CompleteLayerUpload"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "ecs:DescribeTaskDefinition",
        "ecs:RegisterTaskDefinition",
        "ecs:UpdateService",
        "ecs:DescribeServices"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "iam:PassRole"
      ],
      "Resource": "arn:aws:iam::*:role/ecsTaskExecutionRole"
    }
  ]
}

Attach the policy to your IAM user:

Terminal
# Create the policy from the JSON file
aws iam create-policy \
    --policy-name GitHubActionsDeployPolicy \
    --policy-document file://github-actions-policy.json

# Get your AWS account ID
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)

# Attach the policy to the user
aws iam attach-user-policy \
    --user-name github-actions-deploy \
    --policy-arn arn:aws:iam::${ACCOUNT_ID}:policy/GitHubActionsDeployPolicy

# Verify the policy is attached
aws iam list-attached-user-policies \
    --user-name github-actions-deploy

Creating the CI/CD workflow

Now you'll create the complete GitHub Actions workflow that automates your entire deployment pipeline. This workflow runs tests, builds Docker images, pushes to ECR, and deploys to ECS automatically on every push to the main branch.

Add AWS credentials to GitHub Secrets:

Before creating the workflow, store your AWS credentials securely in GitHub:

  1. Navigate to your repository on GitHub
  2. Click Settings -> Secrets and variables -> Actions
  3. Click New repository secret
  4. Add AWS_ACCESS_KEY_ID with the value from your IAM user creation
  5. Add AWS_SECRET_ACCESS_KEY with the secret access key value
  6. Add AWS_ACCOUNT_ID with your 12-digit AWS account ID

Create the workflow file:

In your repository, create .github/workflows/deploy.yml:

.github/workflows/deploy.yml
name: Deploy to AWS ECS

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

env:
  AWS_REGION: us-east-1
  ECR_REPOSITORY: news-api
  ECS_SERVICE: news-api-service
  ECS_CLUSTER: news-api-cluster
  ECS_TASK_DEFINITION: news-api-task
  CONTAINER_NAME: news-api

jobs:
  test:
    name: Run Tests
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v3
      
      - name: Set up Python 3.11
        uses: actions/setup-python@v4
        with:
          python-version: '3.11'
      
      - name: Cache pip dependencies
        uses: actions/cache@v3
        with:
          path: ~/.cache/pip
          key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
          restore-keys: |
            ${{ runner.os }}-pip-
      
      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt
          pip install pytest pytest-cov
      
      - name: Run tests with coverage
        run: |
          pytest --cov=app --cov-report=term-missing
      
      - name: Check test coverage threshold
        run: |
          coverage report --fail-under=70

  build-and-deploy:
    name: Build and Deploy
    needs: test
    runs-on: ubuntu-latest
    # Only run on pushes to main (not on pull requests)
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v3
      
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v2
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ env.AWS_REGION }}
      
      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v1
      
      - name: Build, tag, and push image to Amazon ECR
        id: build-image
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          # Build Docker image
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:latest .
          
          # Push both tags to ECR
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest
          
          # Output image URI for next step
          echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT
      
      - name: Download current task definition
        run: |
          aws ecs describe-task-definition \
            --task-definition ${{ env.ECS_TASK_DEFINITION }} \
            --query taskDefinition > task-definition.json
      
      - name: Update task definition with new image
        id: task-def
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        with:
          task-definition: task-definition.json
          container-name: ${{ env.CONTAINER_NAME }}
          image: ${{ steps.build-image.outputs.image }}
      
      - name: Deploy to Amazon ECS
        uses: aws-actions/amazon-ecs-deploy-task-definition@v1
        with:
          task-definition: ${{ steps.task-def.outputs.task-definition }}
          service: ${{ env.ECS_SERVICE }}
          cluster: ${{ env.ECS_CLUSTER }}
          wait-for-service-stability: true
      
      - name: Verify deployment
        run: |
          echo "Deployment completed successfully!"
          echo "Image deployed: ${{ steps.build-image.outputs.image }}"
          
          # Get service status
          aws ecs describe-services \
            --cluster ${{ env.ECS_CLUSTER }} \
            --services ${{ env.ECS_SERVICE }} \
            --query 'services[0].{Status:status,Running:runningCount,Desired:desiredCount}' \
            --output table
      
      - name: Post deployment notification
        if: success()
        run: |
          echo "โœ… Deployment successful!"
          echo "Commit: ${{ github.sha }}"
          echo "Deployed by: ${{ github.actor }}"
      
      - name: Deployment failure notification
        if: failure()
        run: |
          echo "โŒ Deployment failed!"
          echo "Check the logs above for details"

Understanding the workflow structure:

Quality gates: The build-and-deploy job depends on test passing (needs: test). If tests fail, deployment never runs. This prevents broken code from reaching production.

Pull request protection: The deployment job only runs on pushes to main, not on pull requests. Pull requests run tests but don't deploy, enabling safe code review before merging.

Image tagging strategy: Each build creates two tags: the Git commit SHA (immutable, traceable) and latest (convenient for local development). Production uses SHA tags for exact version tracking.

Deployment verification: The wait-for-service-stability flag makes the workflow wait until ECS confirms the deployment succeeded (all tasks healthy). If deployment fails, the workflow reports failure.

The workflow caches pip dependencies via actions/cache. The first run installs fresh (2-3 minutes); every run after that restores from cache (30-45 seconds). Across the lifetime of the repo, that's the difference between dependency installation costing a few hours of CI time per month versus a few minutes.

Testing the pipeline

Now verify your CI/CD pipeline works end-to-end by triggering a deployment.

Trigger your first automated deployment:

Terminal
# Make a small code change to trigger deployment
echo "# Deployment test" >> README.md

# Commit and push to main branch
git add README.md
git commit -m "Test CI/CD pipeline"
git push origin main

Watch the workflow execute:

  1. Navigate to your GitHub repository
  2. Click the Actions tab
  3. Click the most recent workflow run (your commit message)
  4. Watch the test job complete (1-2 minutes)
  5. Watch the build-and-deploy job execute (3-5 minutes)
  6. Verify all steps show green checkmarks

Verify deployment in AWS:

Terminal
# Check ECS service is running new tasks
aws ecs describe-services \
    --cluster news-api-cluster \
    --services news-api-service \
    --query 'services[0].{Running:runningCount,Desired:desiredCount,TaskDef:taskDefinition}' \
    --output table

# Check task definition image matches Git SHA
aws ecs describe-task-definition \
    --task-definition news-api-task \
    --query 'taskDefinition.containerDefinitions[0].image'

# Result should show: ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com/news-api:abc123
# where abc123 is your Git commit SHA

Test your live API:

Terminal
# Get your ALB DNS name
aws elbv2 describe-load-balancers \
    --names news-api-alb \
    --query 'LoadBalancers[0].DNSName' \
    --output text

# Test the API
curl http://YOUR-ALB-DNS/docs
# Should return your FastAPI documentation page

# Test an endpoint (requires an API key, covered in Chapter 26)
curl -H "Authorization: Bearer YOUR_KEY" http://YOUR-ALB-DNS/articles
# Should return articles from your database
CI/CD pipeline verified

If all steps succeeded, every subsequent push to main runs the same sequence: tests, build, push to ECR, ECS deploy, health-check wait. The manual sequence from Chapter 28 still works; it just isn't the path the team has to take anymore.

Common CI/CD issues

Workflow runs but the deploy job skips: the push went to a feature branch. The deploy job is gated on github.ref == 'refs/heads/main'.

AWS authentication fails: GitHub Secret names are case-sensitive. They must match AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY exactly.

Tests pass locally but fail in CI: a dependency is installed in your local venv but not in requirements.txt. CI runs from a fresh checkout, so anything not pinned in the lockfile won't be there.

Deploy succeeds but tasks fail health checks: read the CloudWatch task logs for the startup failure. The usual suspects are a missing env var, a wrong DATABASE_URL, or a port mismatch between containerPort and the ALB target group.

Next, in section 4, we wire the four golden signals (latency, traffic, errors, saturation) into a CloudWatch dashboard and configure three alarms that page on conditions actually worth waking someone for.