How to Host a Static Website on S3 and CloudFront with HTTPS (2026)

Oct 31, 2026 · 12 min · Ajay Kumar

This guide is for anyone putting a static site or a single-page app (React, Vue, Svelte, Astro, Hugo output) on AWS who wants it served over HTTPS on their own domain, from a bucket that is not public. By the end you will have a private S3 bucket, an ACM certificate in us-east-1, a CloudFront distribution that reaches the bucket through Origin Access Control, a CloudFront Function that handles directory indexes or SPA routes, Route 53 alias records, and a deploy command that sets cache headers and invalidates correctly. The configuration targets Terraform 1.17.1 and AWS provider 6.71.0, both released on 28 October 2026 (Terraform, provider), and every argument was checked against the provider docs at that tag; I ran terraform fmt and tested the function locally, but this is not output from a live account. You need a domain with a public hosted zone in Route 53, AWS CLI v2, and credentials that can create S3, CloudFront, ACM and Route 53 resources. If you want the background on S3 itself, prices and storage classes included, read the companion post, Amazon S3 Explained (2026).

How the pieces fit together

The old tutorial pattern, S3 static website hosting with a public bucket, still works, but it is HTTP-only on the S3 side and requires turning off Block Public Access, which has been on by default for new buckets since April 2023. The current pattern keeps the bucket private and lets only one CloudFront distribution read it.

Private bucket, public site: the request path Browser https://www.example.com DNS: Route 53 A/AAAA alias to the distribution CloudFront edge TLS: ACM cert, us-east-1 Function: rewrite URI Cache: CachingOptimized HTTP redirects to HTTPS Origin access (OAC) on cache miss only signs request, SigV4 signing: always Private S3 bucket Block Public Access on ACLs off (owner enforced) policy: AWS:SourceArn equals this distribution Steps 3 to 5 Step 4 Steps 2 and 6 Step 7 Direct bucket URLs get 403 AccessDenied; only this distribution's signed requests can read objects.
The request path built in this guide. Requirements (us-east-1 certificate, REST endpoint origin, SourceArn condition) come from the CloudFront Developer Guide.

Two rules from the CloudFront OAC documentation shape everything below. First, the origin must be the bucket's REST endpoint, not its website endpoint; a website endpoint is a custom origin and cannot use OAC at all. Second, the bucket's Object Ownership must be "Bucket owner enforced", the default for new buckets. OAC replaced the legacy origin access identity (OAI), which does not support SSE-KMS or Regions launched after January 2023; do not start anything new on OAI.

Step 1: Set up the Terraform project and providers

Create a directory with these files. One provider block is enough: provider 6.x added a per-resource region argument, so the certificate can live in us-east-1 while the bucket lives wherever you want, without a second aliased provider (HashiCorp's 6.0 announcement).

# versions.tf
terraform {
  required_version = ">= 1.10"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.71"
    }
  }
}

provider "aws" {
  region = var.region # where the bucket lives
}

variable "region" {
  type    = string
  default = "eu-west-2"
}

variable "domain" {
  type        = string
  description = "Apex domain with a Route 53 public hosted zone, e.g. example.com"
}

variable "site_name" {
  type    = string
  default = "www"
}

locals {
  fqdn = "${var.site_name}.${var.domain}"
}

data "aws_caller_identity" "current" {}

data "aws_route53_zone" "this" {
  name         = var.domain
  private_zone = false
}

Set domain in a terraform.tfvars file (for example domain = "example.com"). The site will be served at www.example.com.

Step 2: Create a private S3 bucket

The bucket gets no website configuration and no public policy. Block Public Access and disabled ACLs are already defaults; declaring them makes the intent visible in review and stops someone flipping them in the console without Terraform noticing. Encryption is also automatic: every new object has been encrypted with SSE-S3 since January 2023.

# s3.tf
resource "aws_s3_bucket" "site" {
  # Bucket names are global; prefix with the account ID to avoid collisions.
  bucket = "${data.aws_caller_identity.current.account_id}-${replace(local.fqdn, ".", "-")}"
}

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_s3_bucket_ownership_controls" "site" {
  bucket = aws_s3_bucket.site.id
  rule {
    object_ownership = "BucketOwnerEnforced" # ACLs off, required for OAC
  }
}

resource "aws_s3_bucket_versioning" "site" {
  bucket = aws_s3_bucket.site.id
  versioning_configuration {
    status = "Enabled" # lets you roll back a bad deploy
  }
}

resource "aws_s3_bucket_lifecycle_configuration" "site" {
  bucket = aws_s3_bucket.site.id

  rule {
    id     = "expire-old-versions"
    status = "Enabled"
    filter {}
    noncurrent_version_expiration {
      noncurrent_days = 30
    }
    abort_incomplete_multipart_upload {
      days_after_initiation = 7
    }
  }
}

