Static Website on AWS with S3, CloudFront and ACM using Terraform

· 1 min read · Terraform & OpenTofu Tutorials

S3 + CloudFront + ACM + Route 53 #

The recommended way to host a static site on AWS is a private S3 bucket that only CloudFront can read, with HTTPS from an ACM certificate and a Route 53 DNS record. The bucket is not a public website endpoint.

Browser -> Route 53 -> CloudFront (HTTPS, cache) -> S3 (private, via OAC)

Providers #

CloudFront certificates must be created in us-east-1, so you need a second provider:

providers.tf
provider "aws" {
  region = "eu-west-1"
}

provider "aws" {
  alias  = "us_east_1"
  region = "us-east-1"
}

variable "domain_name" {
  type    = string
  default = "www.example.com"
}

variable "zone_name" {
  type    = string
  default = "example.com"
}

data "aws_route53_zone" "this" {
  name = var.zone_name
}

Private bucket #

bucket.tf
resource "aws_s3_bucket" "site" {
  bucket = "ditwl-site-${var.domain_name}"
}

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
}

More hardening options are in S3 with Terraform.

Certificate with DNS validation #

acm.tf
resource "aws_acm_certificate" "site" {
  provider          = aws.us_east_1
  domain_name       = var.domain_name
  validation_method = "DNS"

  lifecycle {
    create_before_destroy = true
  }
}

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

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

resource "aws_acm_certificate_validation" "site" {
  provider                = aws.us_east_1
  certificate_arn         = aws_acm_certificate.site.arn
  validation_record_fqdns = [for r in aws_route53_record.validation : r.fqdn]
}

The for_each creates one validation record per domain name. See Route 53 with Terraform.

CloudFront with Origin Access Control #

cloudfront.tf
resource "aws_cloudfront_origin_access_control" "site" {
  name                              = "ditwl-site-oac"
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
}

resource "aws_cloudfront_distribution" "site" {
  enabled             = true
  default_root_object = "index.html"
  aliases             = [var.domain_name]
  price_class         = "PriceClass_100"

  origin {
    domain_name              = aws_s3_bucket.site.bucket_regional_domain_name
    origin_id                = "s3-site"
    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

    # AWS managed policy: CachingOptimized
    cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6"
  }

  custom_error_response {
    error_code         = 404
    response_code      = 404
    response_page_path = "/404.html"
  }

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

Allow only this distribution to read the bucket #

policy.tf
data "aws_iam_policy_document" "site" {
  statement {
    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
}

DNS record #

dns.tf
resource "aws_route53_record" "site" {
  zone_id = data.aws_route53_zone.this.zone_id
  name    = var.domain_name
  type    = "A"

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

Upload and invalidate #

$ aws s3 sync ./public s3://ditwl-site-www.example.com --delete
$ aws cloudfront create-invalidation --distribution-id EXXXXXXXXXXXX --paths "/*"

Terraform creates the infrastructure, and your site build pipeline deploys the files. Do not manage thousands of files with aws_s3_object.

Notes #

  • CloudFront takes several minutes to deploy and to delete.
  • With OAC there are no public objects and no static website endpoint, so directory URLs such as /about/ do not serve index.html automatically. Add a CloudFront Function that rewrites them, or use a build that generates about.html.
  • For example.com and www.example.com, add both names to aliases and to the certificate (subject_alternative_names).
  • Cost: CloudFront and S3 are charged by usage. The free tier covers a small site. See cost estimation.

#AWS #AWS S3 #AWS CLOUDFRONT #Terraform #OpenTofu