Back to Blog
· 10 min read · EN

CI/CD on AWS: GitHub Actions to ECS Deploy Pipeline

A step-by-step tutorial for building a production CI/CD pipeline with GitHub Actions, ECR, and ECS Fargate using OIDC authentication and blue-green deployments.

DevOpsTutorial #aws#cicd#github-actions#ecr#ecs#deployment#terraform
CI/CD on AWS: GitHub Actions to ECS Deploy Pipeline

Every container-based project needs a deployment pipeline. And every time I set one up, I see the same mistakes: hardcoded access keys, no rollback strategy, manual steps that “someone just remembers to do.” Let me show you the pipeline I use for every ECS Fargate project.

This is not theoretical. This is the same pipeline running across multiple client projects, refined over dozens of iterations. It uses OIDC for authentication, supports blue-green and rolling deployments, handles multiple environments, and gives you one-command rollback.

Architecture Overview

The flow is straightforward:

CI/CD Pipeline: GitHub Actions to ECS

Developer pushes to main


GitHub Actions triggered

    ├─ 1. Authenticate via OIDC (no access keys)
    ├─ 2. Run tests
    ├─ 3. Build Docker image
    ├─ 4. Push to ECR (tagged with commit SHA)
    ├─ 5. Update ECS task definition
    ├─ 6. Deploy new service version
    └─ 7. Wait for stability

         ├─ Stable → Done
         └─ Unstable → Auto-rollback

Each step is idempotent. If the pipeline fails at step 5, you can re-run it without side effects. The ECR image from step 4 already exists, so it skips the push and proceeds to deployment.

Prerequisites

Before building the pipeline, you need these AWS resources. I manage all of them with Terraform as part of my architecture patterns for small teams:

  1. ECS Cluster with Fargate capacity provider
  2. ECR Repository for your container images
  3. ECS Service with at least one running task
  4. IAM OIDC Provider for GitHub Actions
  5. IAM Role with permissions for ECR push and ECS deploy

If you are starting from scratch, here is the Terraform for the OIDC provider:

# OIDC Provider for GitHub Actions
resource "aws_iam_openid_connect_provider" "github" {
  url = "https://token.actions.githubusercontent.com"

  client_id_list = ["sts.amazonaws.com"]

  thumbprint_list = [
    "6938fd4d98bab03faadb97b34396831e3780aea1",
    "1c58a3a8518e8759bf075b76b750d4f2df264fcd"
  ]
}

# IAM Role for GitHub Actions
resource "aws_iam_role" "github_actions" {
  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 = {
          StringEquals = {
            "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
          }
          StringLike = {
            "token.actions.githubusercontent.com:sub" = "repo:your-org/your-repo:*"
          }
        }
      }
    ]
  })
}

# Policy: ECR push + ECS deploy
resource "aws_iam_role_policy" "deploy" {
  name = "deploy-policy"
  role = aws_iam_role.github_actions.id

  policy = jsonencode({
    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:UpdateService",
          "ecs:DescribeServices",
          "ecs:DescribeTaskDefinition",
          "ecs:RegisterTaskDefinition",
          "ecs:DeregisterTaskDefinition",
          "iam:PassRole"
        ]
        Resource = "*"
      }
    ]
  })
}

Important: restrict the StringLike condition to your specific repository. Never use repo:*:* as that would allow any GitHub repository to assume your role.

Step 1: OIDC Authentication Setup

OIDC replaces static access keys with short-lived tokens. Here is why this matters:

  • No secrets to rotate
  • Tokens expire after the workflow completes
  • If your repo is compromised, attackers cannot extract permanent credentials
  • Audit trail shows exactly which workflow run assumed the role

In your GitHub Actions workflow, authentication looks like this:

permissions:
  id-token: write
  contents: read

steps:
  - name: Configure AWS credentials
    uses: aws-actions/configure-aws-credentials@v4
    with:
      role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
      aws-region: ap-southeast-1

That is it. No AWS_ACCESS_KEY_ID secret. No AWS_SECRET_ACCESS_KEY. The GitHub OIDC token is exchanged for temporary AWS credentials automatically.

Step 2: Complete GitHub Actions Workflow

Here is the full workflow file. I will break down each section after:

name: Deploy to ECS