Versioning is optional, but it turns "the deploy deleted the wrong files" into a five-minute restore. The lifecycle rule stops old versions from accumulating forever.

Step 3: Request an ACM certificate in us-east-1

CloudFront requires ACM certificates to be requested or imported in US East (N. Virginia), whatever Region your bucket is in. This is the single most common reason a custom-domain setup fails.

# acm.tf
# CloudFront only accepts ACM certificates from us-east-1.
# Provider 6.x lets a regional resource override its region directly.
resource "aws_acm_certificate" "site" {
  region            = "us-east-1"
  domain_name       = local.fqdn
  validation_method = "DNS"
  key_algorithm     = "EC_prime256v1"

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_route53_record" "cert_validation" {
  for_each = {
    for o in aws_acm_certificate.site.domain_validation_options : o.domain_name => {
      name   = o.resource_record_name
      type   = o.resource_record_type
      record = o.resource_record_value
    }
  }

  zone_id         = data.aws_route53_zone.this.zone_id
  name            = each.value.name
  type            = each.value.type
  records         = [each.value.record]
  ttl             = 300
  allow_overwrite = true
}

resource "aws_acm_certificate_validation" "site" {
  region                  = "us-east-1"
  certificate_arn         = aws_acm_certificate.site.arn
  validation_record_fqdns = [for r in aws_route53_record.cert_validation : r.fqdn]
}

DNS validation matters for more than the first issue. ACM renews the certificate automatically as long as the validation CNAME stays in place, which is the whole reason to use ACM as public certificate lifetimes keep shrinking (TLS Certificate Lifetimes in 2026 has the schedule). aws_acm_certificate_validation makes Terraform wait until the certificate is ISSUED before the distribution tries to use it. ECDSA P-256 keys are supported by CloudFront and give smaller handshakes; switch to RSA_2048 if you must serve very old clients.

Step 4: Create the Origin Access Control and a CloudFront Function

The OAC tells CloudFront to sign every origin request with SigV4. signing_behavior = "always" is the setting the docs recommend, and it is also the only one that guarantees HTTPS between CloudFront and S3.

CloudFront's default_root_object only applies to the root URL. A request for /about/ will not be mapped to /about/index.html, and a deep link like /dashboard/settings in a React app will hit S3 for an object that does not exist. A small CloudFront Function on the viewer-request event fixes both, based on AWS's own index.html example:

// rewrite.js
// CloudFront Functions, runtime cloudfront-js-2.0, viewer-request event.
// Set SPA = true for React/Vue/Svelte client-side routing; false for a
// multi-page static site where /about/ means /about/index.html.
const SPA = true;

async function handler(event) {
  const request = event.request;
  const uri = request.uri;

  // Requests for real files (anything with an extension) pass through.
  const lastSegment = uri.substring(uri.lastIndexOf('/') + 1);
  if (lastSegment.includes('.')) {
    return request;
  }

  if (SPA) {
    request.uri = '/index.html';           // let the client router handle it
  } else if (uri.endsWith('/')) {
    request.uri = uri + 'index.html';      // /docs/  -> /docs/index.html
  } else {
    request.uri = uri + '/index.html';     // /docs   -> /docs/index.html
  }
  return request;
}

I ran it under Node with a stub event to check the mapping:

SPA=true  /about              -> /index.html
SPA=true  /assets/app.3f9a.js -> /assets/app.3f9a.js
SPA=false /about              -> /about/index.html
SPA=false /about/             -> /about/index.html
SPA=false /favicon.ico        -> /favicon.ico

Why a function and not the popular trick of mapping 403 and 404 custom error responses to /index.html with status 200? The error-response trick applies to every request, so a missing JavaScript bundle also returns your HTML with a 200, and the browser reports a confusing MIME-type error instead of a 404. The function only rewrites extensionless paths. CloudFront Functions cost $0.10 per million invocations after the first 2 million each month, per the CloudFront price list, and the runtime 2.0 reference lists what JavaScript is available (no network, no timers, no fs).

Step 5: Create the CloudFront distribution

# cloudfront.tf
resource "aws_cloudfront_origin_access_control" "site" {
  name                              = "${local.fqdn}-oac"
  description                       = "OAC for ${local.fqdn}"
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
}

resource "aws_cloudfront_function" "rewrite" {
  name    = "${replace(local.fqdn, ".", "-")}-rewrite"
  runtime = "cloudfront-js-2.0"
  comment = "Directory index and SPA fallback"
  publish = true
  code    = file("${path.module}/rewrite.js")
}

