CloudIntermediate

How to Host a Static Website on AWS S3 and CloudFront

A complete walkthrough of hosting a static site on S3 with CloudFront in front of it — bucket policy, Origin Access Control, caching, and HTTPS via ACM.

DevFieldGuideJuly 31, 20269 min read
Share:

This walks through the actual production pattern for hosting a static site on AWS: a private S3 bucket, CloudFront in front of it via Origin Access Control, and a free ACM certificate for HTTPS — not the older, publicly-readable-bucket approach that's still floating around in outdated tutorials.

Prerequisites
  • An AWS account and the AWS CLI configured with credentials that can create S3 buckets, CloudFront distributions, and ACM certificates.
  • A built static site (an index.html and assets, or the output of any static site generator).
  • A registered domain if you want a custom domain — this works with the default CloudFront domain too, without one.

Step 1: Create the S3 bucket

bash
aws s3api create-bucket \
  --bucket my-static-site-example \
  --region us-east-1

Block all public access explicitly — this bucket is never meant to be reached directly, only through CloudFront:

bash
aws s3api put-public-access-block \
  --bucket my-static-site-example \
  --public-access-block-configuration \
  BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

Step 2: Upload the site

bash
aws s3 sync ./out s3://my-static-site-example --delete

--delete removes objects in the bucket that no longer exist locally, keeping the bucket an exact mirror of your build output rather than accumulating stale files from previous deploys.

Step 3: Create a CloudFront distribution with Origin Access Control

Origin Access Control (OAC) is the current, recommended mechanism for letting CloudFront read from a private S3 bucket — it replaced the older Origin Access Identity (OAI), which AWS now considers legacy.

bash
aws cloudfront create-origin-access-control \
  --origin-access-control-config \
  Name="my-static-site-oac",SigningProtocol=sigv4,SigningBehavior=always,OriginAccessControlOriginType=s3

Creating the distribution itself is most reliably done through the console or Infrastructure as Code (see Infrastructure as Code: Why Terraform Won) given how many fields it has, but the essential shape in Terraform:

hcl
resource "aws_cloudfront_distribution" "site" {
  enabled             = true
  default_root_object = "index.html"
 
  origin {
    domain_name              = aws_s3_bucket.site.bucket_regional_domain_name
    origin_id                = "s3-origin"
    origin_access_control_id = aws_cloudfront_origin_access_control.oac.id
  }
 
  default_cache_behavior {
    target_origin_id       = "s3-origin"
    viewer_protocol_policy = "redirect-to-https"
    allowed_methods         = ["GET", "HEAD"]
    cached_methods           = ["GET", "HEAD"]
    cache_policy_id          = data.aws_cloudfront_cache_policy.caching_optimized.id
  }
 
  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }
 
  viewer_certificate {
    cloudfront_default_certificate = true
  }
}

Step 4: Lock the bucket policy to CloudFront only

With OAC in place, the bucket policy grants read access specifically to the CloudFront service principal, scoped to your exact distribution — not to the public:

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

The Condition block matters — without it, any CloudFront distribution in any AWS account could theoretically be configured to read this bucket, not just yours. Scoping it to your exact distribution ARN closes that gap.

Browser requestHTTPS request to your CloudFront domain
CloudFront edgeServes from cache if present
Cache miss → originCloudFront signs the request via OAC
Private S3 bucketOnly readable by this exact distribution

Step 5: Add a custom domain and free HTTPS via ACM

Request a certificate in us-east-1 specifically — CloudFront only accepts ACM certificates issued in that region, regardless of which region your other resources live in:

bash
aws acm request-certificate \
  --domain-name example.com \
  --validation-method DNS \
  --region us-east-1

After validating the certificate (adding the DNS CNAME record ACM provides), attach it to the distribution's viewer_certificate block and add your domain as an alternate domain name (aliases), then point your domain's DNS at the CloudFront distribution — a Route 53 alias record if your domain is hosted there, or a CNAME otherwise.

Step 6: Handle client-side routing correctly

A single-page app or a static export with client-side routes needs 403/404 responses from S3 mapped back to index.html, so a direct load of a deep route doesn't just show a raw S3 error:

Error caching behavior: 403 → /index.html, HTTP 200 404 → /index.html, HTTP 200

For a fully static-exported multi-page site (like this one, or most Next.js output: "export" sites) this matters less, since every real route already has its own index.html generated at build time — but it's essential for any client-only routing layer.

Step 7: Invalidate the cache on deploy

CloudFront caches aggressively by design — after uploading new files, invalidate so viewers see the update instead of a stale cached version:

bash
aws cloudfront create-invalidation \
  --distribution-id EXXXXXXXXXXXXX \
  --paths "/*"

