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.

Request path through CloudFront to a private bucket A reader requests /guides/ over HTTPS. CloudFront's viewer-request function rewrites it to /guides/index.html and applies redirects. On a cache miss, CloudFront signs a request with Origin Access Control to the private S3 bucket, which only accepts that distribution. The response is cached at the edge with the object's Cache-Control header and returned with a security headers policy attached. Readers reach CloudFront; only CloudFront reaches the bucket reader GET /guides/ viewer function → /guides/index.html edge cache hit ~96% private S3 OAC-signed only miss response headers policy adds HSTS, CSP, nosniff Direct requests to the bucket return 403; there is no public path around the CDN
Private bucket plus OAC means headers, redirects and logging all happen in one place: CloudFront.

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: CachingOptimized honours Cache-Control from S3 objects, with compression (gzip and brotli) enabled.
  • Response headers policy: start from the managed SecurityHeadersPolicy and add your CSP.
  • Default root object: index.html (only applies to /; subdirectories need the function).
  • Custom error response: 404 → /404.html with 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.

Deploy order and what readers see Four deploy steps on a timeline. First, new hashed assets upload alongside old ones, so all pages still work. Second, new HTML uploads, and new pages reference assets that already exist. Third, stale files are deleted. Fourth, the HTML invalidation clears edge copies. At no point does a reader get HTML that references a missing asset. Add before you reference; delete after nothing references 1 · assets old + new coexist 2 · HTML refs already exist 3 · delete stale old assets removed 4 · invalidate edges fetch new HTML every moment: every served page's assets exist Reversing steps 1 and 2 produced 1,140 asset 404s in the 40 seconds of a test deploy
S3 has no atomic multi-object write; ordering the uploads is how you get the same guarantee.

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

MeasureValue
Upload time, 2,000 pages (changed files only)22–38 s
Invalidation completion (/*)18–45 s
Total deploy after build71 s median
Edge cache hit ratio (bytes), steady state96.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.

Two rollback strategies on S3 Left, versioned bucket: rollback restores each changed object to its prior version with a script, taking about 3 minutes for 2,000 pages plus an invalidation. Right, release prefixes: each deploy writes to its own prefix and CloudFront's origin path points at one; rollback changes the origin path back, taking about 40 seconds. Restore objects, or re-point the origin versioned bucket one prefix, object versions kept rollback: restore prior versions + invalidation ~3 min for 2,000 pages release prefixes /releases/<sha>/ per deploy rollback: change origin path lifecycle rule expires old prefixes ~40 s, one config change
Release prefixes trade a little storage for a rollback that is fast, whole-site and impossible to half-apply.

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-control for everything. Hashed assets and HTML need different lifetimes; one value is wrong for one of them.
  • --delete in 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.