We migrated a Hugo static site from a self-hosted nginx container on a local server to S3 + CloudFront. The motivation was simple: a static site has no business running on a server we have to patch. The migration took a few hours and involved four gotchas that aren’t obvious from the AWS documentation.
This is a record of what we did and what tripped us up.
The setup
- Hugo static site (PaperMod theme)
- S3 bucket with all public access blocked — Origin Access Control (OAC) only
- CloudFront distribution with ACM SSL cert
- Cloudflare DNS, gray cloud (DNS-only)
- Gitea self-hosted repo with a webhook-triggered deploy container on-prem
The deploy flow on push: Gitea fires a webhook → container on saturn pulls the repo, runs hugo --minify, syncs to S3, invalidates CloudFront.
Gotcha 1: S3 bucket names cannot have underscores
The bucket name needs to be DNS-compliant. Underscores are not allowed. If the bucket is ever used for static website hosting via a CNAME, the bucket name has to match the domain exactly — and domain names can’t have underscores.
We wanted a name with underscores. We used hyphens instead.
Gotcha 2: ACM certificate must be in us-east-1
CloudFront only reads ACM certificates from us-east-1, regardless of where your bucket or other resources are. If you request the cert in another region, it won’t appear in the CloudFront certificate dropdown and the CLI will reject it.
aws acm request-certificate \
--domain-name example.com \
--subject-alternative-names www.example.com \
--validation-method DNS \
--region us-east-1
DNS validation records need to be added to Cloudflare before the cert issues. ACM polls for them — once they’re in, issuance takes a few minutes.
Gotcha 3: Cloudflare must be DNS-only (gray cloud)
If Cloudflare is proxying traffic (orange cloud) and CloudFront is also doing SSL via ACM, the two SSL layers conflict. Cloudflare terminates the connection and tries to re-initiate to CloudFront — but CloudFront expects the original Host header, and Cloudflare’s proxy rewrites it.
Set every DNS record pointing to CloudFront to DNS-only (gray cloud). Let CloudFront handle SSL end-to-end via ACM. Cloudflare’s proxy adds no value here.
Apex CNAMEs work fine in Cloudflare — it does CNAME flattening at the zone root automatically.
One other thing: we had a wildcard * A record in Cloudflare pointing to our local server, which handles staging and other internal subdomains. We left it in place. Explicit records (apex and www) take precedence over the wildcard — subdomains continue routing to the local server, apex and www go to CloudFront.
Gotcha 4: CloudFront default root object only covers /
Hugo generates clean URLs — /notes/some-article/ resolves to /notes/some-article/index.html in S3. CloudFront’s “default root object” setting only rewrites requests for the bare /. Every other directory path (/notes/, /notes/some-article/) hits S3 as-is and gets a 404.
The fix is a CloudFront Function attached to the viewer-request event:
function handler(event) {
var request = event.request;
var uri = request.uri;
if (uri.endsWith('/')) {
request.uri += 'index.html';
} else if (!uri.includes('.')) {
request.uri += '/index.html';
}
return request;
}
This rewrites any request ending in / or with no file extension to append index.html before CloudFront fetches from S3. Create it, publish it to LIVE, and associate it with the default cache behavior as a viewer-request function.
We discovered this after go-live when every article returned 404. The homepage worked because it hits the root default object. Everything else did not.
The deploy webhook
Rather than a managed CI/CD service, we run a small webhook container on our local server. On push, Gitea sends a signed POST to it. The container verifies the HMAC-SHA256 signature, then runs the deploy script:
#!/bin/sh
set -e
git fetch origin main
git reset --hard origin/main
hugo --source /site --destination /public --minify
aws s3 sync /public/ s3://your-bucket-name --delete
aws cloudfront create-invalidation --distribution-id <ID> --paths "/*"
AWS credentials are passed as environment variables from a .env file on the host, not baked into the container image.
The CloudFront invalidation adds a second or two to the deploy time and ensures cached stale files don’t linger. For a low-traffic site the cost is negligible.
Summary
| Issue | Fix |
|---|---|
| Bucket name with underscores rejected | Use hyphens — S3 names are DNS-compliant |
| ACM cert not appearing in CloudFront | Request cert in us-east-1 |
| SSL errors with Cloudflare | Set DNS records to DNS-only (gray cloud) |
| All subdirectory paths returning 404 | Add a CloudFront Function to rewrite directory URLs to index.html |
The end state: push to Gitea, site updates in under 30 seconds, no servers to maintain.