Skip to main content

GitHub OIDC + AWS IAM + Terraform: A Practical Guide (and Pain Log)

I wanted to deploy my Hugo website using Terraform and GitHub Actions — securely — with least privilege — without Route 53, using my domain on Porkbun, and leveraging AWS Free Tier services.

Day 1 — AWS Account Setup + Role Plumbing

Started from scratch.

  • Created the AWS account
  • Set up MFA, secure root, all that
  • Made a single Admin IAM user (for CLI/debug, not daily use)

Then I created a role: GitHubAction-AssumeRoleWithAction.

This is the entry point for OIDC — GitHub Actions assumes this via token.actions.githubusercontent.com. It has just enough permissions to assume a more privileged role later.

In other words, it doesn’t do anything by itself — it just opens the door.

I was planning to:

  • Host the site on S3
  • Serve it via CloudFront
  • Use Terraform for provisioning
  • Avoid using AWS managed policies (for real, this time)
  • Deploy from GitHub Actions using OIDC (no long-lived creds)

Day 2 — Chaining Roles, Chasing Permissions

Created the real workhorse role: role_static_site_deploy.

Only GitHubAction-AssumeRoleWithAction can assume it. This is the role that actually creates buckets, uploads files, issues invalidations, requests certs, etc.

Instead of attaching AmazonS3FullAccess (gross), I wrote a bunch of inline policies. Stuff like:

  • Minimal S3 perms to upload the site
  • CloudFront perms to update the distribution
  • ACM cert request access (scoped to us-east-1)
  • Tagging permissions (because CloudFront demands them for reasons)

Yes, it was verbose. Yes, I missed a few List* actions and had to rerun the pipeline a bunch of times to fix the AccessDenied errors. Yes, I regretted all of it halfway through. But hey, least privilege means least regret later, right?

Day 3 — Terraform Reminded Me Who’s Really in Charge

Here’s what hit me like a brick: Terraform backends authenticate before the provider block kicks in.

So all that role-chaining I set up? Doesn’t help with backend state.

I had to attach direct S3 state bucket access to the OIDC entrypoint role (GitHubAction-AssumeRoleWithAction) just for terraform init to work. No way around it.

Got the pipeline running and finally pushed an index.html to the S3 bucket. Victory?

Not quite.

CloudFront said nope.

Turns out I hadn’t updated the S3 bucket policy to allow CloudFront (via OAC) to read from it. Fixed that by scoping access to the CloudFront distribution’s service principal. Only after doing that did I get actual content served.

DNS? Porkbun.

I’m not using Route 53. Everything’s on Porkbun.

The process was “simple”:

  • Add a CNAME from my subdomain → CloudFront distribution
  • Wait 10 minutes
  • Refresh page: nothing
  • Go outside, bike around the block
  • Come back, refresh: still nothing
  • Take a shower, cook dinner
  • Refresh: still nothing
  • Forget about it
  • Come back an hour later and… it works now

¯\(ツ)

Where It’s At Now

  • GitHub Actions (via OIDC) deploys a default index.html through Terraform
  • Content lands on an S3 bucket
  • CloudFront serves it securely
  • Bucket is private; CloudFront access is locked via OAC
  • Porkbun DNS is pointed and working
  • All roles use inline policies with no managed policies attached (🎉)

Takeaways

  • OIDC with GitHub is worth it. No more long-lived AWS keys.
  • IAM role chaining is powerful — but adds complexity to debug.
  • Terraform backends need their own IAM love.
  • Porkbun is great, but manual cleanup of DNS records before deploy is sometimes required.
  • ACM validation takes time. Don’t expect instant certs.
  • Least privilege works, but prepare to iterate and swear a little.

What’s Next

  • Split the pipeline:
    • Infra first
    • Then deploy Hugo content updates via GitHub Actions
  • Keep refining policies