LAB30_AWS_Global SPA Hosting: S3 + CloudFront + ACM Certificate + WAF

Global SPA Hosting: S3 + CloudFront + ACM Certificate + WAF

☁ AWS 🔥 Advanced CloudFront ACM WAF v2 Custom Domain OAC

Scenario

Your React SPA needs to be served globally on a custom domain with a valid TLS certificate, protection against common web exploits (SQL injection, XSS), and geoblocking for regions your product does not support. The S3 bucket must remain private — only CloudFront is allowed to read from it.

Your Objectives
  • Request an ACM certificate for your domain using DNS validation. Use the aws provider aliased to us-east-1 (CloudFront requires ACM certs in us-east-1 regardless of your origin region).
  • Create a private S3 bucket with OAC and a bucket policy allowing only CloudFront.
  • Create a WAFv2 Web ACL (scope CLOUDFRONT) with the AWS managed rule group for common exploits (AWSManagedRulesCommonRuleSet) and an IP-based rate limit rule (100 requests/5 minutes per IP).
  • Create a CloudFront distribution with the custom domain, ACM certificate, OAC, WAF Web ACL, and a custom error response (403 → index.html with 200 for SPA routing).
  • Create Route53 alias A and AAAA records for the domain pointing to the CloudFront distribution.

Additional Context

ACM us-east-1 requirement: CloudFront can only use ACM certificates in us-east-1, even if your S3 bucket is in another region. Use a Terraform provider alias to create the certificate in the correct region: provider "aws" { alias = "us_east_1"; region = "us-east-1" } and reference it with provider = aws.us_east_1.

WAFv2 scope: WAFv2 Web ACLs have two scopes: CLOUDFRONT (must be created in us-east-1) and REGIONAL (for ALB, API Gateway, AppSync). You cannot attach a REGIONAL WAF ACL to a CloudFront distribution.

versions.tf

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

providers.tf

# Default provider for your primary region.
provider "aws" { region = var.aws_region }

# ACM certificates for CloudFront MUST be in us-east-1.
# WAFv2 CLOUDFRONT scope also requires us-east-1.
provider "aws" {
  alias  = "us_east_1"
  region = "us-east-1"
}

variables.tf

variable "aws_region"   { type = string; default = "us-east-1" }
variable "domain_name"  { type = string; description = "Root domain, e.g. example.com" }
variable "subdomain"    { type = string; default = "app"; description = "e.g. 'app' creates app.example.com" }
variable "environment"  { type = string; default = "prod" }

main.tf

provider "aws" { region = var.aws_region }
provider "aws" { alias = "us_east_1"; region = "us-east-1" }

locals {
  fqdn = "${var.subdomain}.${var.domain_name}"
  tags = { Environment = var.environment, ManagedBy = "terraform" }
}

data "aws_caller_identity" "current" {}
data "aws_route53_zone" "main" { name = var.domain_name; private_zone = false }

# ACM certificate — must be in us-east-1 for CloudFront.
resource "aws_acm_certificate" "app" {
  provider          = aws.us_east_1
  domain_name       = local.fqdn
  validation_method = "DNS"
  tags              = local.tags
  lifecycle { create_before_destroy = true }
}

