Skip to content
Infrastructure as Code projects 5 min read

Project: Static Website on S3

Static sites are everywhere — marketing pages, documentation, single-page apps — and S3 plus CloudFront is the canonical AWS pattern for serving them cheaply and globally. In this project you will build the whole stack in Terraform: a private S3 bucket holding the files, a CloudFront distribution caching them at edge locations, an ACM certificate for HTTPS, and a Route 53 record pointing your domain at CloudFront. The result is a reusable, version-controlled deployment you can spin up for any domain in minutes. Everything here works identically with OpenTofu (tofu in place of terraform).

Architecture overview

The flow is simple: visitors hit your domain, Route 53 resolves it to CloudFront, CloudFront serves cached objects (or fetches them from S3 on a miss) over HTTPS using an ACM certificate. The S3 bucket itself stays private — only CloudFront can read it, via an Origin Access Control (OAC).

ComponentPurposeNotes
S3 bucketStores the site filesPrivate; no public access
CloudFrontGlobal CDN + TLS terminationUses OAC to read S3
ACM certificateHTTPS for your domainMust live in us-east-1
Route 53DNS for the domainAlias record to CloudFront

ACM certificates used by CloudFront must be created in the us-east-1 region, regardless of where the rest of your infrastructure lives. We handle this with a provider alias below.

Providers and variables

Define two AWS providers — one for your default region and an aliased one pinned to us-east-1 for ACM.

terraform {
  required_version = ">= 1.5.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.40"
    }
  }
}

provider "aws" {
  region = var.region
}

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

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

variable "domain_name" {
  type        = string
  description = "FQDN to serve the site from, e.g. www.example.com"
}

variable "zone_name" {
  type        = string
  description = "Route 53 hosted zone, e.g. example.com"
}

The S3 origin bucket

Create the bucket and lock it down. Public access is fully blocked; CloudFront reaches it through OAC instead of public URLs.

resource "aws_s3_bucket" "site" {
  bucket = 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
}

resource "aws_s3_bucket_website_configuration" "site" {
  bucket = aws_s3_bucket.site.id
  index_document {
    suffix = "index.html"
  }
  error_document {
    key = "404.html"
  }
}

ACM certificate with DNS validation

Request the certificate in us-east-1 and validate it automatically by writing the CNAME records into Route 53.

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

resource "aws_acm_certificate" "cert" {
  provider          = aws.us_east_1
  domain_name       = 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.cert.domain_validation_options : dvo.domain_name => {
      name   = dvo.resource_record_name
      type   = dvo.resource_record_type
      record = dvo.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     = 60
}

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

CloudFront distribution with OAC

The Origin Access Control signs requests so only CloudFront can read the bucket. The distribution serves over HTTPS and redirects HTTP to HTTPS.

resource "aws_cloudfront_origin_access_control" "site" {
  name                              = "${var.domain_name}-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]

  origin {
    domain_name              = aws_s3_bucket.site.bucket_regional_domain_name
    origin_id                = "s3-origin"
    origin_access_control_id = aws_cloudfront_origin_access_control.site.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        = "658327ea-f89d-4fab-a63d-7e88639e58f6" # Managed-CachingOptimized
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

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

Finally, attach a bucket policy that grants the distribution read access, and point DNS at CloudFront.

resource "aws_s3_bucket_policy" "site" {
  bucket = aws_s3_bucket.site.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Sid       = "AllowCloudFrontRead"
      Effect    = "Allow"
      Principal = { Service = "cloudfront.amazonaws.com" }
      Action    = "s3:GetObject"
      Resource  = "${aws_s3_bucket.site.arn}/*"
      Condition = {
        StringEquals = {
          "AWS:SourceArn" = aws_cloudfront_distribution.site.arn
        }
      }
    }]
  })
}

resource "aws_route53_record" "alias" {
  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
  }
}

Deploy and upload

Initialize, review the plan, then apply.

terraform init
terraform plan -var 'domain_name=www.example.com' -var 'zone_name=example.com'
terraform apply -var 'domain_name=www.example.com' -var 'zone_name=example.com'

Output:

Plan: 9 to add, 0 to change, 0 to destroy.
...
aws_cloudfront_distribution.site: Creation complete after 4m12s
Apply complete! Resources: 9 added, 0 changed, 0 destroyed.

Once the distribution is deployed, push your built site to the bucket. CloudFront serves it within seconds.

aws s3 sync ./dist s3://www.example.com --delete
aws cloudfront create-invalidation \
  --distribution-id E123EXAMPLE --paths "/*"

The first apply can take several minutes because CloudFront propagates to every edge location. Subsequent content updates only require an s3 sync plus a cache invalidation — no Terraform run needed.

Best practices

  • Keep the bucket private and rely exclusively on OAC; never re-enable public ACLs to “make it work.”
  • Use AWS managed cache policies (like CachingOptimized) instead of hand-rolling legacy forwarded_values, which is deprecated.
  • Pin the ACM provider to us-east-1 with an alias so the certificate is valid for CloudFront.
  • Set short TTLs on index.html (or invalidate on deploy) so users see fresh content, while letting hashed assets cache long-term.
  • Store Terraform state remotely (S3 + DynamoDB lock, or a backend) so the deployment is collaborative and recoverable.
  • Parameterize domain_name and zone_name so the same configuration — or a module wrapping it — serves any site.
Last updated June 14, 2026
Was this helpful?