Static Website on AWS with S3, CloudFront and ACM using Terraform
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:
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 #
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 #
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 #
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 #
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 #
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 serveindex.htmlautomatically. Add a CloudFront Function that rewrites them, or use a build that generatesabout.html. - For
example.comandwww.example.com, add both names toaliasesand to the certificate (subject_alternative_names). - Cost: CloudFront and S3 are charged by usage. The free tier covers a small site. See cost estimation.