TerraformAWS

AWS S3 + CloudFront Static Site

Host a static website on S3 with CloudFront CDN, HTTPS via ACM, and origin access control.

s3cloudfrontcdnstatic-sitehttps

Prerequisites

  • Domain registered in Route 53 (or external)
  • AWS Certificate Manager certificate in us-east-1 for your domain
  • Terraform >= 1.5.0

Template Code

# ─────────────────────────────────────────────────────────────────────────────
# AWS S3 + CloudFront Static Website
# ─────────────────────────────────────────────────────────────────────────────

variable "domain_name"      {}
variable "certificate_arn"  {}
variable "environment"      { default = "production" }

locals {
  s3_bucket_name = "${var.environment}-${replace(var.domain_name, ".", "-")}-site"
}

# ── S3 Bucket (no public access) ──────────────────────────────────────────────
resource "aws_s3_bucket" "site" {
  bucket = local.s3_bucket_name
  tags   = { Environment = var.environment }
}

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
}

# ── CloudFront Origin Access Control ─────────────────────────────────────────
resource "aws_cloudfront_origin_access_control" "site" {
  name                              = local.s3_bucket_name
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
}

# ── CloudFront Distribution ───────────────────────────────────────────────────
resource "aws_cloudfront_distribution" "site" {
  enabled             = true
  is_ipv6_enabled     = true
  default_root_object = "index.html"
  aliases             = [var.domain_name]
  price_class         = "PriceClass_100"  # US + Europe + Asia

  origin {
    domain_name              = aws_s3_bucket.site.bucket_regional_domain_name
    origin_id                = "S3-${local.s3_bucket_name}"
    origin_access_control_id = aws_cloudfront_origin_access_control.site.id
  }

  default_cache_behavior {
    allowed_methods        = ["GET", "HEAD"]
    cached_methods         = ["GET", "HEAD"]
    target_origin_id       = "S3-${local.s3_bucket_name}"
    viewer_protocol_policy = "redirect-to-https"
    compress               = true

    forwarded_values {
      query_string = false
      cookies { forward = "none" }
    }

    min_ttl     = 0
    default_ttl = 86400   # 1 day
    max_ttl     = 604800  # 7 days
  }

  # Custom 404 → index.html for SPA routing
  custom_error_response {
    error_code         = 404
    response_code      = 200
    response_page_path = "/index.html"
  }

  restrictions {
    geo_restriction { restriction_type = "none" }
  }

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

  tags = { Environment = var.environment }
}

# ── S3 Bucket Policy (allow CloudFront only) ──────────────────────────────────
data "aws_iam_policy_document" "site_bucket_policy" {
  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_bucket_policy.json
}

output "cloudfront_url"         { value = "https://${aws_cloudfront_distribution.site.domain_name}" }
output "cloudfront_dist_id"     { value = aws_cloudfront_distribution.site.id }
output "s3_bucket_name"         { value = aws_s3_bucket.site.bucket }

Usage

terraform init
terraform apply -var="domain_name=www.example.com" -var="certificate_arn=arn:aws:acm:..."