Skip to content
ExponaBLOG
TERRAFORM, IAC & CLOUD AUTOMATION · DAY 065 / 100

Static website hosting with Terraform in Terraform & IaC

S3, CloudFront and Route 53 together.

Expona Admin8 min read

Hook

Deploying a static website to production requires more than just uploading files to storage—it needs a content delivery network for global performance, HTTPS for security, and DNS routing to tie it all together. Static website hosting with Terraform in Terraform & IaC lets us define this entire stack as code, making it reproducible, auditable, and version-controlled alongside the rest of our infrastructure.

Why this matters for production static sites

We've all seen the temptation to manually create an S3 bucket, grant it public access, and call it done. That approach works for demos, but it leaves us vulnerable to misconfiguration, makes scaling across environments painful, and offers no paper trail of who changed what. When we combine S3 for storage, CloudFront for caching and delivery, Route 53 for DNS, and ACM for HTTPS certificates—all defined in Terraform—we get a robust, repeatable pattern suitable for production workloads like marketing sites, documentation portals, and single-page applications.

Private bucket with Origin Access Control

The foundation of a secure static site is a private S3 bucket. No public ACLs, no public access blocks disabled—just a bucket that holds our content and nothing else. We then use CloudFront's Origin Access Control (OAC) to grant CloudFront exclusive permission to read from the bucket.

hcl
resource "aws_s3_bucket" "website" {
  bucket = "my-site-${data.aws_caller_identity.current.account_id}"
}

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

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_cloudfront_origin_access_control" "website" {
  name                              = "website-oac"
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
}

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

resource "aws_s3_bucket_policy" "website" {
  bucket = aws_s3_bucket.website.id
  policy = data.aws_iam_policy_document.s3_policy.json
}

This setup ensures that only CloudFront can access the bucket contents, and only when requests come through the specific distribution. The versioning enables rollback if a deployment goes wrong. In a real scenario, we'd also configure an S3 lifecycle policy to clean up old versions after 90 days.

CloudFront distribution with caching strategy

