CI/CDAWS

GitHub Actions CI/CD for Docker to AWS ECR + ECS — Complete Pipeline

By Akshay Ghalme·April 8, 2026·~15 min read

Push to main, Docker image builds, gets pushed to ECR, ECS picks up the new image, zero-downtime rolling update — all without a single access key. This guide walks through building a complete CI/CD pipeline with GitHub Actions that deploys to ECS Fargate using OIDC authentication. Every YAML block and Terraform resource is production-tested.

What We’re Building

The complete pipeline flow:

  1. Developer pushes code to main branch
  2. GitHub Actions triggers the workflow
  3. GitHub authenticates to AWS via OIDC (no access keys)
  4. Workflow builds a Docker image with multi-stage build
  5. Tags the image with the git commit SHA
  6. Pushes the image to ECR
  7. Updates the ECS task definition with the new image tag
  8. Deploys to ECS Fargate with a rolling update
  9. Waits for service stability before marking the deployment as successful

Prerequisites

  • A GitHub repository with a Dockerfile
  • An AWS account with an ECS cluster and service already running — see our ECS Fargate guide
  • Terraform installed for the infrastructure setup
  • AWS CLI configured locally

OIDC Setup — No Access Keys

OIDC lets GitHub Actions assume an AWS IAM role directly — no long-lived secrets to manage. For a deep dive, see our CI/CD OIDC guide. Here’s the Terraform:

# OIDC Identity Provider for GitHub
resource "aws_iam_openid_connect_provider" "github" {
  url             = "https://token.actions.githubusercontent.com"
  client_id_list  = ["sts.amazonaws.com"]
  thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}

# IAM Role that GitHub Actions assumes
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:ref:refs/heads/main"
        }
      }
    }]
  })
}

# Permissions: ECR push + ECS deploy
resource "aws_iam_role_policy" "github_actions_deploy" {
  name = "ecr-ecs-deploy"
  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:ListTasks",
          "ecs:DescribeTasks"
        ]
        Resource = "*"
      },
      {
        Effect = "Allow"
        Action = "iam:PassRole"
        Resource = [
          var.ecs_task_execution_role_arn,
          var.ecs_task_role_arn
        ]
      }
    ]
  })
}

ECR Repository Terraform

resource "aws_ecr_repository" "app" {
  name                 = "my-app"
  image_tag_mutability = "IMMUTABLE"  # Prevent tag overwrites

  image_scanning_configuration {
    scan_on_push = true
  }

  encryption_configuration {
    encryption_type = "AES256"
  }

  tags = { Environment = var.environment }
}

# Lifecycle policy: keep only last 20 images
resource "aws_ecr_lifecycle_policy" "app" {
  repository = aws_ecr_repository.app.name

  policy = jsonencode({
    rules = [{
      rulePriority = 1
      description  = "Keep last 20 images"
      selection = {
        tagStatus   = "any"
        countType   = "imageCountMoreThan"
        countNumber = 20
      }
      action = {
        type = "expire"
      }
    }]
  })
}

output "ecr_repository_url" {
  value = aws_ecr_repository.app.repository_url
}

Complete GitHub Actions Workflow

Create .github/workflows/deploy.yml in your repository:

name: Deploy to ECS

on:
  push:
    branches: [main]

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

env:
  AWS_REGION: ap-south-1
  ECR_REPOSITORY: my-app
  ECS_CLUSTER: my-cluster
  ECS_SERVICE: my-service
  ECS_TASK_DEFINITION: my-task
  CONTAINER_NAME: my-app

jobs:
  deploy:
    name: Build and Deploy
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
          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 Docker image
        id: build-image
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build \
            --build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
            --build-arg GIT_SHA=${{ github.sha }} \
            -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG \
            -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_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@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

Multi-Stage Dockerfile for Production

Keep your production images small and secure:

# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build

# Stage 2: Production
FROM node:20-alpine AS production

# Security: run as non-root
RUN addgroup -g 1001 appgroup && \
    adduser -u 1001 -G appgroup -s /bin/sh -D appuser

WORKDIR /app

# Copy only what we need
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:appgroup /app/package.json ./

# Build metadata
ARG BUILD_DATE
ARG GIT_SHA
LABEL build.date=$BUILD_DATE
LABEL git.sha=$GIT_SHA

USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "dist/server.js"]

This multi-stage build reduces image size by 60-80% compared to a single-stage build, and running as non-root follows security best practices.

Environment Variables and Secrets

Never hardcode secrets in your Docker image or task definition. Use these approaches:

  • AWS Systems Manager Parameter Store — for configuration values
  • AWS Secrets Manager — for database passwords, API keys
  • ECS task definition secrets block — injects at container start