on:
  push:
    branches: [main]
  workflow_dispatch:
    inputs:
      environment:
        description: 'Target environment'
        required: true
        default: 'staging'
        type: choice
        options:
          - staging
          - production

env:
  AWS_REGION: ap-southeast-1
  ECR_REPOSITORY: my-api
  ECS_CLUSTER: my-cluster
  ECS_SERVICE: my-api-service
  CONTAINER_NAME: api

permissions:
  id-token: write
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Go
        uses: actions/setup-go@v5
        with:
          go-version: '1.22'

      - name: Run tests
        run: go test ./... -v -race -coverprofile=coverage.out

      - name: Check coverage threshold
        run: |
          COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | tr -d '%')
          if (( $(echo "$COVERAGE < 70" | bc -l) )); then
            echo "Coverage ${COVERAGE}% is below 70% threshold"
            exit 1
          fi

  deploy:
    needs: test
    runs-on: ubuntu-latest
    environment: ${{ github.event.inputs.environment || 'staging' }}

    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, tag, and push image
        id: build-image
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:latest .
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest
          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_SERVICE }} \
            --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 ECS
        uses: aws-actions/amazon-ecs-deploy-task-definition@v2
        with:
          task-definition: ${{ steps.task-def.outputs.task-definition }}
          service: ${{ env.ECS_SERVICE }}
          cluster: ${{ env.ECS_CLUSTER }}
          wait-for-service-stability: true
          wait-for-minutes: 10

      - name: Notify on failure
        if: failure()
        run: |
          echo "Deployment failed. Previous task definition is still active."
          echo "To rollback manually: aws ecs update-service --cluster $ECS_CLUSTER --service $ECS_SERVICE --task-definition <previous-revision>"

Step 3: ECS Task Definition Update Strategy

The key insight: never modify your task definition in place. Always register a new revision.

ECS task definitions are immutable and versioned. When you deploy:

  1. Current running: my-api:42
  2. Pipeline registers: my-api:43 with new image
  3. ECS service updates to use my-api:43
  4. New tasks start with revision 43
  5. Old tasks (revision 42) drain connections
  6. Once healthy, old tasks stop

If revision 43 fails health checks, ECS automatically stops the rollout. Your service continues running on revision 42.

# See all task definition revisions
aws ecs list-task-definitions --family-prefix my-api

# Rollback to specific revision
aws ecs update-service \
  --cluster my-cluster \
  --service my-api-service \
  --task-definition my-api:42

Step 4: Blue/Green vs Rolling Deployment

Rolling Deployment (Default)

ECS replaces tasks one at a time. During deployment, both old and new versions run simultaneously:

Time 0: [v1] [v1] [v1]         ← 3 tasks running v1
Time 1: [v1] [v1] [v2]         ← 1 new task starting
Time 2: [v1] [v2] [v2]         ← old task draining
Time 3: [v2] [v2] [v2]         ← deployment complete

Configure with deployment parameters:

resource "aws_ecs_service" "api" {
  # ...

  deployment_minimum_healthy_percent = 66
  deployment_maximum_percent         = 200

  deployment_circuit_breaker {
    enable   = true
    rollback = true  # Auto-rollback on failure
  }
}

Pros: Simple, no extra infrastructure, works with standard ALB. Cons: Rollback takes as long as deployment. Both versions serve traffic during transition.

Blue/Green Deployment

Uses AWS CodeDeploy with two target groups. Traffic switches atomically:

resource "aws_codedeploy_deployment_group" "api" {
  app_name               = aws_codedeploy_app.api.name
  deployment_group_name  = "api-deploy-group"
  service_role_arn       = aws_iam_role.codedeploy.arn
  deployment_config_name = "CodeDeployDefault.ECSAllAtOnce"

  ecs_service {
    cluster_name = aws_ecs_cluster.main.name
    service_name = aws_ecs_service.api.name
  }

  blue_green_deployment_config {
    terminate_blue_instances_on_deployment_success {
      action                           = "TERMINATE"
      termination_wait_time_in_minutes = 5
    }

    deployment_ready_option {
      action_on_timeout = "CONTINUE_DEPLOYMENT"
    }
  }

  load_balancer_info {
    target_group_pair_info {
      prod_traffic_route {
        listener_arns = [aws_lb_listener.https.arn]
      }
      target_group {
        name = aws_lb_target_group.blue.name
      }
      target_group {
        name = aws_lb_target_group.green.name
      }
    }
  }
}

