Build a site like this with AI, part 2 of 4
Keyless AWS deploys with GitHub OIDC
GitHub's OIDC tokens, a deploy role scoped to one site, and a short workflow that builds and syncs to S3.
The obvious way to deploy from GitHub is to create access keys and paste them into GitHub secrets. As an AWS professional I don’t like long-lived keys, and I especially don’t like root account keys, which are the quickest way to get a pipeline working and the worst place to leave them. GitHub Actions can prove who it is to AWS with a short-lived signed token instead, so there is no key to leak or rotate.
It takes three pieces: a one-time trust between GitHub and AWS, a narrow role for each site, and a short workflow in each site’s repo. The first two are CloudFormation stacks that live in my private infrastructure repo.
1. Trust GitHub once per account
This registers GitHub as an identity provider. An account can only have one, so keep it in its own stack:
GitHubProvider:
Type: AWS::IAM::OIDCProvider
Properties:
Url: https://token.actions.githubusercontent.com
ClientIdList: [sts.amazonaws.com]
2. Give each repo its own role
Each site gets a role that only the main branch of its repo can assume:
Condition:
StringEquals:
token.actions.githubusercontent.com:aud: sts.amazonaws.com
StringLike:
token.actions.githubusercontent.com:sub:
- !Sub 'repo:${GitHubRepo}:ref:refs/heads/${Branch}'
- !Join ['', ['repo:', !Select [0, !Split ['/', !Ref GitHubRepo]], '@*/',
!Select [1, !Split ['/', !Ref GitHubRepo]], '@*:ref:refs/heads/', !Ref Branch]]
The second sub pattern is there because of a real failure. My first deploy died with Not authorized to perform sts:AssumeRoleWithWebIdentity. The role trusted repo:rclegg/portfolio:ref:refs/heads/main, but newer GitHub repos send the claim with immutable owner and repo ids, like repo:rclegg@8725793/portfolio@1432581754:ref:refs/heads/main. Allowing both forms fixed it.
The role is limited to the main branch of one repo, and its permissions match what a deploy does: read its own stack’s outputs, sync its own bucket and invalidate the CloudFront cache.
3. Build and sync from the site’s repo
The workflow in each site’s repo builds the site, assumes the role and syncs the files. This is the one for this site, which builds Astro and the PDF resume first:
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7.0.1
- uses: actions/setup-node@v7.1.0
with: {node-version: 22}
- name: Build
run: cd web && npm ci && npx playwright install --with-deps chromium && npm run build
- uses: aws-actions/configure-aws-credentials@v6.3.0
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/gha-portfolio-site
aws-region: us-east-1
- name: Sync and invalidate
run: |
out() { aws cloudformation describe-stacks --stack-name portfolio-site --query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" --output text; }
aws s3 sync web/dist "s3://$(out BucketName)" --delete
aws cloudfront create-invalidation --distribution-id "$(out DistributionId)" --paths '/*'
id-token: write is the line that lets the job request an OIDC token. The account id sits in a repository secret, so it never appears in the code. The bucket name and distribution id come from the stack’s outputs, which means the workflow needs no hard-coded names. lease_tracker uses the same workflow without the build step.
Adding a new site
- Add a site stack and a deploy role for the repo to the infrastructure repo, and deploy them.
- Add the
AWS_ACCOUNT_IDsecret to the new repo. - Copy the workflow above, change the role and stack names, and push to main.
The stack is created once from the infrastructure repo. After that, every push to the site’s repo only builds and syncs.