GitHub Actions for AWS deployments. A small, sane setup.
This is the GitHub Actions starter I want everyone on my team to use for AWS deploys. No long-lived access keys, no plaintext secrets, no magic. The whole thing is about 60 lines of YAML.
This is not a rerun of the older Lambda-and-GitHub post on this blog. That one was about reacting to GitHub webhooks from AWS. This is the inverse: deploying to AWS from GitHub. Different direction, different problem.
The old way
For years the standard pattern was:
- Create an IAM user.
- Generate an access key for it.
- Paste the key into a GitHub repository secret.
- Hope the key never leaks. Rotate when you remember.
It worked. It was also the source of basically every “our production access keys ended up in a public repo” incident.
The new way: OIDC
GitHub Actions can be an OIDC identity provider. Same idea as IRSA on EKS — your workflow gets a short-lived JWT signed by GitHub, AWS trusts that JWT under specific conditions, the workflow assumes a role with no long-lived secret ever existing.
The pieces:
- Register
token.actions.githubusercontent.comas an OIDC provider in IAM. - Create an IAM role whose trust policy allows assumption from that provider, scoped to your repo and (ideally) a specific branch or environment.
- In the workflow, call
aws-actions/configure-aws-credentials@v4withrole-to-assume. No secrets.
The trust policy
The least-permissive version pins the role to a single repo and a single ref:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::1111…: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:yourorg/my-service:ref:refs/heads/main"
}
}
}]
}For pull-request previews you would broaden the sub pattern, for example:
"token.actions.githubusercontent.com:sub": "repo:yourorg/my-service:pull_request"And for a tag-driven release flow:
"token.actions.githubusercontent.com:sub": "repo:yourorg/my-service:ref:refs/tags/v*"Whatever you do, don’t use repo:org/*. That trusts every workflow in every repo of the org. People do this. People shouldn’t.
The workflow
This is the whole thing. .github/workflows/deploy.yml:
name: deploy
on:
push:
branches: [main]
permissions:
id-token: write # required for OIDC
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Assume deploy role
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::1111…:role/github-deploy-my-service
aws-region: eu-west-1
- name: Build & push image
run: |
ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
REPO=$ACCOUNT.dkr.ecr.eu-west-1.amazonaws.com/my-service
aws ecr get-login-password --region eu-west-1 \
| docker login --username AWS --password-stdin $REPO
docker build -t $REPO:${{ github.sha }} .
docker push $REPO:${{ github.sha }}
- name: Deploy
run: |
aws ecs update-service \
--cluster prod \
--service my-service \
--force-new-deploymentTwo things worth highlighting:
- The
permissions:block at the top is mandatory. Withoutid-token: writethe OIDC token isn’t minted, and you’ll get a confusing 403 from STS. environment: productionhooks this job into GitHub’s environments feature, which lets you require approvals, restrict deploys to specific branches, and have per-environment OIDC subjects. Use it.
The bit I keep forgetting
GitHub Actions runners can be slow to publish their token to STS during high-load periods. configure-aws-credentials handles the retry for you, but if you write the assume-role call by hand using the AWS CLI you’ll occasionally see InvalidIdentityToken on the first try. The action is doing more for you than it looks like.
That’s it. Sixty lines, zero long-lived secrets, scoped to one repo and one branch. If your AWS deploy workflow still uses access keys, this is the migration to do this quarter.