GitHub Actions Deploy to AWS: OIDC, IAM Roles, and Real Workflows
In 2021, GitHub released OIDC support for Actions — and quietly made static AWS access keys in CI/CD pipelines obsolete. The old approach required storing AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY as GitHub secrets, rotating them manually, and hoping nobody accidentally logged them. The new approach is a trust relationship: your GitHub Actions workflow requests temporary credentials from AWS STS using a signed JWT from GitHub, gets short-lived credentials good for the job duration, and never touches a long-term key.
Most teams are still using the old approach. This guide covers the right way to deploy to AWS from GitHub Actions in 2026. If you’re evaluating CI/CD platforms more broadly, the GitHub Actions vs GitLab CI comparison covers how the two differ on runners, pricing, security scanning, and self-hosting.
OIDC: How It Actually Works
GitHub runs an OpenID Connect identity provider at token.actions.githubusercontent.com. When a workflow job starts, GitHub issues a signed JWT token containing claims about the workflow: the repository, the branch, the environment, the triggering event. Your workflow can exchange that token for AWS credentials by calling STS AssumeRoleWithWebIdentity.
For this to work, AWS needs to trust GitHub’s OIDC provider, and the IAM role’s trust policy needs to specify which GitHub repository and workflow context can assume it. The credentials that come back are temporary — default session duration is one hour — and scoped to exactly what the role’s permission policies allow.
Setting up the OIDC provider in AWS (one-time per account):
# Get GitHub's OIDC thumbprint
THUMBPRINT=$(openssl s_client -connect token.actions.githubusercontent.com:443 \
-showcerts </dev/null 2>/dev/null | \
openssl x509 -fingerprint -sha1 -noout | \
sed 's/.*=//; s/://g' | tr '[:upper:]' '[:lower:]')
# Create the OIDC provider
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com \
--thumbprint-list $THUMBPRINT
Or with Terraform:
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}
That thumbprint hash comes from GitHub’s TLS leaf certificate. The openssl command above extracts it automatically. It’s stable but worth verifying if you’re reading this a year from now — certificate rotations do happen.
The IAM Role Trust Policy
The trust policy is where you specify which GitHub repos and branches can assume the role. Be precise here — a trust policy that accepts any repository in your organization is a significant security risk.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:*"
}
}
}
]
}
The sub condition claim controls which repository and context can assume the role. repo:my-org/my-repo:* allows any branch or event in that repo. Tighten it further with specific branch restrictions:
"token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/main"
This limits the role to workflows running on the main branch. A workflow on a feature branch gets denied — which is exactly what you want for production deployments. The trust policy mechanics are covered in detail in the AWS IAM roles and policies guide, including how the condition keys work and what happens when cross-account trust is involved.
Deploying a Lambda Function
A typical Lambda deployment workflow: test on push to any branch, deploy on merge to main.
name: Deploy Lambda
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
id-token: write # Required for OIDC
contents: read
env:
AWS_REGION: us-east-1
FUNCTION_NAME: my-lambda-function
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install and test
run: |
npm ci
npm test
deploy:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
environment: production
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-deploy-lambda
aws-region: $
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Build
run: npm ci && npm run build
- name: Deploy to Lambda
run: |
zip -r function.zip dist/ node_modules/
aws lambda update-function-code \
--function-name $ \
--zip-file fileb://function.zip
- name: Wait for update
run: |
aws lambda wait function-updated \
--function-name $
- name: Publish version
run: |
aws lambda publish-version \
--function-name $
Two things worth noting. The permissions: id-token: write block at the workflow level is required — without it, GitHub doesn’t issue the OIDC token and configure-aws-credentials fails silently. The environment: production block on the deploy job enables GitHub Environments, which can require manual approval before the job runs.
Deploying a Static Site to S3
Static sites are the straightforward case — four IAM actions total. s3:PutObject and s3:DeleteObject handle the sync, s3:ListBucket lets the CLI see what’s already there before deciding what to upload or delete, and cloudfront:CreateInvalidation busts the CDN cache after the sync finishes. Scope everything to the specific bucket ARN, not a wildcard.
name: Deploy Frontend
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
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-deploy-frontend
aws-region: us-east-1
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Build
run: npm ci && npm run build
- name: Sync to S3
run: |
aws s3 sync dist/ s3://my-bucket/ \
--delete \
--cache-control "max-age=31536000,immutable" \
--exclude "index.html"
# index.html with shorter cache
aws s3 cp dist/index.html s3://my-bucket/index.html \
--cache-control "no-cache"
- name: Invalidate CloudFront
run: |
aws cloudfront create-invalidation \
--distribution-id $ \
--paths "/*"
The split sync and cp approach sets different cache headers for static assets versus index.html. Hashed assets (bundles with content hashes in filenames) can be cached aggressively; index.html should never be cached by CDN so users always get the current version pointing at the right asset hashes.
Deploying to ECS
ECS deployments need a bit more: build and push to ECR, then update the service.
name: Deploy to ECS
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
env:
AWS_REGION: us-east-1
ECR_REPOSITORY: my-app
ECS_SERVICE: my-service
ECS_CLUSTER: my-cluster
CONTAINER_NAME: app
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
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-deploy-ecs
aws-region: $
- name: Login to ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build, tag, and push image
id: build-image
env:
ECR_REGISTRY: $
IMAGE_TAG: $
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
- name: Download task definition
run: |
aws ecs describe-task-definition \
--task-definition my-task \
--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: $
image: $
- name: Deploy to ECS
uses: aws-actions/amazon-ecs-deploy-task-definition@v2
with:
task-definition: $
service: $
cluster: $
wait-for-service-stability: true
The wait-for-service-stability: true flag makes the action wait until ECS confirms the new task definition is running before the job completes. Without it, the deploy job finishes immediately and you won’t know about a failed deployment until you check ECS or your CloudWatch alarms fire.
Environments and Approval Gates
GitHub Environments let you gate deployments behind manual approval, branch protection, and wait timers. Configure them at Settings > Environments in your repository.
jobs:
deploy-staging:
runs-on: ubuntu-latest
environment: staging
# No approval required — deploys automatically
deploy-production:
runs-on: ubuntu-latest
environment: production
needs: deploy-staging
# Requires manual approval from configured reviewers
The environment: production job pauses until a configured reviewer approves from the GitHub Actions UI. Reviewers get a notification, see the commit diff, and click Approve. Only then does the deploy job run. For compliance-sensitive deployments this is a lightweight audit trail without requiring a separate approval system.
Environment-specific secrets and variables are scoped to the environment. Production database passwords, production IAM role ARNs, production CloudFront distribution IDs — none of these live at the repository level where any branch workflow could access them.
Caching Dependencies
Actions billing is per compute minute. Slow workflows add up. Caching node_modules, pip packages, or Maven artifacts can cut 2-5 minutes off build times.
- name: Cache node_modules
uses: actions/cache@v4
with:
path: ~/.npm
key: $-node-$
restore-keys: |
$-node-
hashFiles hashes the lock file to generate the cache key. Change package-lock.json and npm downloads fresh dependencies on the next run; leave it unchanged and the cache restores in a few seconds before npm ci finishes in milliseconds. The same pattern works for Python — cache ~/.cache/pip keyed on requirements.txt — and Maven — cache ~/.m2 keyed on pom.xml.
IAM Role Scoping
Each deployment target should have its own IAM role with the minimum required permissions. Don’t create one github-deploy role with broad permissions that all workflows share.
Lambda is clean: UpdateFunctionCode and PublishVersion scoped to the function ARN, plus GetFunction to poll status. Three actions, one function.
ECS is messier. GetAuthorizationToken can’t be scoped to a resource — it’s always account-level. The ECR push actions (BatchCheckLayerAvailability, PutImage, InitiateLayerUpload, UploadLayerPart, CompleteLayerUpload) scope to the repository. RegisterTaskDefinition is account-level too. UpdateService and the Describe calls scope to the cluster. Then there’s iam:PassRole pointed at the task execution role — that’s the one that bites people on their first ECS deploy. Without it, the UpdateService call succeeds, the new task never launches, and you’re reading ECS events wondering what went wrong.
The iam:PassRole permission trips people up. ECS needs to assume the task execution role when launching tasks, and your deploy role needs iam:PassRole to pass that role to ECS. Without it, ecs:UpdateService succeeds but the task fails to launch. Scoping iam:PassRole tightly to the task execution role ARN — not iam:PassRole on * — keeps the blast radius small.
For secrets management in your deployed services, the AWS Secrets Manager guide covers how Lambda and ECS tasks access secrets at runtime without putting them in environment variables.
Debugging OIDC Failures
OIDC failures produce cryptic errors. Common ones:
Error: Not authorized to perform sts:AssumeRoleWithWebIdentity — the trust policy condition doesn’t match the workflow context. Check whether the sub claim matches the branch or event type. A trust policy restricting to refs/heads/main will deny a PR workflow running on refs/pull/123/merge.
Error: The security token included in the request is expired — the job ran longer than the role’s MaxSessionDuration. Default is one hour. Set it to 4 hours for long-running deploy jobs.
The “Invalid token” error almost always means the id-token: write permission block is missing. GitHub won’t issue an OIDC token without it, and configure-aws-credentials fails silently in a way that reads like a token problem. Add it at workflow level or on each individual job — whichever scope the job runs in.
“Could not fetch OIDC token” is different. That’s a forked repository problem. Fork PRs don’t receive OIDC tokens from GitHub — it’s intentional, a security boundary preventing fork contributors from accessing production credentials. Your deploy job should never run on pull_request events from forks; restrict it to push events on protected branches only.
Switching from static keys to OIDC removes a whole category of credential leakage risk. The initial setup takes 20 minutes. The trust you get — no long-term credentials in GitHub, automatic expiry, per-repo and per-branch scoping — is worth every minute of it.
Comments