Hosting a Static Website on a Private S3 Bucket
S3 Website Hosting is convenient until you realize the bucket has to be public. Stories like the empty bucket that cost $1,300 in a day inspired me to think harder about how I host static sites.
This blog runs on a private S3 bucket. No public access, no stored keys, pennies a month. Here’s the stack:
- Private S3 Bucket: No public access allowed.
- CloudFront: The only entity allowed to read from S3 (via Origin Access Control).
- CloudFront Functions: A lightweight edge function to handle “pretty URLs” (so
/aboutserves/about/index.html) and redirects. - GitHub Actions + OIDC: Zero-trust CI/CD that assumes a temporary AWS role to deploy.
- Terraform: The entire infrastructure is defined as code.
High-Level Diagram#
Request Flow#
- The user requests a page (e.g.,
/about/). - DNS resolves to the CloudFront distribution.
- A CloudFront Function handles
www→ apex and pretty URL rewriting. - CloudFront serves from cache; on a miss, it fetches from the private S3 origin via OAC.
- The response is cached and returned to the user over HTTPS.
How changes go live#
Here is how a code change turns into a new page. GitHub Actions checks out the repo and runs Hugo, which writes the static site into the public/ folder. The workflow then assumes an AWS role with OIDC and syncs that folder to S3 with the --delete flag so the bucket mirrors the build. Finally, it asks CloudFront to create an invalidation for /*. That nudges edge caches to pick up the new files right away.
Why this path? You could let caches expire naturally, or version every asset and avoid invalidations. For a personal site, one broad invalidation after deploy is simpler and the cost is tiny. Using OIDC means there are no saved AWS keys in the repository, and the IAM policy stays tight: S3 sync, list, multipart, and cloudfront:CreateInvalidation.
CloudFront Function (Redirects + Pretty URLs)#
function handler(event) {
var request = event.request;
var host = request.headers.host.value;
// 1) Redirect www → apex
if (host.startsWith('www.')) {
var apexDomain = host.substring(4);
return {
statusCode: 301,
statusDescription: 'Moved Permanently',
headers: { 'location': { value: 'https://' + apexDomain + request.uri } }
};
}
// 2) Pretty URL rewrite
// If URI ends in '/', append 'index.html'.
// If URI doesn't contain a dot in the last path segment, append '/index.html'.
var uri = request.uri;
if (uri.endsWith('/')) {
request.uri = uri + 'index.html';
} else {
// Check if the last segment contains a dot (heuristic for file extension)
var lastSegment = uri.split('/').pop();
if (lastSegment.indexOf('.') === -1) {
request.uri = uri + '/index.html';
}
}
return request;
}This approach preserves “pretty” URLs by handling standard cases (/about -> /about/index.html) while letting file requests pass through (/style.css). It relies on a dot heuristic for extensions, which is simple and effective for standard static site generators like Hugo.
Security Model#
Every request goes through CloudFront—one front door. I can enforce HTTPS, apply redirects and headers, and see consistent logs. Users cannot bypass the CDN to hit S3 directly, which protects the origin, improves cache hit rate, and avoids duplicate URLs on s3.amazonaws.com. I don’t run AWS WAF today, but this setup makes it easy to add later without changing the origin.
Observability and Cost#
CloudFront writes access logs to a dedicated S3 bucket so I can inspect traffic or plug Athena in later. A handful of CloudWatch alarms on 4xx/5xx spikes and origin errors are enough for a personal site. Cost stays low: most of it is data transfer. The Function is essentially free at this scale, and S3 requests are tiny compared to the CDN cache hit rate.
Trade‑offs#
Why not Amplify Hosting? AWS now recommends Amplify for static sites. It handles builds, deploys, and CDN out of the box. I wanted more control over the infrastructure and to keep everything in Terraform. I wrote more about this tradeoff in Should You Just Use Amplify?
Why not Lambda@Edge? It can run more complex logic, but adds latency and cost. For two simple viewer tasks, a CloudFront Function is a better fit.
Why GitHub Actions? The code already lives there, and OIDC integration avoids managing long‑lived credentials. CodeBuild or CircleCI work fine too.
Why This Design#
The goal is a setup that behaves well without constant attention. The origin stays private, the CDN handles speed and TLS, and the edge logic is as small as it can be. Publishing is just a git push, and the infrastructure lives in a few Terraform files I can come back to later without re‑learning context. Simple to run, quick to load, and easy to change.
Related: Plugging the Leaky Bucket covers the four misconfigs behind most S3 breaches.
Good luck plugging leaks.