CI/CD Best Practices for AWS ECS with GitHub Actions

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 podtells you exactly which code is runningImmutability: 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
Developer creates a PR with infrastructure changes
GitHub Actions runs
terraform plan: the output shows exactly what will be created, modified, or destroyedTeam reviews the plan in the PR comments
PR is approved and merged:
terraform applyruns automaticallyInfrastructure 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 planmust passNo 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.



