Skip to main content

Command Palette

Search for a command to run...

CI/CD Best Practices for AWS ECS with GitHub Actions

Updated
8 min read
CI/CD Best Practices for AWS ECS with GitHub Actions
T
Cloud DevOps & Platform Engineer | Helping the Next Generation of Engineers Build Skills and Careers in Cloud & DevOps

Deploying containers to AWS ECS shouldn't involve SSH-ing into servers, manually pushing Docker images, or copy-pasting environment variables. Yet I've seen teams do exactly that and pay for it with broken deployments, security incidents, and sleepless nights.

In this article, I'll walk you through building a production-grade CI/CD pipeline using GitHub Actions, AWS ECR, and ECS Fargate with zero hardcoded credentials and full infrastructure automation via Terraform. This is based on a capstone project I built that brings together everything I've learned about secure, automated deployments.


Pipeline Architecture Overview

Here's the complete flow from code push to production deployment:

Key Principles

  • No hardcoded credentials: OIDC federation, not access keys

  • Infrastructure as code: Terraform manages everything

  • Immutable deployments: Every deploy uses a unique image tag

  • Branch protection: No direct pushes to main

  • Approval gates: Terraform changes require review


Step 1: GitHub Actions OIDC with AWS

The most common mistake in CI/CD pipelines? Hardcoded AWS access keys. They end up in environment variables, get rotated inconsistently, and create a massive security risk.

The modern approach is OpenID Connect (OIDC) federation, GitHub Actions authenticates directly with AWS using short-lived tokens. No secrets to rotate. No keys to leak.

Set Up the AWS OIDC Provider

First, create the OIDC provider in Terraform:

# oidc.tf
resource "aws_iam_openid_connect_provider" "github" {
  url             = "https://token.actions.githubusercontent.com"
  client_id_list  = ["sts.amazonaws.com"]
  thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}

Create the IAM Role

# iam.tf
data "aws_iam_policy_document" "github_actions_assume" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRoleWithWebIdentity"]

    principals {
      type        = "Federated"
      identifiers = [aws_iam_openid_connect_provider.github.arn]
    }

    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:aud"
      values   = ["sts.amazonaws.com"]
    }

    condition {
      test     = "StringLike"
      variable = "token.actions.githubusercontent.com:sub"
      values   = ["repo:your-org/your-repo:ref:refs/heads/main"]
    }
  }
}

resource "aws_iam_role" "github_actions" {
  name               = "github-actions-deploy"
  assume_role_policy = data.aws_iam_policy_document.github_actions_assume.json
}

resource "aws_iam_role_policy_attachment" "ecr_push" {
  role       = aws_iam_role.github_actions.name
  policy_arn = "arn:aws:iam::policy/AmazonEC2ContainerRegistryPowerUser"
}

resource "aws_iam_role_policy_attachment" "ecs_deploy" {
  role       = aws_iam_role.github_actions.name
  policy_arn = "arn:aws:iam::policy/AmazonECS_FullAccess"
}

Security note: In production, create custom policies with least-privilege permissions instead of using AWS managed policies.

Configure the GitHub Actions Workflow

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

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

permissions:
  id-token: write   # Required for OIDC
  contents: read

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

      - 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: us-east-1

That's it. No AWS_ACCESS_KEY_ID. No AWS_SECRET_ACCESS_KEY. The OIDC token is generated fresh for each workflow run and expires automatically.


Step 2: Docker Build, Tag, and Push to ECR

Tagging Strategy: Git SHA

Never use latest as your image tag. It makes rollbacks impossible and debugging a nightmare. Instead, tag every image with the Git commit SHA:

      - name: Login to Amazon ECR
        id: ecr-login
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build, tag, and push image
        env:
          ECR_REGISTRY: ${{ steps.ecr-login.outputs.registry }}
          ECR_REPOSITORY: my-app
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t \(ECR_REGISTRY/\)ECR_REPOSITORY:$IMAGE_TAG .
          docker push \(ECR_REGISTRY/\)ECR_REPOSITORY:$IMAGE_TAG
          echo "image=\(ECR_REGISTRY/\)ECR_REPOSITORY:\(IMAGE_TAG" >> \)GITHUB_OUTPUT

Why Git SHA Tags?

  • Traceability: Every running container maps to an exact commit

  • Rollbacks: Deploy the previous SHA to roll back instantly

  • Debugging: kubectl describe pod tells you exactly which code is running

  • Immutability: Each tag is unique and never overwritten


Step 3: Terraform Plan on PR, Apply on Merge

Infrastructure changes should follow the same review process as application code.

The Workflow

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

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.7.0

      - 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: us-east-1

      - name: Terraform Init
        run: terraform init

      - name: Terraform Plan
        if: github.event_name == 'pull_request'
        run: terraform plan -no-color
        continue-on-error: true

      - name: Terraform Apply
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        run: terraform apply -auto-approve

The Review Process

  1. Developer creates a PR with infrastructure changes

  2. GitHub Actions runs terraform plan: the output shows exactly what will be created, modified, or destroyed

  3. Team reviews the plan in the PR comments

  4. PR is approved and merged: terraform apply runs automatically

  5. Infrastructure is updated with full audit trail in Git

This process has saved me from deploying breaking changes more times than I can count.


