Skip to content

Hosting a static website on S3 + CloudFront: the current correct way

The current correct way to host a static website on S3 with CloudFront: a private bucket behind Origin Access Control, an ACM certificate in us-east-1, and a function for subdirectory index pages.

The current correct pattern is a private S3 bucket served through CloudFront with Origin Access Control (OAC) — not a public bucket, not the S3 website endpoint, and not the legacy Origin Access Identity (OAI). Block Public Access stays on, the bucket policy admits only your CloudFront distribution, the TLS certificate comes from ACM in us-east-1, and a small CloudFront Function handles the one thing S3’s REST endpoint will not: serving index.html for subdirectory URLs. Most outdated tutorials still show the public-bucket website endpoint; skip them.

What’s happening

There are two ways to expose S3 content over HTTP, and they behave differently. The website endpoint (bucket.s3-website-region.amazonaws.com) understands index documents and custom error pages but requires the bucket to be public and only speaks plain HTTP — CloudFront cannot fetch from it over HTTPS. The REST endpoint (bucket.s3.region.amazonaws.com) supports HTTPS and private access via OAC, but has no concept of a default document beyond what CloudFront’s Default Root Object gives you — and that setting only covers the root URL, not /blog/ or /pricing/.

OAC is how CloudFront authenticates to a private bucket: the distribution signs its origin requests, and the bucket policy grants s3:GetObject to the CloudFront service principal only when the request comes from your specific distribution ARN. It replaced OAI and supports SSE-KMS objects and all regions; new builds should not use OAI. Certificates for CloudFront must live in ACM in us-east-1 regardless of where the bucket is — a hard rule that costs everyone thirty confused minutes once. Details are in the CloudFront documentation.

What to check first

  • Is the DNS zone reachable? You will need to create validation records for ACM and an alias for the site; confirm who controls the zone before starting.
  • Does anything need server-side logic? Forms, auth, redirects with logic — static hosting will not do it; you will need functions at the edge or a different platform.
  • Is this a SPA or a multi-page site? SPAs want 403/404 rewritten to /index.html; document sites want per-directory index files. The two need different error handling.
  • Existing bucket or new? Retro-fitting OAC onto a public bucket means also removing the public policy and re-enabling Block Public Access.

How to verify

After setup, confirm the bucket is sealed and only CloudFront can read it:

# Should show all four Block Public Access settings true
aws s3api get-public-access-block --bucket my-site-bucket

# Direct object URL must return 403
curl -sI https://my-site-bucket.s3.ca-central-1.amazonaws.com/index.html | head -1

# Through the distribution: 200, with a cache header
curl -sI https://www.example.com/ | grep -iE 'HTTP|x-cache'

CloudFront > Distributions > your distribution > Origins should show the origin using Origin access control settings, and the S3 bucket policy should reference your distribution ARN.

The runbook

  1. Create the bucket, keep it private. Leave Block Public Access fully on. No website-hosting toggle — that feature belongs to the endpoint you are not using.

  2. Request the certificate in us-east-1. In Certificate Manager (region N. Virginia), request a public cert for example.com and www.example.com, validate via DNS.

  3. Create the distribution with OAC. Origin = the bucket’s REST endpoint (the console offers it), Origin access > Origin access control settings, create a new OAC. Set Default Root Object to index.html, attach the certificate and your alternate domain names, redirect HTTP to HTTPS.

  4. Apply the bucket policy CloudFront gives you. The console offers to copy it; it looks like this:

    {
      "Version": "2012-10-17",
      "Statement": [{
        "Sid": "AllowCloudFrontServicePrincipal",
        "Effect": "Allow",
        "Principal": { "Service": "cloudfront.amazonaws.com" },
        "Action": "s3:GetObject",
        "Resource": "arn:aws:s3:::my-site-bucket/*",
        "Condition": {
          "StringEquals": {
            "AWS:SourceArn": "arn:aws:cloudfront::111122223333:distribution/EDFDVBD6EXAMPLE"
          }
        }
      }]
    }
  5. Handle subdirectory indexes with a CloudFront Function. Attach on viewer request:

    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;
    }

    For a SPA instead, skip the function and add custom error responses mapping 403 and 404 to /index.html with a 200 code.

  6. Point DNS at the distribution. An alias record in Route 53, or a CNAME at your DNS host, to the dxxxxxxxxxxxx.cloudfront.net domain.

  7. Deploy with sync plus a targeted invalidation:

    aws s3 sync ./dist s3://my-site-bucket/ --delete
    aws cloudfront create-invalidation --distribution-id EDFDVBD6EXAMPLE --paths "/*"

    The sync patterns article covers the flags worth knowing; note that invalidations above the monthly free allotment bill per path.

When this is the wrong move

If the site needs server-side rendering, sessions, or an admin backend, this stack is the wrong shape — you will end up bolting Lambda onto everything a small app server does cleanly. If the audience is entirely internal, an internal ALB or a simpler host avoids public distribution plumbing. And if you publish twice a year and traffic is tiny, a managed platform’s free tier gets you the same result with less to own — this pattern earns its keep when you want CDN performance, custom TLS, and infrastructure you control.

Common mistakes

  • Requesting the ACM certificate in the bucket’s region and staring at an empty certificate dropdown in CloudFront — it must be us-east-1.
  • Following an old tutorial onto the public website endpoint, then failing a security review for a world-readable bucket.
  • Forgetting the subdirectory index behaviour and shipping a site where /about/ returns 403 while /about/index.html works.
  • Invalidating /* on every commit in a busy CI pipeline and paying for it — use versioned asset filenames and invalidate only the HTML.

Prevention

  • Codify the whole stack (bucket, OAC, distribution, function, DNS) in Terraform or CloudFormation the first week, while it is still small.
  • Keep deploys to one script: sync, invalidate, done — no console-drift.
  • Alarm on CloudFront 5xx rate the same way you would any origin, alongside your CloudWatch baseline.
  • Re-check the bucket policy after any bucket rename or distribution rebuild; a stale distribution ARN in the condition breaks the site with uniform 403s.

Done this way, the result is fast, cheap, and nearly maintenance-free — the failure modes are all in the setup, not the running. If nobody on your team truly owns the AWS environment end to end, that is the root of most of these problems — Stack Harbor runs AWS accounts daily under AWS Operations, and you can book an AWS review.