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.”
Deploying this yourself, step by step
Everything above explains the shape of the stack. This part is the actual sequence to stand it up — written for someone doing this for the first time, not just a command dump to copy without knowing what each one does.
1. Get the prerequisites in place
- An AWS account, with permissions to create S3, CloudFront, ACM, Route 53, and IAM resources. For a first pass, admin or power-user on your own account is simplest — you can narrow it down later.
- Terraform installed locally (1.5 or newer).
- The AWS CLI, installed and authenticated — either
aws configure(access key/secret) oraws sso loginif your org uses IAM Identity Center. Confirm it worked with:This prints the account ID and identity Terraform will use. Worth checking every time before youaws sts get-caller-identityapplyanything — it’s the last checkpoint before real, billable resources get created. - A domain name, with its DNS already delegated to a Route 53 hosted zone. It doesn’t need to be registered through AWS, but the certificate step later depends on Route 53 already managing the zone — if you’re starting from a domain registered elsewhere (Namecheap, GoDaddy, etc.), create the hosted zone in Route 53 first and update your registrar’s nameservers to point at it. That change can take a few hours to propagate, so it’s worth doing this first.
- A GitHub repo to hold the site’s source. Private is fine — nothing sensitive ends up in it, since (as covered above) no AWS keys are ever stored in the repo itself.
2. Write or grab the Terraform
The bucket, CloudFront distribution, ACM certificate, and OIDC role described above aren’t
hypothetical — they’re four real Terraform resources (aws_s3_bucket,
aws_cloudfront_distribution, aws_acm_certificate, aws_iam_openid_connect_provider), plus
the supporting pieces around them (bucket policy, Route 53 records, the IAM role’s trust
policy). Rather than retyping all of it here, the full working version is linked at the bottom
of this post — clone that, or use it as a reference while writing your own.
3. Initialize and plan
From the folder holding that Terraform:
terraform init
This downloads the AWS provider and sets up local state. Then, always plan before applying:
terraform plan \
-var="domain_name=yourdomain.com" \
-var="bucket_name=your-unique-bucket-name" \
-var="github_repo=yourusername/your-repo-name"
A couple of things worth knowing about those variables: bucket_name has to be globally
unique across every AWS account, not just yours — if it’s taken, Terraform will fail loudly
later at apply time, so pick something specific rather than a generic name. github_repo is
what scopes the OIDC trust policy — it has to match the exact repo you’ll push to, or GitHub
Actions won’t be able to assume the role later.
Read through what plan says it’s going to create. For a first run, that should be somewhere
around ten to twelve resources, nothing marked for destruction. If anything looks unexpected,
stop and figure out why before moving on — plan is a dry run specifically so mistakes are
cheap to catch here.
4. Apply it
terraform apply \
-var="domain_name=yourdomain.com" \
-var="bucket_name=your-unique-bucket-name" \
-var="github_repo=yourusername/your-repo-name"
Type yes when prompted. This takes a few minutes — most of that time is Terraform waiting on
the ACM certificate’s DNS validation, which it polls for automatically rather than needing you
to do anything.
When it finishes, run:
terraform output
That prints the values the workflow file needs next: the bucket name, the CloudFront distribution ID, and the OIDC role’s ARN.
5. Wire those outputs into the GitHub Actions workflow
Open the workflow file (the one shown earlier in this post) and replace the placeholder values
— S3_BUCKET, CLOUDFRONT_DISTRIBUTION_ID, and AWS_ROLE_ARN — with the real ones from
terraform output. This is the step that actually connects the AWS infrastructure to the CI/CD
pipeline; skipping it means the workflow will run but fail trying to sync to a bucket that
doesn’t exist.
6. Push and watch it deploy
Commit the workflow file change, push to main, and check the Actions tab in your GitHub
repo. The run should build the Hugo site, authenticate to AWS via OIDC, sync the built files to
S3, and invalidate the CloudFront cache — all without you touching AWS credentials again after
this point.
If the run fails at the “configure AWS credentials” step specifically, it’s almost always one
of two things: the role ARN doesn’t match what Terraform actually created, or the github_repo
variable from step 3 doesn’t exactly match the repo you pushed to.
7. Verify
Visit https://yourdomain.com. HTTPS should just work — CloudFront and ACM handle that
automatically once the certificate’s validated. If the page doesn’t load, it’s almost always
DNS rather than anything AWS-side at this point — check that the Route 53 A record (an alias
pointing at the CloudFront distribution) has actually propagated, with something like
dig yourdomain.com or an external tool like whatsmydns.net.
From here, every future push to main that touches the site rebuilds and redeploys it
automatically — no manual aws s3 sync ever again.
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.