Why this setup
This site is a static Hugo build. The obvious way to host that is aws s3 website-endpoint
with a public bucket. I didn’t do that, for a few reasons:
- No native HTTPS on the S3 website endpoint
- No custom domain without extra DNS gymnastics
- A public bucket is a wider blast radius than it needs to be
- No caching/edge distribution, so every visitor round-trips to
us-east-1(or wherever the bucket lives)
Instead, the stack is:
Route 53 (DNS) → CloudFront (CDN + TLS) → S3 (private bucket, OAC)
↑
GitHub Actions (build + deploy, OIDC auth)
The S3 bucket
The bucket is not configured for static website hosting and not public. It’s a plain private bucket that CloudFront reads from via Origin Access Control (OAC) — the modern replacement for the older Origin Access Identity (OAI).
Bucket policy only allows s3:GetObject from the specific CloudFront distribution:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCloudFrontServicePrincipal",
"Effect": "Allow",
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-site-bucket/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::123456789012:distribution/EXXXXXXXXXXXXX"
}
}
}
]
}
No public read access, no bucket-level ACLs to manage.
CloudFront
A single distribution in front of the bucket, with:
- Origin: the S3 bucket, accessed via OAC
- Viewer protocol policy: redirect HTTP → HTTPS
- Custom error responses: 404 →
/404.html(Hugo generates this), so broken links don’t show a raw S3 XML error - ACM certificate: issued in
us-east-1(required for CloudFront regardless of where the bucket lives), covering the apex domain andwww - Cache behavior: long TTL on hashed static assets (CSS/JS with fingerprinted filenames), shorter TTL on HTML so new posts show up quickly after a deploy
The deploy pipeline
GitHub Actions builds the Hugo site and syncs it to S3 on every push to main. The important
part isn’t the sync — it’s how the workflow authenticates to AWS.
No long-lived access keys are stored in GitHub. Instead, the workflow uses OIDC federation: GitHub issues a short-lived, cryptographically signed token identifying the specific repo and branch, and AWS IAM trusts that token to assume a role — scoped to only this repo, only this branch, only the permissions needed to sync this one bucket and invalidate this one distribution.
name: Deploy site
on:
push:
branches: [main]
permissions:
id-token: write # required for OIDC
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: peaceiris/actions-hugo@v3
with:
hugo-version: '0.139.4'
extended: true
- run: hugo --minify
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy-site
aws-region: ap-southeast-1
- run: aws s3 sync ./public s3://my-site-bucket --delete
- run: |
aws cloudfront create-invalidation \
--distribution-id EXXXXXXXXXXXXX \
--paths "/*"
The IAM role’s trust policy pins the token audience and repo/branch subject claim, so a token minted for a different repository — even within the same GitHub org — can’t assume it. That’s the whole point of OIDC here: no secret to leak, no key to rotate, and the blast radius of a compromised workflow is scoped to “sync one bucket and invalidate one distribution,” not “whatever this AWS account’s long-lived key can touch.”
What I’d change at scale
For a single personal site this is already more infrastructure than strictly necessary — a public S3 website endpoint would work fine. I built it this way because:
- It’s a realistic pattern for anything beyond a toy project
- It gave me something concrete to write about (this post)
- The incremental cost over the “simple” version is basically zero — CloudFront’s free tier covers this traffic level comfortably
If this were a team-owned production site rather than a personal blog, the next additions would be: a staging distribution behind a separate subdomain, CloudFront Functions for redirect logic instead of baking it into Hugo, and a WAF web ACL in front of CloudFront for basic rate limiting.
Repo with the full Terraform for this stack (bucket, OAC, distribution, ACM cert, IAM OIDC role) is linked on the projects page.