# In your ECS task definition (Terraform)
resource "aws_ecs_task_definition" "app" {
  family                   = "my-app"
  requires_compatibilities = ["FARGATE"]
  network_mode             = "awsvpc"
  cpu                      = 256
  memory                   = 512
  execution_role_arn       = aws_iam_role.ecs_execution.arn
  task_role_arn            = aws_iam_role.ecs_task.arn

  container_definitions = jsonencode([{
    name  = "my-app"
    image = "${aws_ecr_repository.app.repository_url}:latest"
    portMappings = [{
      containerPort = 3000
      protocol      = "tcp"
    }]
    environment = [
      { name = "NODE_ENV", value = "production" },
      { name = "PORT", value = "3000" }
    ]
    secrets = [
      {
        name      = "DATABASE_URL"
        valueFrom = aws_ssm_parameter.db_url.arn
      },
      {
        name      = "API_KEY"
        valueFrom = aws_secretsmanager_secret.api_key.arn
      }
    ]
    logConfiguration = {
      logDriver = "awslogs"
      options = {
        "awslogs-group"         = "/ecs/my-app"
        "awslogs-region"        = var.aws_region
        "awslogs-stream-prefix" = "ecs"
      }
    }
  }])
}

Deployment Strategies

Rolling Update (Default)

ECS launches new tasks with the new image, waits for them to pass health checks, then drains old tasks. Configure in your ECS service:

resource "aws_ecs_service" "app" {
  name            = "my-service"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.app.arn
  desired_count   = 3
  launch_type     = "FARGATE"

  deployment_configuration {
    minimum_healthy_percent = 100  # Never go below desired count
    maximum_percent         = 200  # Allow double during deploy
  }

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

  # ... network and load balancer config
}

Blue-Green Deployment

For zero-downtime deployments with instant rollback, use CodeDeploy with ECS:

resource "aws_ecs_service" "app" {
  # ... other config

  deployment_controller {
    type = "CODE_DEPLOY"
  }
}

Blue-green requires additional setup with CodeDeploy and two target groups, but gives you the ability to instantly switch traffic back if something goes wrong.

Rollback Strategy

Since we tag images with the git SHA, rollback is straightforward:

# Find the last working commit SHA
git log --oneline -5

# Option 1: Manual rollback via AWS CLI
aws ecs update-service \
  --cluster my-cluster \
  --service my-service \
  --task-definition my-task:PREVIOUS_REVISION \
  --force-new-deployment

# Option 2: Revert the commit and let the pipeline redeploy
git revert HEAD
git push origin main

# Option 3: Re-run a previous successful workflow in GitHub Actions UI

With deployment_circuit_breaker enabled (shown above), ECS automatically rolls back if new tasks fail health checks.

Monitoring Deployments

ECS Service Events

# Check recent deployment events
aws ecs describe-services \
  --cluster my-cluster \
  --services my-service \
  --query 'services[0].events[:10]' \
  --output table

CloudWatch Logs

# Tail logs from ECS tasks
aws logs tail /ecs/my-app --follow --since 5m

# Search for errors
aws logs filter-log-events \
  --log-group-name /ecs/my-app \
  --filter-pattern "ERROR" \
  --start-time $(date -d '1 hour ago' +%s000)

GitHub Actions Status

Add a deployment status badge to your README:

![Deploy](https://github.com/your-org/your-repo/actions/workflows/deploy.yml/badge.svg)

Complete Working Example

Here’s the full directory structure for a working project:

my-app/
  .github/
    workflows/
      deploy.yml          # GitHub Actions workflow (above)
  terraform/
    ecr.tf               # ECR repository
    ecs.tf               # ECS cluster, service, task definition
    iam.tf               # OIDC provider, GitHub Actions role
    variables.tf
    outputs.tf
  Dockerfile             # Multi-stage build (above)
  src/
    server.js            # Your application
  package.json

Frequently Asked Questions

Why use OIDC instead of access keys?

OIDC eliminates long-lived secrets entirely. No access keys to rotate, no risk of keys leaking in logs or repos. GitHub gets short-lived tokens (15 minutes) directly from AWS STS. It is the recommended approach by both AWS and GitHub.

My deployment takes too long — how do I fix it?

Common causes: health check grace period too low (ECS kills tasks before they start), large Docker images (use multi-stage builds), high deregistration delay (default 300s on ALB target groups — reduce to 30s), and slow health check endpoints. Check ECS service events for clues.

How do I rollback a failed deployment?

With circuit breaker enabled, ECS auto-rolls back. Manually: update the ECS service to the previous task definition revision. Since images are tagged with git SHA, you can always reference a known-good version.

Can I deploy to multiple environments?

Yes. Use GitHub Environments (Settings → Environments) to define staging and production with different AWS role ARNs, cluster names, and optional approval gates. Parameterize the workflow with environment-specific variables.

How much does ECR cost?

Storage: ~$0.10/GB/month. A typical app with 10 image versions at 200MB each costs about $0.20/month. Set up lifecycle policies to auto-delete old images and keep costs minimal.

AG

Akshay Ghalme

AWS DevOps Engineer with 3+ years building production cloud infrastructure. AWS Certified Solutions Architect. Currently managing a multi-tenant SaaS platform serving 1000+ customers.

More Guides & Terraform Modules

Every guide comes with a matching open-source Terraform module you can deploy right away.