GitHub Actions CI/CD for Docker to AWS ECR + ECS — Complete Pipeline
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:
- Developer pushes code to
mainbranch - GitHub Actions triggers the workflow
- GitHub authenticates to AWS via OIDC (no access keys)
- Workflow builds a Docker image with multi-stage build
- Tags the image with the git commit SHA
- Pushes the image to ECR
- Updates the ECS task definition with the new image tag
- Deploys to ECS Fargate with a rolling update
- 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
secretsblock — 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:

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.