A wildcard /* invalidation bills as a single path for CloudFront's invalidation pricing, not "one charge per file" — a common cost misconception. The free tier includes 1,000 invalidation paths per month, and a single deploy's /* invalidation only consumes one of them.

Adding security headers with a CloudFront Function

A static site with no server can still set real security headers (Strict-Transport-Security, X-Content-Type-Options, Content-Security-Policy) by attaching a lightweight CloudFront Function to the viewer-response event — this runs at the edge, adding negligible latency compared to a full Lambda@Edge function:

javascript
function handler(event) {
  var response = event.response;
  var headers = response.headers;
 
  headers["strict-transport-security"] = { value: "max-age=63072000; includeSubDomains; preload" };
  headers["x-content-type-options"] = { value: "nosniff" };
  headers["x-frame-options"] = { value: "DENY" };
  headers["referrer-policy"] = { value: "strict-origin-when-cross-origin" };
 
  return response;
}
bash
aws cloudfront create-function \
  --name add-security-headers \
  --function-config Comment="Security headers",Runtime="cloudfront-js-2.0" \
  --function-code fileb://headers.js

Attach it to the distribution's default_cache_behavior as a function-association on the viewer-response event. CloudFront Functions are the right tool specifically for cheap, stateless header/URL manipulation like this — Lambda@Edge is the heavier option, needed only when logic requires actual compute beyond simple request/response transformation (calling another service, for instance).

Automating deploys in CI

Wiring steps 2 (sync) and 7 (invalidate) into GitHub Actions, using OIDC federation rather than long-lived AWS keys, turns this into a real deploy pipeline triggered on every merge to main:

yaml
permissions:
  id-token: write
  contents: read
 
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
          aws-region: us-east-1
      - run: aws s3 sync ./out s3://my-static-site-example --delete
      - run: |
          aws cloudfront create-invalidation \
            --distribution-id ${{ vars.CLOUDFRONT_DISTRIBUTION_ID }} \
            --paths "/*"

This is close to the exact pattern this site's own deploy.yml uses in production — see GitHub Actions Reusable Workflows for how to extract it into a shared template once you have more than one static site to deploy this way.

Handling multiple environments from one bucket structure

A single S3 bucket can serve several environments (preview builds, staging, production) by prefixing keys and pointing separate CloudFront distributions (or separate cache behaviors on one distribution) at different prefixes:

s3://my-static-site-example/production/index.html s3://my-static-site-example/staging/index.html s3://my-static-site-example/pr-123/index.html
bash
aws s3 sync ./out s3://my-static-site-example/pr-123 --delete

This is a common pattern for preview deployments on pull requests — each PR gets its own prefix, uploaded and torn down automatically in CI, without provisioning a whole new bucket or distribution per PR. The tradeoff is that all environments share the same underlying bucket's access controls and lifecycle rules — genuinely separate compliance or access requirements per environment argue for separate buckets instead, prefixing being the lighter-weight option for environments that don't need that isolation.

Verifying the setup end to end

Before considering the deploy done, three checks confirm the pieces are actually working together, not just individually configured:

bash
# 1. Confirm the bucket itself is NOT directly reachable
curl -I https://my-static-site-example.s3.amazonaws.com/index.html
# expect: 403 Forbidden — this is correct, not a bug
 
# 2. Confirm CloudFront serves the site correctly
curl -I https://<distribution-id>.cloudfront.net/
# expect: 200 OK
 
# 3. Confirm HTTPS redirect and the custom domain both work
curl -I http://example.com/
# expect: a 301/302 redirect to https://

A 403 on the direct S3 URL is the sign the Origin Access Control setup is actually working as intended, not a misconfiguration to fix — the bucket should never be reachable except through CloudFront.

Common mistakes

Common mistakes
  • Making the S3 bucket publicly readable instead of using Origin Access Control. This was the standard pattern years ago, but it means anyone who discovers the bucket's direct S3 URL can bypass CloudFront (and its caching/HTTPS) entirely — OAC keeps the bucket genuinely private.
  • Requesting the ACM certificate in the wrong region. CloudFront only accepts certificates from us-east-1 — a certificate requested in any other region simply won't appear as selectable when attaching it to the distribution.
  • Forgetting to set default_root_object to index.html. Without it, requests to the root path (/) return a listing error instead of your homepage.
  • Skipping the cache invalidation step after every deploy, and assuming users are seeing the new version — CloudFront's default caching behavior can serve a stale version for hours without an explicit invalidation.

Practical tips

Best practices
  1. Set long cache lifetimes on hashed, immutable assets (/_next/static/..., fingerprinted JS/CSS) and short-or-no cache on index.html and other entry points — this gets you both aggressive edge caching and instant rollout of new deploys.
  2. Automate the sync-and-invalidate steps in CI (GitHub Actions with OIDC, not static AWS keys) rather than running them by hand — see GitHub Actions Reusable Workflows for a template of exactly this pattern.
  3. Use a wildcard /* invalidation on deploy rather than trying to compute exactly which files changed — the pricing model makes the extra precision not worth the complexity for a typical site.
  4. Keep the bucket policy scoped to one specific distribution ARN, not a broader condition — the tighter the policy, the less blast radius if anything else in the account is misconfigured later.

Frequently Asked Questions

DevFieldGuide
DevFieldGuide

Editorial Team

Practical tutorials and developer tools, written and maintained by the DevFieldGuide team.

Enjoyed this article?

Get the next one straight to your inbox, along with the best of what we publish each week.

Related Articles

More in Cloud

View all