resource "aws_cloudfront_distribution" "site" {
  enabled             = true
  is_ipv6_enabled     = true
  http_version        = "http2and3"
  comment             = local.fqdn
  aliases             = [local.fqdn]
  default_root_object = "index.html"
  price_class         = "PriceClass_100" # North America and Europe edges only

  origin {
    origin_id                = "s3-site"
    domain_name              = aws_s3_bucket.site.bucket_regional_domain_name # REST endpoint, not the website endpoint
    origin_access_control_id = aws_cloudfront_origin_access_control.site.id
  }

  default_cache_behavior {
    target_origin_id       = "s3-site"
    viewer_protocol_policy = "redirect-to-https"
    allowed_methods        = ["GET", "HEAD"]
    cached_methods         = ["GET", "HEAD"]
    compress               = true

    # Managed CachingOptimized policy: no cookies or query strings in the key.
    cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6"

    function_association {
      event_type   = "viewer-request"
      function_arn = aws_cloudfront_function.rewrite.arn
    }
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  viewer_certificate {
    acm_certificate_arn      = aws_acm_certificate_validation.site.certificate_arn
    ssl_support_method       = "sni-only"
    minimum_protocol_version = "TLSv1.2_2021"
  }
}

output "distribution_id" {
  value = aws_cloudfront_distribution.site.id
}

output "bucket" {
  value = aws_s3_bucket.site.bucket
}

A few choices worth explaining. bucket_regional_domain_name gives the REST endpoint (bucket.s3.eu-west-2.amazonaws.com), which is what OAC needs. The cache policy ID is the managed CachingOptimized policy: default TTL 24 hours, maximum 365 days, minimum 1 second, no cookies or query strings in the cache key, Gzip and Brotli cached separately. TLSv1.2_2021 is the widely compatible security policy; CloudFront now also lists TLSv1.2_2025 and TLSv1.3_2025 policies in its supported protocols table if you can drop older clients.

Step 6: Grant CloudFront read access with a bucket policy

This is the policy from the OAC docs, written with aws_iam_policy_document. The AWS:SourceArn condition is what stops any other CloudFront distribution, including one in someone else's account, from reading your bucket.

# policy.tf
data "aws_iam_policy_document" "site" {
  statement {
    sid       = "AllowCloudFrontServicePrincipalReadOnly"
    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]
    }
  }
}

resource "aws_s3_bucket_policy" "site" {
  bucket = aws_s3_bucket.site.id
  policy = data.aws_iam_policy_document.site.json

  # The public access block must exist first, or a race can reject the policy.
  depends_on = [aws_s3_bucket_public_access_block.site]
}

