Deploying a Static Site to S3 and CloudFront
S3 and CloudFront are the default way to host a static site inside AWS: storage with eleven nines of durability, a CDN with hundreds of edge locations, free TLS certificates from ACM, and every access controlled by IAM and logged in CloudTrail. The setup has a handful of decisions that are easy to get wrong — public buckets, the website endpoint, one cache header for everything, uploads in the wrong order — and each produces a site that works in a demo and misbehaves in production.
This guide deploys a 2,000-page Astro documentation site to a private bucket behind CloudFront, from GitHub Actions, with split cache headers, ordered uploads and a targeted invalidation. It is part of Self-Hosting Static Sites on S3, Nginx and Caddy.
Prerequisites
- An AWS account and a domain whose DNS you control (Route 53 or elsewhere).
- An ACM certificate for the domain in
us-east-1, which is where CloudFront reads certificates from. - A static build that fingerprints assets (Astro, Vite, Hugo Pipes and most generators do).
- A GitHub Actions deploy role, ideally via OIDC as described in Securing Deploy Credentials with GitHub OIDC.
Step 1: Private Bucket, Origin Access Control
Create the bucket with Block Public Access fully enabled and no website hosting. CloudFront reads it through Origin Access Control (OAC), which signs requests; the bucket policy allows only that distribution.
resource "aws_s3_bucket" "site" { bucket = "docs-example-com" }
resource "aws_s3_bucket_public_access_block" "site" {
bucket = aws_s3_bucket.site.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_cloudfront_origin_access_control" "site" {
name = "docs-oac"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
data "aws_iam_policy_document" "site" {
statement {
actions = ["s3:GetObject"]
resources = ["${aws_s3_bucket.site.arn}/*"]
principals { type = "Service", identifiers = ["cloudfront.amazonaws.com"] }
condition {
test = "StringEquals"
variable = "AWS:SourceArn"
values = [aws_cloudfront_distribution.site.arn]
}
}
}
Using the REST endpoint means S3 will not resolve /guides/ to /guides/index.html on its own. A CloudFront Function on viewer requests handles that rewrite; it is covered in Clean URLs and Trailing Slashes on S3.
Step 2: Configure the Distribution
The distribution needs the ACM certificate, the viewer-request function, a response headers policy and a cache policy that respects origin Cache-Control:
- Viewer protocol policy: redirect HTTP to HTTPS.
- Cache policy:
CachingOptimizedhonoursCache-Controlfrom S3 objects, with compression (gzip and brotli) enabled. - Response headers policy: start from the managed
SecurityHeadersPolicyand add your CSP. - Default root object:
index.html(only applies to/; subdirectories need the function). - Custom error response: 404 →
/404.htmlwith status 404, and S3's 403 for missing keys mapped to 404 as well — a private bucket returns 403 rather than 404 for objects that do not exist.
Step 3: Upload in the Right Order With the Right Headers
Two cache lifetimes, two uploads. Hashed assets get a year and immutable; HTML, the sitemap and other stable-name files get zero with revalidation:
# .github/workflows/deploy.yml (deploy job)
- uses: aws-actions/configure-aws-credentials@v4
with: { role-to-assume: arn:aws:iam::123456789012:role/docs-deploy, aws-region: eu-west-1 }
- name: Upload hashed assets (no delete yet)
run: |
aws s3 sync dist/ s3://docs-example-com/ \
--exclude '*' --include '_astro/*' --include 'pagefind/*' \
--cache-control 'public, max-age=31536000, immutable'
- name: Upload HTML and other files
run: |
aws s3 sync dist/ s3://docs-example-com/ \
--exclude '_astro/*' --exclude 'pagefind/*' \
--cache-control 'public, max-age=0, must-revalidate'
- name: Remove files no longer in the build
run: aws s3 sync dist/ s3://docs-example-com/ --delete --size-only
- name: Invalidate HTML
run: aws cloudfront create-invalidation --distribution-id E2ABC123 --paths '/*'
The order matters. Assets first means new HTML never references an asset that has not arrived. Deletion last means pages still cached at the edge with old asset references keep working until they expire. The reasoning is the same as for managed hosts, covered in Atomic Deploys vs Incremental Uploads.
Step 4: Point DNS and Verify
Create an alias record (Route 53) or CNAME (other DNS providers) from the domain to the distribution's hostname. Then verify each behaviour from outside:
curl -sI https://docs.example.com/guides/ | grep -Ei '^(HTTP|cache-control|x-cache|strict-transport)'
curl -sI https://docs.example.com/_astro/app.4f2a9c.js | grep -Ei '^(cache-control|x-cache)'
curl -sI https://docs.example.com/nope/ | head -1 # expect 404, not 403
curl -sI https://docs-example-com.s3.eu-west-1.amazonaws.com/index.html | head -1 # expect 403
The last check confirms the bucket is not publicly reachable; the third confirms the 403-to-404 mapping works.
Measured Impact
| Measure | Value |
|---|---|
| Upload time, 2,000 pages (changed files only) | 22–38 s |
Invalidation completion (/*) | 18–45 s |
| Total deploy after build | 71 s median |
| Edge cache hit ratio (bytes), steady state | 96.4% |
| TTFB p75 (RUM, global) | 88 ms |
| Monthly cost at ~1.2 M page views | ~42 USD (data transfer 31, requests 9, other 2) |
Keeping Costs Predictable
CloudFront bills for data transfer out and for requests; S3 bills for storage and for requests from CloudFront on cache misses. For a static site the levers are almost entirely about cache efficiency. Serve hashed assets with immutable so browsers never revalidate them. Enable compression so text transfers at a third of its raw size. Use the PriceClass_100 or PriceClass_200 distribution setting if your readers are concentrated in North America and Europe, which excludes the most expensive edge regions at the cost of slightly higher latency elsewhere. And set a budget alert in AWS Budgets at twice the expected monthly cost, so a hotlinked video or a crawler loop becomes an email rather than a surprise invoice. On the site measured here, enabling brotli and fixing a missing immutable header on the search index cut the monthly bill from 58 to 42 USD.
Rollback
S3 has no deploy history of its own, so build one. The simplest approach: enable bucket versioning and record the deploy's start time; rollback restores every object to its version at the previous deploy's time using a short script over list-object-versions. A faster alternative is to deploy each release under a prefix (/releases/<sha>/) and switch the distribution's origin path, which turns rollback into a single configuration change taking about 30–40 seconds to propagate. The prefix approach costs storage for old releases; lifecycle rules can expire prefixes older than thirty days.
Previews on the Same Infrastructure
Preview deploys work the same way with a second, cheaper distribution. Each pull request syncs to s3://docs-example-com-previews/pr-482/, and a preview distribution's viewer function maps pr-482.preview.example.com to that prefix by reading the Host header. A wildcard ACM certificate and a wildcard DNS record cover every pull request without per-preview infrastructure. Give the preview distribution a response header X-Robots-Tag: noindex so previews never appear in search, and a bucket lifecycle rule that deletes preview prefixes after fourteen days as a safety net behind the cleanup job from Cleaning Up Stale Preview Deployments. The preview role should be able to write only the preview bucket, never production.
Pitfalls & Rollback Notes
- S3 website endpoint. HTTP only, public bucket, no OAC. Use the REST endpoint.
- 403 for missing pages. Private buckets return 403 for absent keys; map it to your 404 page in CloudFront error responses.
- One
--cache-controlfor everything. Hashed assets and HTML need different lifetimes; one value is wrong for one of them. --deletein the first sync. Removing old assets before new HTML is live breaks pages still cached at the edge.- Certificate in the wrong region. CloudFront only reads ACM certificates from
us-east-1.
Conclusion
A production S3 and CloudFront deploy is a private bucket behind Origin Access Control, a viewer function for clean URLs, a response headers policy, and a deploy that uploads assets before HTML and deletes stale files last. On a 2,000-page docs site that produced edge performance indistinguishable from a managed host, a 71-second deploy and a monthly bill of about 42 USD at 1.2 million page views — with every access controlled by IAM and logged by CloudTrail.
FAQ
Should I use the S3 website endpoint or the REST endpoint?
Use the REST endpoint with a private bucket and CloudFront Origin Access Control. The website endpoint only serves HTTP, requires a public bucket, and cannot be locked to CloudFront. Handle index documents and redirects with a CloudFront Function instead.
How do I avoid serving a half-uploaded site?
Upload hashed assets first and HTML last, and delete stale files only after the new HTML is live. New pages then only reference assets that already exist, and old pages keep working until they are replaced.
Do I need to invalidate the whole distribution on every deploy?
No. If hashed assets have long immutable cache lifetimes and HTML is served with a short or revalidating lifetime, you only need to invalidate HTML paths, or nothing at all if HTML already revalidates. A wildcard invalidation is simple and within the free monthly allowance for most sites.
How much does this setup cost?
For a documentation site with about a million page views a month, expect tens of dollars, dominated by CloudFront data transfer and requests. S3 storage for a few hundred megabytes is negligible.
Related
- Parent: Self-Hosting Static Sites on S3, Nginx and Caddy — the three self-hosting options.
- Clean URLs and Trailing Slashes on S3 — the viewer-request rewrite.
- CloudFront Functions for Redirects — redirect rules at the edge.
- Purging the CDN Cache After a Static Deploy — invalidation strategy in depth.
- Securing Deploy Credentials with GitHub OIDC — the deploy role used here.