CloudFront is where the magic happens. It caches our content at edge locations worldwide, serves it over HTTPS, and can rewrite URLs. We configure a default root object so requests to the domain root return index.html, set custom error pages for 404s and 403s, and define cache behavior that's appropriate for static assets.

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

  enabled             = true
  is_ipv6_enabled     = true
  default_root_object = "index.html"

  default_cache_behavior {
    allowed_methods  = ["GET", "HEAD", "OPTIONS"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = "s3-origin"

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

    viewer_protocol_policy = "redirect-to-https"
    min_ttl                = 0
    default_ttl            = 3600
    max_ttl                = 86400
    compress               = true
  }

  cache_behaviors {
    path_pattern     = "/assets/*"
    allowed_methods  = ["GET", "HEAD"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = "s3-origin"

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

    viewer_protocol_policy = "https-only"
    min_ttl                = 0
    default_ttl            = 31536000
    max_ttl                = 31536000
    compress               = true
  }

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

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

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  viewer_certificate {
    cloudfront_default_certificate = false
    acm_certificate_arn            = aws_acm_certificate.website.arn
    ssl_support_method             = "sni-only"
    minimum_protocol_version       = "TLSv1.2_2021"
  }
}

Notice the cache behavior split: HTML and dynamic content get a short 1-hour TTL, while assets like CSS and JavaScript under /assets/* get a one-year TTL with versioning in the filename. This is a real-world strategy we use across e-commerce and SaaS platforms. The custom error responses also handle the classic SPA routing issue where a 403 (forbidden, because S3 doesn't list directories) should return our index.html so the front-end router can take over.

ACM certificate and DNS routing

HTTPS is non-negotiable, and AWS Certificate Manager (ACM) makes it free and simple. One gotcha: CloudFront certificates must be in us-east-1 regardless of your primary region.

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

resource "aws_acm_certificate" "website" {
  provider          = aws.us_east_1
  domain_name       = "example.com"
  validation_method = "DNS"

  subject_alternative_names = [
    "www.example.com"
  ]

  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 => {
      name   = dvo.resource_record_name
      type   = dvo.resource_record_type
      record = dvo.resource_record_value
    }
  }

  zone_id = aws_route53_zone.website.zone_id
  name    = each.value.name
  type    = each.value.type
  ttl     = 60
  records = [each.value.record]
}

resource "aws_acm_certificate_validation" "website" {
  provider        = aws.us_east_1
  certificate_arn = aws_acm_certificate.website.arn

  timeouts {
    create = "5m"
  }

  depends_on = [aws_route53_record.cert_validation]
}

resource "aws_route53_record" "website_alias" {
  zone_id = aws_route53_zone.website.zone_id
  name    = "example.com"
  type    = "A"

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

resource "aws_route53_record" "website_www_alias" {
  zone_id = aws_route53_zone.website.zone_id
  name    = "www.example.com"
  type    = "A"

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

We define the certificate with DNS validation, create Route 53 records to prove domain ownership, and wait for validation to complete before proceeding. The alias records then point both the root domain and www subdomain to CloudFront. This pattern is bulletproof: if we ever need to change the certificate or distribution, Terraform will coordinate the updates safely.

Deploy: Uploading builds and cache invalidation

Once infrastructure is in place, we need a process to upload new builds and keep the cache fresh. We typically do this from CI/CD—GitHub Actions, GitLab CI, or AWS CodePipeline.

hcl
resource "aws_s3_object" "website_files" {
  for_each = fileset("${path.module}/dist", "**")

  bucket       = aws_s3_bucket.website.id
  key          = each.value
  source       = "${path.module}/dist/${each.value}"
  etag         = filemd5("${path.module}/dist/${each.value}")
  content_type = lookup(local.mime_types, regex("\\.[^.]+$", each.value), "application/octet-stream")
}

locals {
  mime_types = {
    ".html" = "text/html"
    ".css"  = "text/css"
    ".js"   = "application/javascript"
    ".json" = "application/json"
    ".png"  = "image/png"
    ".jpg"  = "image/jpeg"
    ".svg"  = "image/svg+xml"
    ".woff" = "font/woff"
    ".woff2" = "font/woff2"
  }
}

resource "aws_cloudfront_invalidation" "website" {
  distribution_id = aws_cloudfront_distribution.website.id
  paths           = ["/*"]

  depends_on = [aws_s3_object.website_files]
}

The fileset function automatically discovers all files in the dist directory, uploads them with correct MIME types, and triggers a CloudFront invalidation. In CI, we'd run terraform apply after building the site; Terraform detects changed ETags and re-uploads only what's needed. For even faster feedback in CD pipelines, we can use the AWS CLI directly:

hcl
#!/bin/bash
aws s3 sync dist/ s3://my-site-bucket/ \
  --region us-east-1 \
  --delete \
  --cache-control "public, max-age=3600" \
  --exclude "assets/*" \
  --metadata "deploy-date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')"

aws s3 sync dist/assets/ s3://my-site-bucket/assets/ \
  --region us-east-1 \
  --cache-control "public, max-age=31536000, immutable" \
  --metadata "deploy-date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')"

aws cloudfront create-invalidation \
  --distribution-id E1234ABCD \
  --paths "/*"

This pattern ensures HTML refreshes within an hour while assets stay cached for a year, providing both freshness and performance.

Common mistakes

  • Forgetting the us-east-1 provider alias. CloudFront only accepts ACM certificates from us-east-1. If you create the certificate in your primary region, the distribution won't find it.
  • Invalidating the entire distribution on every deploy. CloudFront charges for invalidations beyond the first 100 per month. Be specific: invalidate only /index.html and /404.html if those are the only files that changed.
  • Setting cache TTLs without version hashing. If your asset filenames don't include a hash, long TTLs will serve stale CSS and JavaScript. Always hash your build output filenames.
  • Missing the S3 bucket policy condition. Without the AWS:SourceArn condition, any CloudFront distribution could access your bucket. Always tie OAC permissions to a specific distribution.
  • Not enabling S3 versioning. Versioning is cheap and lets you roll back a bad deployment in seconds by restoring an older object version.

Key takeaways

  • Keep the S3 bucket private with public access blocks, and use CloudFront Origin Access Control to grant only CloudFront read permissions.
  • Split cache behaviors by content type: short TTLs for HTML, long TTLs for versioned assets.
  • Always provision ACM certificates in us-east-1 for CloudFront, and use DNS validation to automate the provisioning process.
  • Coordinate S3 uploads and CloudFront invalidations in your CI pipeline to ensure consistency between what's uploaded and what's served.
  • Use S3 versioning and lifecycle policies to keep storage costs manageable while retaining the ability to roll back.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles