← All posts

Host a static website on AWS S3 + CloudFront with Terraform

A private S3 bucket, CloudFront with Origin Access Control, a free ACM certificate and Route 53 records, all in Terraform. The exact setup behind this site.

Muhammad Umar··Updated ·9 min read
AWSTerraformCloudFrontS3
Diagram: a git push runs GitHub Actions, which uploads the site to a private S3 bucket. CloudFront serves it with an ACM certificate and Route 53 points the domain at CloudFront.

This website is plain static files sitting in an S3 bucket, served worldwide through CloudFront over HTTPS. It costs close to nothing to run, survives any amount of traffic, and every piece of it is defined in Terraform.

Here is the full setup and why each piece is there.

The S3 and CloudFront architecture in Terraform

Visitor ──HTTPS──▶ CloudFront ──(signed, OAC)──▶ private S3 bucket
                     │
         ACM certificate (us-east-1)
                     │
      Route 53: example.com + www → CloudFront (A + AAAA alias)

The important decisions:

  • The bucket is private. No “static website hosting”, no public bucket policy. Only CloudFront can read it.
  • CloudFront uses Origin Access Control (OAC), the modern replacement for Origin Access Identity.
  • The certificate lives in us-east-1. CloudFront only accepts ACM certificates from that region, whatever region the rest of your stack is in.
  • www redirects to the apex domain, using a tiny CloudFront Function rather than a second bucket.

Prerequisites

  • A Route 53 hosted zone for your domain, with the registrar’s name servers pointing to it.
  • Terraform with the AWS provider 5.x.
  • A remote state backend (I cover that in Terraform remote state on S3 with KMS).

New AWS account? CloudFront may refuse to create a distribution with “Your account must be verified before you can add new CloudFront resources.” That’s an account-level block, not a Terraform problem. Open a free Account and billing case with AWS Support and include the error. It usually clears within a day or two.

The private S3 bucket

resource "aws_s3_bucket" "website" {
  bucket = var.domain_name
}

resource "aws_s3_bucket_ownership_controls" "website" {
  bucket = aws_s3_bucket.website.id
  rule { object_ownership = "BucketOwnerEnforced" }
}

resource "aws_s3_bucket_public_access_block" "website" {
  bucket                  = aws_s3_bucket.website.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_bucket_versioning" "website" {
  bucket = aws_s3_bucket.website.id
  versioning_configuration { status = "Enabled" }
}

Versioning lets you roll back a bad deploy. Add a lifecycle rule that expires noncurrent versions after 30 days so old files don’t pile up.

The certificate, validated through DNS

resource "aws_acm_certificate" "website" {
  domain_name               = var.domain_name
  subject_alternative_names = ["www.${var.domain_name}"]
  validation_method         = "DNS"
  lifecycle { create_before_destroy = true }
}

resource "aws_route53_record" "cert_validation" {
  for_each = {
    for dvo in aws_acm_certificate.website.domain_validation_options :
    dvo.domain_name => dvo
  }
  zone_id         = var.zone_id
  name            = each.value.resource_record_name
  type            = each.value.resource_record_type
  ttl             = 300
  records         = [each.value.resource_record_value]
  allow_overwrite = true
}

resource "aws_acm_certificate_validation" "website" {
  certificate_arn         = aws_acm_certificate.website.arn
  validation_record_fqdns = [for r in aws_route53_record.cert_validation : r.fqdn]
}

Because the hosted zone is in the same account, Terraform creates the validation records itself and waits until ACM issues the certificate. No clicking in the console.

A CloudFront Function for www and clean URLs

Two small problems to solve at the edge:

  1. www.example.com should permanently redirect to example.com.
  2. S3 doesn’t know that /about means /about/index.html.
function handler(event) {
  var request = event.request;
  var host = request.headers.host.value;

  if (host.startsWith('www.')) {
    return {
      statusCode: 301,
      statusDescription: 'Moved Permanently',
      headers: { location: { value: 'https://' + host.substring(4) + request.uri } }
    };
  }

  var uri = request.uri;
  if (uri.endsWith('/')) {
    request.uri = uri + 'index.html';
  } else if (!uri.split('/').pop().includes('.')) {
    request.uri = uri + '/index.html';
  }
  return request;
}

CloudFront Functions run on every request at the edge and cost a fraction of Lambda@Edge. For a rewrite like this they’re the right tool.

The distribution

resource "aws_cloudfront_origin_access_control" "website" {
  name                              = "website-oac"
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
}

resource "aws_cloudfront_distribution" "website" {
  enabled             = true
  is_ipv6_enabled     = true
  default_root_object = "index.html"
  aliases             = [var.domain_name, "www.${var.domain_name}"]
  price_class         = "PriceClass_100"

  origin {
    origin_id                = "s3-website"
    domain_name              = aws_s3_bucket.website.bucket_regional_domain_name
    origin_access_control_id = aws_cloudfront_origin_access_control.website.id
  }

  default_cache_behavior {
    target_origin_id       = "s3-website"
    viewer_protocol_policy = "redirect-to-https"
    allowed_methods        = ["GET", "HEAD", "OPTIONS"]
    cached_methods         = ["GET", "HEAD"]
    compress               = true
    cache_policy_id        = data.aws_cloudfront_cache_policy.caching_optimized.id

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

  # A private bucket returns 403 for missing keys, so map both to the 404 page
  custom_error_response {
    error_code         = 403
    response_code      = 404
    response_page_path = "/404.html"
  }

  restrictions {
    geo_restriction { restriction_type = "none" }
  }

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

Two details people often miss:

  • Use bucket_regional_domain_name, not the S3 website endpoint. OAC only works with the REST endpoint.
  • Map 403 to your 404 page. Without s3:ListBucket, S3 returns 403 (not 404) for missing objects, so visitors would see an ugly XML error.

The bucket policy: only this distribution

data "aws_iam_policy_document" "website_bucket" {
  statement {
    actions   = ["s3:GetObject"]
    resources = ["${aws_s3_bucket.website.arn}/*"]
    principals {
      type        = "Service"
      identifiers = ["cloudfront.amazonaws.com"]
    }
    condition {
      test     = "StringEquals"
      variable = "AWS:SourceArn"
      values   = [aws_cloudfront_distribution.website.arn]
    }
  }
}

The AWS:SourceArn condition matters: without it, any CloudFront distribution in any account could read your bucket.

DNS records

resource "aws_route53_record" "website" {
  for_each = toset(["A", "AAAA"])
  zone_id  = var.zone_id
  name     = var.domain_name
  type     = each.key

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

Repeat for www. Alias records are free in Route 53 and resolve straight to CloudFront’s IPs.

What it costs

For a personal or small business site the bill is tiny: the hosted zone is $0.50/month, ACM certificates are free, and CloudFront’s free tier covers a large amount of traffic each month. S3 storage for a static site is a few cents. For a small site that adds up to roughly a dollar a month, plus the yearly cost of the domain name itself.

Deploying content

Infrastructure is half the job. The other half is getting files into the bucket without storing AWS keys in GitHub. That’s the next post: Deploy to AWS from GitHub Actions without access keys.

Want this set up for your team?

I build this kind of infrastructure for startups and small teams, with fixed prices and full handover.

Newsletter

Practical DevOps, once or twice a month

New tutorials, Terraform snippets and AWS cost tips. No spam, unsubscribe anytime.