Pros: Instant rollback (just switch traffic back). No mixed versions serving traffic. Cons: Costs 2x compute during deployment. More complex setup.

My recommendation: Start with rolling deployment. Move to blue/green only if you need instant rollback capability or cannot tolerate mixed versions during deployment.

Step 5: Rollback Strategy

Every deployment should have a clear rollback path. Here are three levels:

Automatic Rollback (Circuit Breaker)

ECS deployment circuit breaker monitors health checks during deployment. If new tasks fail to stabilize, it automatically rolls back:

deployment_circuit_breaker {
  enable   = true
  rollback = true
}

This catches most deployment failures without human intervention.

Manual Rollback (Task Definition Revert)

If automatic rollback does not trigger but you notice issues post-deployment:

# Find the previous working revision
aws ecs describe-services \
  --cluster my-cluster \
  --services my-api-service \
  --query 'services[0].deployments[*].taskDefinition'

# Rollback to previous revision
aws ecs update-service \
  --cluster my-cluster \
  --service my-api-service \
  --task-definition my-api:42 \
  --force-new-deployment

Git Revert (Full Pipeline Rollback)

For critical issues, revert the commit and let the pipeline deploy the previous code:

git revert HEAD
git push origin main
# Pipeline rebuilds and deploys the reverted code

This is the safest option because it ensures your Git history matches what is deployed.

Step 6: Environment-Specific Deploys

The workflow supports two deployment patterns:

Automatic (Staging on Push)

Every push to main deploys to staging automatically. This gives you continuous integration with immediate feedback.

Manual (Production with Approval)

Production requires a manual trigger with approval gates:

environment: production  # GitHub Environment with required reviewers

Configure this in GitHub Settings > Environments > production > add required reviewers.

The same workflow handles both. The environment input determines which AWS account, cluster, and service receives the deployment.

env:
  ECS_CLUSTER: ${{ vars.ECS_CLUSTER }}  # Set per-environment in GitHub
  ECS_SERVICE: ${{ vars.ECS_SERVICE }}

Common Pitfalls and Debugging Tips

Pitfall 1: OIDC Subject Claim Mismatch

Error: Not authorized to perform sts:AssumeRoleWithWebIdentity

The subject claim in your IAM role condition must match exactly. For pushes to main:

repo:your-org/your-repo:ref:refs/heads/main

For pull requests:

repo:your-org/your-repo:pull_request

Use a wildcard repo:your-org/your-repo:* during development, then restrict for production.

Pitfall 2: Health Check Grace Period Too Short

New containers need time to start before health checks declare them unhealthy. Set an appropriate grace period:

health_check_grace_period_seconds = 60

Without this, ECS kills your container before it finishes starting up, creating an infinite deploy loop.

Pitfall 3: Forgetting Service Stability Wait

The wait-for-service-stability flag is critical. Without it, the pipeline reports success immediately after starting the deployment, before knowing if it actually worked.

Pitfall 4: ECR Image Tag Mutability

Always use immutable tags. The commit SHA guarantees each image is unique:

resource "aws_ecr_repository" "api" {
  name                 = "my-api"
  image_tag_mutability = "IMMUTABLE"
}

Pitfall 5: Task Role vs Execution Role

  • Task Role: Permissions your application needs at runtime (access S3, SQS, etc.)
  • Execution Role: Permissions ECS needs to pull images and write logs

These are different IAM roles. Mixing them up causes either pull failures or runtime permission errors.

Complete Pipeline Summary

The pipeline I have described here is the same one I set up for clients through my DevOps support service. It combines security, reliability, and simplicity without over-engineering.

If you are running ECS Fargate as your primary compute, this pipeline takes about a day to set up from scratch. Once running, it eliminates manual deployment steps entirely.

Key principles:

  • No long-lived credentials anywhere
  • Every deployment is reversible
  • Staging validates before production
  • Circuit breakers catch failures automatically
  • Git history matches production state

The next step after getting basic deployments working is adding database migration support, canary deployments, and automated load testing. But start here. Ship this pipeline, let it run for a few weeks, then iterate.

See this pipeline in action in my CI/CD portfolio projects and insurance backend DevOps implementation.