If you also want missing files to return 404 instead of 403, add s3:ListBucket on the bucket ARN (not /*) in a second statement. S3 returns 403 for a missing key when the caller lacks s3:ListBucket. With the cache policy above, no query strings reach the origin, so viewers cannot use that permission to list the bucket. In SPA mode it rarely matters, because extensionless paths never miss.

Step 7: Point your domain at CloudFront with Route 53 alias records

# dns.tf
resource "aws_route53_record" "site" {
  for_each = toset(["A", "AAAA"])

  zone_id = data.aws_route53_zone.this.zone_id
  name    = local.fqdn
  type    = each.key

  alias {
    name                   = aws_cloudfront_distribution.site.domain_name
    zone_id                = aws_cloudfront_distribution.site.hosted_zone_id
    evaluate_target_health = false
  }
}

Alias records are free to query and work at the zone apex, which a CNAME cannot. The AAAA record matters because is_ipv6_enabled = true and clients prefer IPv6 where they have it.

Step 8: Apply, upload the site and invalidate the cache

terraform init
terraform apply   # first apply waits for ACM validation and CloudFront deployment

BUCKET=$(terraform output -raw bucket)
DIST=$(terraform output -raw distribution_id)

# 1. Fingerprinted assets: cache for a year, never revalidate.
#    Excluding *.html (not just the root index.html) keeps nested pages
#    such as about/index.html out of the year-long cache in multi-page mode.
aws s3 sync ./dist "s3://$BUCKET" --delete \
  --exclude "*.html" \
  --cache-control "public,max-age=31536000,immutable"

# 2. Every HTML file: always revalidate, so new deploys show up.
aws s3 cp ./dist "s3://$BUCKET" --recursive \
  --exclude "*" --include "*.html" \
  --cache-control "no-cache"

# 3. Drop the edge copy of the HTML only (use "/*" for a multi-page site).
aws cloudfront create-invalidation --distribution-id "$DIST" \
  --paths "/index.html" "/"

Check the result:

curl -sI https://www.example.com/ | grep -iE "^(HTTP|x-cache|cache-control)"
# HTTP/2 200
# cache-control: no-cache
# x-cache: Miss from cloudfront   (Hit from cloudfront on the next request)

curl -sI "https://$BUCKET.s3.eu-west-2.amazonaws.com/index.html" | head -1
# HTTP/1.1 403 Forbidden          (the bucket really is private)

Two notes on invalidations. The first 1,000 paths a month are free per account and each one after is $0.005, and a wildcard like /* counts as one path. But if your build tool fingerprints file names, you should rarely need more than the HTML paths above; new asset names are new cache keys. Because CachingOptimized has a 1-second minimum TTL, no-cache on index.html means edges hold it for about a second, so the invalidation is belt and braces.

For CI, run the same three commands from GitHub Actions with an OIDC role scoped to s3:PutObject, s3:DeleteObject, s3:ListBucket on this bucket and cloudfront:CreateInvalidation on this distribution; How to Use GitHub Actions OIDC With AWS, GCP and Azure shows the trust policy. If traffic is modest, look at CloudFront's flat-rate plans, announced 18 November 2025, which include a $0 tier with 1 million requests and 100 GB a month.

Troubleshooting

Symptom Likely cause Fix
AccessDenied XML on every URL Bucket policy missing, or AWS:SourceArn names a different distribution ID Re-apply policy.tf; compare the ARN in the policy with aws cloudfront get-distribution --id $DIST
AccessDenied on deep links or /about/ only No rewrite function, and S3 returns 403 for missing keys without s3:ListBucket Attach the Step 4 function; optionally grant s3:ListBucket to get honest 404s
AccessDenied on the root URL / default_root_object not set Set default_root_object = "index.html"
403 or 504 after pointing at bucket.s3-website-... Website endpoint used as origin; it cannot use OAC Use bucket_regional_domain_name (REST endpoint)
InvalidViewerCertificate on apply Certificate not in us-east-1, not yet ISSUED, or does not cover the alias Request it in us-east-1, reference aws_acm_certificate_validation, match SANs to aliases
CNAMEAlreadyExists The alias is attached to another distribution, possibly in another account Remove it there, or follow AWS's move-alternate-domain procedure
Old page still served after deploy index.html cached at the edge or in the browser Set Cache-Control: no-cache on HTML; invalidate /index.html and /
403 only for objects encrypted with SSE-KMS KMS key policy does not allow the CloudFront service principal Add the kms:Decrypt statement from the OAC docs with the same AWS:SourceArn condition
403 for files uploaded by another account or tool Object Ownership not BucketOwnerEnforced, so the uploader owns the object Set BucketOwnerEnforced; re-upload affected objects
JS bundle returns HTML with a MIME error Custom error responses map 404 to /index.html Remove those error responses and use the function instead

When a 403 shows up, the fastest split is curl -sI against the CloudFront URL and against the S3 REST URL with your own credentials (aws s3api head-object). If S3 says the object exists and CloudFront says 403, the problem is the policy or OAC. If S3 says 404, it is the key or the rewrite. For IAM-side denials in general, How to Fix AWS AccessDenied Errors walks through the policy evaluation order.

FAQ

Do I need S3 static website hosting enabled to use CloudFront?

No, and with Origin Access Control you must not use it. OAC works only with the bucket's REST endpoint; the website endpoint is treated as a custom origin, needs a public bucket and serves HTTP only. Directory indexes and SPA routing move into a CloudFront Function instead.

Why does my ACM certificate have to be in us-east-1?

CloudFront is a global service that reads viewer certificates from ACM in US East (N. Virginia) only. A certificate in any other Region will not appear in the console picker and fails with InvalidViewerCertificate through the API. With AWS provider 6.x, set region = "us-east-1" on the certificate resource.

What is the difference between OAC and OAI?

Both let CloudFront read a private bucket. OAC is the current mechanism: it signs requests with SigV4, works with SSE-KMS and with every Region, and is scoped by the AWS:SourceArn condition. OAI is legacy, lacks SSE-KMS support, and AWS documents a migration path from it.

How much does it cost to host a static site on S3 and CloudFront?

For a small site, often nothing or close to it: S3 to CloudFront transfer is free, the first 1,000 invalidation paths and 2 million function invocations each month are free, and the flat-rate Free plan covers 1 million requests and 100 GB. On pay-as-you-go, US edge transfer starts at $0.085 per GB and HTTPS requests at $0.01 per 10,000, per the CloudFront price list.

In production, the first thing I check on any S3 and CloudFront site is the bucket policy's AWS:SourceArn and whether Block Public Access is on at the account level, because those two decide whether the bucket is actually private. Next is cache headers on index.html, which cause more "the deploy didn't work" reports than anything else. I keep the whole stack in Terraform so the certificate, the function and the policy move together. The official references are the CloudFront OAC guide, the certificate requirements, and the managed cache policies.

Related: How to Use GitHub Actions OIDC With AWS, GCP and Azure (2026), TLS Certificate Lifetimes in 2026: 200 Days Now, 47 Days by 2029 and How to Build Multi-Region Failover with Route 53 and Cloud DNS (2026).

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related