Server

An S3 Static Site Behind CloudFront

By Prabath Thalangama· October 24, 2026· 3 min read
#aws#s3#cloudfront

Introduction

The modern pattern is: a private S3 bucket (no public access, no website hosting), fronted by CloudFront which authenticates to the bucket with Origin Access Control (OAC). This gets you HTTPS, a CDN, HTTP/2+3, and no publicly listable bucket.

The pieces

1. Private bucket

aws s3 mb s3://example-com-site
aws s3api put-public-access-block --bucket example-com-site \
  --public-access-block-configuration \
  BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

Do not enable S3 static website hosting — that requires public access. Upload your build:

aws s3 sync ./dist s3://example-com-site --delete \
  --cache-control "public,max-age=31536000,immutable" \
  --exclude index.html
aws s3 cp ./dist/index.html s3://example-com-site/index.html \
  --cache-control "no-cache"

Hashed assets get a long cache; index.html gets no-cache so deploys are picked up.

2. ACM certificate — in us-east-1

CloudFront only uses certificates from us-east-1, regardless of where your bucket is:

aws acm request-certificate --region us-east-1 \
  --domain-name example.com --subject-alternative-names www.example.com \
  --validation-method DNS

Add the CNAME validation records to Route 53 (or your DNS).

3. CloudFront distribution

  • Origin: the bucket's regional domain (example-com-site.s3.eu-west-1.amazonaws.com), not the website endpoint.
  • Origin access: create an OAC (SigV4, S3). CloudFront then signs requests to the bucket.
  • Default root object: index.html.
  • Viewer protocol policy: redirect HTTP to HTTPS.
  • Alternate domain names (CNAMEs): example.com, www.example.com; attach the ACM cert.
  • Compress objects automatically: on.

4. Bucket policy for OAC

{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "AllowCloudFrontOAC",
    "Effect": "Allow",
    "Principal": { "Service": "cloudfront.amazonaws.com" },
    "Action": "s3:GetObject",
    "Resource": "arn:aws:s3:::example-com-site/*",
    "Condition": {
      "StringEquals": {
        "AWS:SourceArn": "arn:aws:cloudfront::123456789012:distribution/E1XXXXXXXXXXXX"
      }
    }
  }]
}

5. SPA routing

A single-page app needs /any/deep/link to serve index.html. Add a CloudFront custom error response: HTTP 403 (and 404) → response page path /index.html, response code 200. (403 because a private bucket returns 403, not 404, for a missing key.)

Better, if you use CloudFront Functions: rewrite requests without a file extension to /index.html at the viewer-request stage — keeps real 404s as 404s.

6. DNS

Route 53 alias A/AAAA records for example.com and www → the CloudFront distribution.

Deploying an update

aws s3 sync ./dist s3://example-com-site --delete ...
aws cloudfront create-invalidation --distribution-id E1XXXX --paths "/index.html" "/"

Only invalidate what changed (usually just index.html if assets are hashed) — invalidations beyond the free 1,000/month cost money.

Verification and troubleshooting

curl -I https://example.com
curl -I https://example.com/some/spa/route          # expect 200 + index.html
dig +short example.com
  • 403 AccessDenied from CloudFront — bucket policy doesn't grant the OAC (wrong distribution ARN in the condition), or the object key doesn't exist (typo, or index.html not uploaded), or public access block plus a legacy OAI config. Check the exact key with aws s3 ls.
  • 403 on every deep link, 200 on / — SPA custom error response not configured (403→/index.html→200).
  • Certificate won't attach / "not in us-east-1" — request it again in us-east-1.
  • Old content after deploy — no invalidation, or the browser cached index.html (set no-cache on it), or a long default TTL. Invalidate / and /index.html.
  • SignatureDoesNotMatch / origin errors — using the S3 website endpoint as the origin with OAC (OAC needs the REST endpoint), or the bucket is in a different region than the origin domain says.
  • www doesn't work — missing SAN on the cert, missing CNAME on the distribution, or no DNS record.
  • HTTP/3 not showing — enable it on the distribution; it's off by default on older ones.
PrabathStuck on something this site can't fix?Reach out to Prabath directly on LinkedIn.