How to Set Up CI/CD for AWS with GitHub Actions — No Access Keys Needed
To set up CI/CD for AWS with GitHub Actions without access keys, use OIDC (OpenID Connect) authentication. Register GitHub as an OIDC identity provider in IAM, create a role with a trust policy scoped to your repo and branch, and use the aws-actions/configure-aws-credentials action with role-to-assume. No long-lived credentials stored anywhere — temporary tokens are issued per workflow run and expire automatically.
If you are deploying to AWS from GitHub Actions, there is a good chance you have an IAM access key sitting in your GitHub secrets right now. It works. It is also a security risk that most teams accept because they do not know there is a better way.
The problem with access keys: they are long-lived credentials. They do not expire unless you rotate them manually. If they leak through a log, a fork, or a compromised dependency, someone has access to your AWS account until you notice and revoke them.
OIDC fixes this entirely. GitHub proves its identity to AWS, AWS issues temporary credentials that last only for the duration of the workflow run, and there are zero secrets stored anywhere.
What You Will Build
- OIDC identity provider — registers GitHub as a trusted identity in your AWS account
- IAM role — GitHub Actions assumes this role to get temporary AWS credentials
- ECR repository — stores your Docker images with scanning enabled
- GitHub Actions workflow — builds, scans, pushes, and deploys with zero stored credentials
How OIDC Works (Simple Version)
The flow is straightforward:
- Your GitHub Actions workflow starts and requests an OIDC token from GitHub
- The workflow sends this token to AWS STS (Security Token Service)
- AWS verifies the token against the OIDC provider you registered
- If the token is valid AND the repo/branch matches your trust policy, AWS issues temporary credentials
- The workflow uses these credentials to push to ECR, deploy to ECS, etc.
- When the job ends, the credentials expire automatically
No keys stored anywhere. No secrets to rotate. No risk of credential leaks.
Prerequisites
- An AWS account
- A GitHub repository
- Terraform 1.5 or later
- A Docker application you want to deploy
Step 1: Create the OIDC Identity Provider in AWS
First, tell AWS to trust GitHub as an identity provider:
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
tags = {
Name = "github-actions-oidc"
}
}
The client_id_list with "sts.amazonaws.com" is the audience value that GitHub includes in its OIDC tokens. The thumbprint is GitHub's certificate fingerprint — AWS uses it to verify the token really came from GitHub.
You only need to create this OIDC provider once per AWS account. Even if you have 50 repos, they all share the same provider. Each repo gets its own IAM role with a specific trust policy.
Step 2: Create the IAM Role for GitHub Actions
This is where you control which repos and branches can assume the role:
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_GITHUB_USERNAME/YOUR_REPO:ref:refs/heads/main"
}
}
}
]
})
}
# Attach permissions for ECR and ECS
resource "aws_iam_role_policy" "deploy_permissions" {
name = "deploy-permissions"
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",
"ecr:DescribeImageScanFindings"
]
Resource = "*"
},
{
Effect = "Allow"
Action = [
"ecs:UpdateService",
"ecs:DescribeServices",
"ecs:RegisterTaskDefinition",
"ecs:DescribeTaskDefinition"
]
Resource = "*"
},
{
Effect = "Allow"
Action = "iam:PassRole"
Resource = "*"
Condition = {
StringEquals = {
"iam:PassedToService" = "ecs-tasks.amazonaws.com"
}
}
}
]
})
}
The sub condition is critical. It restricts the role to a specific repo AND a specific branch. Change YOUR_GITHUB_USERNAME/YOUR_REPO to your actual values. The :ref:refs/heads/main part means only pushes to the main branch can trigger deployments.
Step 3: Create the ECR Repository
resource "aws_ecr_repository" "app" {
name = var.app_name
image_tag_mutability = "MUTABLE"
image_scanning_configuration {
scan_on_push = true
}
encryption_configuration {
encryption_type = "AES256"
}
tags = {
Name = var.app_name
}
}
# Clean up old images — keep only the last 10
resource "aws_ecr_lifecycle_policy" "app" {
repository = aws_ecr_repository.app.name
policy = jsonencode({
rules = [
{
rulePriority = 1
description = "Keep only last 10 images"
selection = {
tagStatus = "any"
countType = "imageCountMoreThan"
countNumber = 10
}
action = {
type = "expire"
}
}
]
})
}
Image scanning on push catches known vulnerabilities in your base images and dependencies. The lifecycle policy prevents ECR from accumulating hundreds of old images and running up storage costs.
Step 4: Write the GitHub Actions Workflow
This is the complete workflow file. Create it at .github/workflows/deploy.yml:
name: Build and Deploy
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
jobs:
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 image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
- name: Wait for image scan
run: |
aws ecr wait image-scan-complete \
--repository-name $ECR_REPOSITORY \
--image-id imageTag=${{ github.sha }}
- name: Check scan results
run: |
CRITICAL=$(aws ecr describe-image-scan-findings \
--repository-name $ECR_REPOSITORY \
--image-id imageTag=${{ github.sha }} \
--query 'imageScanFindings.findingSeverityCounts.CRITICAL' \
--output text)
if [ "$CRITICAL" != "None" ] && [ "$CRITICAL" != "0" ]; then
echo "CRITICAL vulnerabilities found: $CRITICAL"
exit 1
fi
- name: Deploy to ECS
run: |
aws ecs update-service \
--cluster $ECS_CLUSTER \
--service $ECS_SERVICE \
--force-new-deployment
The permissions: id-token: write block is essential. Without it, GitHub will not generate the OIDC token and the AWS credentials step will fail with a confusing error about missing tokens.
Step 5: Restrict Access by Branch
You can create separate roles for different environments:
# Production — only main branch
"token.actions.githubusercontent.com:sub" = "repo:your-org/your-repo:ref:refs/heads/main"
# Staging — only develop branch
"token.actions.githubusercontent.com:sub" = "repo:your-org/your-repo:ref:refs/heads/develop"
# Any branch (for building/testing only, not deploying)
"token.actions.githubusercontent.com:sub" = "repo:your-org/your-repo:*"
The production deployment role should have the strictest condition — only main branch. The staging role can allow the develop branch. A build-only role with limited permissions (just ECR push) can allow any branch.
Common Mistakes to Avoid
- Forgetting the permissions block in the workflow. Without
id-token: write, the OIDC token is never generated. The error message does not clearly tell you this is the problem. - Wrong audience value. The
client_id_listin the OIDC provider must bests.amazonaws.com. Some older tutorials use a different value that no longer works. - Too broad IAM trust policy. Using
repo:your-org/your-repo:*for a production deployment role means any branch, any PR, and any tag can deploy. Lock it down to the specific branch. - Not restricting by repository. If you use a wildcard for the repository name in the trust policy, any repo in your GitHub organization could assume the role. Always specify the exact repo.
- Skipping image scanning. ECR scanning is free and catches known CVEs in your base images. There is no reason not to enable it. Failing the pipeline on critical vulnerabilities prevents you from deploying known-vulnerable images.
Frequently Asked Questions
What is OIDC and why should I use it instead of access keys?
OIDC lets GitHub Actions prove its identity to AWS without stored credentials. Instead of long-lived access keys in GitHub secrets, GitHub requests temporary credentials from AWS STS for each workflow run. These expire automatically when the job finishes. No keys to rotate, no keys to leak.
Does GitHub Actions OIDC work with any AWS service?
Yes. Once GitHub assumes the IAM role via OIDC, it has whatever permissions that role grants. You can deploy to ECS, EKS, S3, Lambda, CloudFormation — the IAM policy controls what the workflow can do.
Can I restrict which branches can deploy to production?
Yes. The IAM trust policy includes a condition on the sub claim that specifies which branches are allowed. Set it to ref:refs/heads/main for production so only the main branch can deploy.
What happens if someone forks my repo?
They cannot assume your AWS role. The trust policy includes your specific repository name. A fork has a different name (their-username/repo-name), so it is blocked by the condition.
Do I still need to store any secrets in GitHub?
You do not need AWS access keys. You do need the AWS account ID and IAM role ARN in your workflow, but these are identifiers, not secrets. You can put them directly in the workflow YAML or as GitHub variables.
Skip the Manual Setup — Use the Terraform Module
The OIDC provider, IAM role, ECR repository, lifecycle policy, and scanning configuration — all of it is in one Terraform module.
module "cicd" {
source = "github.com/akshayghalme/terraform-cicd-pipeline"
app_name = "my-app"
github_repo = "your-username/your-repo"
allowed_branch = "main"
}
Run terraform apply, copy the role ARN into your GitHub Actions workflow, and you are done. Zero access keys, full security.