# Route53 DNS records for ACM DNS validation.
resource "aws_route53_record" "cert_validation" {
  for_each = {
    for dvo in aws_acm_certificate.app.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.main.zone_id
  name    = each.value.name
  type    = each.value.type
  records = [each.value.record]
  ttl     = 60
}

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

# Private S3 bucket for SPA assets.
resource "aws_s3_bucket" "spa" {
  bucket = "${var.subdomain}-${var.domain_name}-${data.aws_caller_identity.current.account_id}"
  tags   = local.tags
}

resource "aws_s3_bucket_public_access_block" "spa" {
  bucket = aws_s3_bucket.spa.id
  block_public_acls = true; block_public_policy = true
  ignore_public_acls = true; restrict_public_buckets = true
}

resource "aws_s3_object" "index" {
  bucket = aws_s3_bucket.spa.id; key = "index.html"
  content = "

${local.fqdn}

"; content_type = "text/html" } # OAC — modern, secure CloudFront-to-S3 access control. resource "aws_cloudfront_origin_access_control" "spa" { name = "${var.subdomain}-oac" origin_access_control_origin_type = "s3" signing_behavior = "always" signing_protocol = "sigv4" } # WAFv2 Web ACL — must be in us-east-1 for CLOUDFRONT scope. resource "aws_wafv2_web_acl" "spa" { provider = aws.us_east_1 name = "spa-waf-${var.environment}" scope = "CLOUDFRONT" description = "WAF for ${local.fqdn}" default_action { allow {} } # AWS Managed Common Rule Set — covers OWASP Top 10. rule { name = "AWSManagedRulesCommonRuleSet" priority = 1 override_action { none {} } statement { managed_rule_group_statement { vendor_name = "AWS"; name = "AWSManagedRulesCommonRuleSet" } } visibility_config { cloudwatch_metrics_enabled = true; metric_name = "CommonRuleSet"; sampled_requests_enabled = true } } # Rate limit — block IPs sending more than 100 requests per 5 minutes. rule { name = "RateLimit" priority = 2 action { block {} } statement { rate_based_statement { limit = 100; aggregate_key_type = "IP" } } visibility_config { cloudwatch_metrics_enabled = true; metric_name = "RateLimit"; sampled_requests_enabled = true } } visibility_config { cloudwatch_metrics_enabled = true; metric_name = "spa-waf"; sampled_requests_enabled = true } tags = local.tags } # CloudFront distribution with custom domain, HTTPS, OAC, WAF. resource "aws_cloudfront_distribution" "spa" { enabled = true is_ipv6_enabled = true default_root_object = "index.html" aliases = [local.fqdn] web_acl_id = aws_wafv2_web_acl.spa.arn origin { domain_name = aws_s3_bucket.spa.bucket_regional_domain_name origin_id = "s3-spa" origin_access_control_id = aws_cloudfront_origin_access_control.spa.id } default_cache_behavior { allowed_methods = ["GET", "HEAD"] cached_methods = ["GET", "HEAD"] target_origin_id = "s3-spa" viewer_protocol_policy = "redirect-to-https" compress = true cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6" # CachingOptimized } custom_error_response { error_code = 403; response_code = 200 response_page_path = "/index.html"; error_caching_min_ttl = 0 } viewer_certificate { acm_certificate_arn = aws_acm_certificate_validation.app.certificate_arn ssl_support_method = "sni-only" minimum_protocol_version = "TLSv1.2_2021" } restrictions { geo_restriction { restriction_type = "none" } } price_class = "PriceClass_100" tags = local.tags } # S3 bucket policy — only CloudFront OAC can read objects. resource "aws_s3_bucket_policy" "spa" { bucket = aws_s3_bucket.spa.id policy = jsonencode({ Version = "2012-10-17" Statement = [{ Sid = "AllowCloudFront"; Effect = "Allow" Principal = { Service = "cloudfront.amazonaws.com" } Action = "s3:GetObject"; Resource = "${aws_s3_bucket.spa.arn}/*" Condition = { StringEquals = { "aws:SourceArn" = aws_cloudfront_distribution.spa.arn } } }] }) depends_on = [aws_cloudfront_distribution.spa] } # Route53 alias A and AAAA records for the CloudFront distribution. resource "aws_route53_record" "app" { for_each = toset(["A", "AAAA"]) zone_id = data.aws_route53_zone.main.zone_id name = local.fqdn type = each.key alias { name = aws_cloudfront_distribution.spa.domain_name zone_id = aws_cloudfront_distribution.spa.hosted_zone_id evaluate_target_health = false } }

outputs.tf

output "site_url"          { value = "https://${local.fqdn}" }
output "cloudfront_domain" { value = aws_cloudfront_distribution.spa.domain_name }
output "certificate_arn"   { value = aws_acm_certificate.app.arn }

Workflow Commands

terraform init
terraform apply -var="domain_name=example.com"

# Verify HTTPS and custom domain (allow DNS propagation time)
curl -I https://app.example.com/

# View WAF blocked requests in CloudWatch
aws cloudwatch get-metric-statistics \
  --namespace AWS/WAFV2 --metric-name BlockedRequests \
  --dimensions Name=WebACL,Value=spa-waf-prod Name=Rule,Value=RateLimit \
  --start-time $(date -u -v-1H +%Y-%m-%dT%H:%M:%SZ) \
  --end-time $(date -u +%Y-%m-%dT%H:%M:%SZ) \
  --period 300 --statistics Sum

terraform destroy -var="domain_name=example.com"
✓ Expected Output After Apply
Apply complete! Resources: 12 added, 0 changed, 0 destroyed.

Outputs:

certificate_arn   = "arn:aws:acm:us-east-1:123456789012:certificate/xxxx"
cloudfront_domain = "d1234567890abc.cloudfront.net"
site_url          = "https://app.example.com"