Step 4: ECS Fargate Service Update

Once the new image is pushed to ECR, we need to update the ECS service to use it.

Update the Task Definition

      - name: Download current task definition
        run: |
          aws ecs describe-task-definition \
            --task-definition my-app \
            --query taskDefinition > task-def.json

      - name: Update image in task definition
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        id: render
        with:
          task-definition: task-def.json
          container-name: my-app
          image: ${{ steps.build.outputs.image }}

      - name: Deploy to ECS
        uses: aws-actions/amazon-ecs-deploy-task-definition@v2
        with:
          task-definition: ${{ steps.render.outputs.task-definition }}
          service: my-app-service
          cluster: my-app-cluster
          wait-for-service-stability: true

What wait-for-service-stability Does

This flag makes the workflow wait until:

  • New tasks are running and healthy

  • Old tasks are drained and stopped

  • The ALB health checks pass

If the new deployment fails health checks, ECS automatically rolls back to the previous task definition. This is built-in rollback protection.


Step 5: Branch Protection and Approval Gates

A pipeline is only as secure as its access controls. Here's what I configure on every repository:

Branch Protection Rules

  • Require pull request reviews: At least 1 approval before merge

  • Require status checks: Tests and terraform plan must pass

  • No direct pushes to main: All changes go through PRs

  • Require linear history: Squash merges keep the history clean

  • Require signed commits: Optional but recommended for compliance

Environment Protection Rules

For production deployments, add an extra layer:

jobs:
  deploy-production:
    runs-on: ubuntu-latest
    environment: production  # Requires manual approval
    steps:
      - name: Deploy to production
        run: echo "Deploying..."

In GitHub repository settings, configure the production environment to require approval from designated reviewers. This creates a manual gate before any production deployment.


Step 6: Secrets Management: Zero Secrets in Code

The golden rule: no secrets in your codebase. Ever.

Where Secrets Live

Secret Type Storage Location
AWS credentials OIDC (no secrets needed!)
Database URLs AWS Systems Manager Parameter Store
API keys AWS Secrets Manager
Environment configs ECS task definition environment variables
Terraform state Encrypted S3 bucket

Using Parameter Store with ECS

resource "aws_ssm_parameter" "db_url" {
  name  = "/my-app/production/database-url"
  type  = "SecureString"
  value = var.database_url  # Passed via Terraform variables, never committed
}

Reference in your ECS task definition:

container_definitions = jsonencode([{
  name  = "my-app"
  image = "${aws_ecr_repository.app.repository_url}:latest"

  secrets = [{
    name      = "DATABASE_URL"
    valueFrom = aws_ssm_parameter.db_url.arn
  }]
}])

ECS fetches the secret at runtime, it never appears in your code, your Docker image, or your CI/CD logs.


The Complete Workflow File

Here's everything combined into a single, production-ready workflow:

name: CI/CD Pipeline

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

permissions:
  id-token: write
  contents: read

env:
  AWS_REGION: us-east-1
  ECR_REPOSITORY: my-app
  ECS_CLUSTER: my-app-cluster
  ECS_SERVICE: my-app-service

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm test
      - run: npm run lint

  build-and-deploy:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: ${{ env.AWS_REGION }}

      - name: Login to ECR
        id: ecr-login
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build and push image
        id: build
        run: |
          IMAGE="\({{ steps.ecr-login.outputs.registry }}/\){{ env.ECR_REPOSITORY }}:${{ github.sha }}"
          docker build -t $IMAGE .
          docker push $IMAGE
          echo "image=\(IMAGE" >> \)GITHUB_OUTPUT

      - name: Deploy to ECS
        uses: aws-actions/amazon-ecs-deploy-task-definition@v2
        with:
          task-definition: task-definition.json
          service: ${{ env.ECS_SERVICE }}
          cluster: ${{ env.ECS_CLUSTER }}
          wait-for-service-stability: true

  terraform:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3

      - name: Configure AWS credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: ${{ env.AWS_REGION }}

      - run: terraform init
      - run: terraform plan -no-color
        if: github.event_name == 'pull_request'
      - run: terraform apply -auto-approve
        if: github.ref == 'refs/heads/main'

Conclusion

A well-designed CI/CD pipeline is the backbone of reliable software delivery. By combining GitHub Actions OIDC, immutable Docker images, Terraform automation, and proper secrets management, you get:

  • Security: No credentials to leak or rotate

  • Reliability: Automated rollbacks and health checks

  • Speed: Push to main, deploy in minutes

  • Auditability: Every change tracked in Git

The days of manual deployments are over. Build your pipeline once, trust it always, and focus on what matters, shipping great software.

If you found this helpful, follow me on Hashnode for more hands-on DevOps tutorials. Let's build and grow together.


Tolani Akintayo is a DevOps Engineer passionate about cloud infrastructure, automation, and mentoring the next generation of engineers. Connect on LinkedIn.

K

OIDC federation over hardcoded keys is the single biggest security win in CI/CD pipelines and most teams still have not adopted it. We made the same switch with Terraform and GitHub Actions last quarter and the difference in operational confidence is night and day. Great to see this as step 1.

T

Awesome to hear you experience. A lot of teams indeed have not adopted OIDC federation which is more secured.

Hopefully more people get to know about it through contents like this.