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

CloudFront with Terraform for Terraform & IaC developers

CDN in front of your app.

Expona Admin10 min read

Why CloudFront Matters for Modern Applications

A global CDN can reduce latency by 70–90% for your users, but only if you configure it correctly. CloudFront with Terraform for Terraform & IaC developers means you can codify your edge distribution strategy, control caching rules, and manage SSL certificates all from version control. We'll walk through the patterns that work in production e-commerce, SaaS, and travel platforms.

Origins: S3 or Load Balancer

CloudFront sits in front of your origin—either an Amazon S3 bucket serving static assets or an Application Load Balancer (ALB) fronting your application servers. The choice depends on your content type and freshness requirements.

S3 as origin works best for images, CSS, JavaScript, and other static assets that rarely change. ALB or API Gateway as origin is the pattern when you need dynamic content, personalization, or server-side logic.

Here's a distribution pointing to an S3 bucket:

hcl
resource "aws_cloudfront_distribution" "static_assets" {
  origin {
    domain_name = aws_s3_bucket.assets.bucket_regional_domain_name
    origin_id   = "S3Origin"

    s3_origin_config {
      origin_access_identity = aws_cloudfront_origin_access_identity.oai.cloudfront_access_identity_path
    }
  }

  enabled = true
  default_root_object = "index.html"

  default_cache_behavior {
    allowed_methods  = ["GET", "HEAD", "OPTIONS"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = "S3Origin"

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

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

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  viewer_certificate {
    cloudfront_default_certificate = true
  }
}

For an application load balancer origin, you'd instead reference the ALB's DNS name and allow more HTTP methods:

hcl
resource "aws_cloudfront_distribution" "app" {
  origin {
    domain_name = aws_lb.main.dns_name
    origin_id   = "ALBOrigin"

    custom_origin_config {
      http_port              = 80
      https_port             = 443
      origin_protocol_policy = "https-only"
      origin_ssl_protocols   = ["TLSv1.2"]
    }
  }

  enabled = true

  default_cache_behavior {
    allowed_methods  = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = "ALBOrigin"

    forwarded_values {
      query_string = true
      cookies {
        forward = "all"
      }
      headers = ["Host", "User-Agent", "Accept", "Accept-Language"]
    }

    viewer_protocol_policy = "redirect-to-https"
    min_ttl                = 0
    default_ttl            = 0
    max_ttl                = 0
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  viewer_certificate {
    cloudfront_default_certificate = true
  }
}

Notice the difference: S3 origins use s3_origin_config, while ALBs use custom_origin_config. With ALB, we're forwarding all HTTP methods and headers to preserve request context for your application.

Origin Access Control for Private Buckets

Serving assets directly from a public S3 bucket is a security risk—users could bypass CloudFront entirely. Origin Access Identity (OAI) or the newer Origin Access Control (OAC) restricts access so only CloudFront can read from your bucket.

OAC is the modern approach recommended by AWS for new distributions. Here's how to set it up:

hcl
resource "aws_cloudfront_origin_access_control" "s3_oac" {
  name                              = "s3-oac"
  description                       = "OAC for S3 bucket access"
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
}

resource "aws_s3_bucket" "assets" {
  bucket = "my-app-assets-prod"
}

resource "aws_s3_bucket_public_access_block" "assets" {
  bucket = aws_s3_bucket.assets.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_bucket_policy" "assets" {
  bucket = aws_s3_bucket.assets.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "AllowCloudFrontOAC"
        Effect = "Allow"
        Principal = {
          Service = "cloudfront.amazonaws.com"
        }
        Action   = "s3:GetObject"
        Resource = "${aws_s3_bucket.assets.arn}/*"
        Condition = {
          StringEquals = {
            "AWS:SourceArn" = "arn:aws:cloudfront::${data.aws_caller_identity.current.account_id}:distribution/${aws_cloudfront_distribution.static_assets.id}"
          }
        }
      }
    ]
  })
}

resource "aws_cloudfront_distribution" "static_assets" {
  origin {
    domain_name            = aws_s3_bucket.assets.bucket_regional_domain_name
    origin_access_control_origin_id = aws_cloudfront_origin_access_control.s3_oac.id
    origin_id              = "S3Origin"
  }

  # ... rest of config
}

This setup blocks direct S3 access entirely. CloudFront signs requests with the OAC credentials, and the bucket policy only allows those signed requests. Your assets are now distributed globally without public exposure.

Cache Policies per Path

Real applications have mixed content: some assets cache for days, others for minutes, and API responses shouldn't cache at all. CloudFront's cache_behavior blocks let you apply different policies to different URL patterns.

Here's a practical example from an e-commerce site:

hcl
resource "aws_cloudfront_distribution" "ecommerce" {
  origin {
    domain_name = aws_lb.main.dns_name
    origin_id   = "ALBOrigin"
    custom_origin_config {
      http_port              = 80
      https_port             = 443
      origin_protocol_policy = "https-only"
      origin_ssl_protocols   = ["TLSv1.2"]
    }
  }

  enabled = true

  # Default: no caching for dynamic pages
  default_cache_behavior {
    allowed_methods  = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = "ALBOrigin"

    forwarded_values {
      query_string = true
      cookies {
        forward = "all"
      }
    }

    viewer_protocol_policy = "redirect-to-https"
    min_ttl                = 0
    default_ttl            = 0
    max_ttl                = 0
  }

  # Static assets: cache for 1 year
  cache_behavior {
    path_pattern     = "/assets/*"
    allowed_methods  = ["GET", "HEAD"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = "ALBOrigin"

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

    viewer_protocol_policy = "redirect-to-https"
    min_ttl                = 0
    default_ttl            = 31536000
    max_ttl                = 31536000
  }

  # Product images: cache for 7 days, allow query strings
  cache_behavior {
    path_pattern     = "/images/*"
    allowed_methods  = ["GET", "HEAD"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = "ALBOrigin"

    forwarded_values {
      query_string = true
      query_string_cache_keys {
        query_strings = ["size", "format"]
      }
      cookies {
        forward = "none"
      }
    }

    viewer_protocol_policy = "redirect-to-https"
    min_ttl                = 0
    default_ttl            = 604800
    max_ttl                = 604800
  }

  # API endpoints: cache only successful responses
  cache_behavior {
    path_pattern     = "/api/*"
    allowed_methods  = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = "ALBOrigin"

    forwarded_values {
      query_string = true
      cookies {
        forward = "all"
      }
      headers = ["Authorization", "Host"]
    }

    viewer_protocol_policy = "redirect-to-https"
    min_ttl                = 0
    default_ttl            = 0
    max_ttl                = 300
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  viewer_certificate {
    cloudfront_default_certificate = true
  }
}

The order matters: CloudFront evaluates cache_behavior blocks top-to-bottom, so place more specific patterns before general ones. This configuration gives you fine-grained control: versioned assets live forever at edge locations, product images refresh weekly, and API calls bypass cache by default.

ACM Certificate in us-east-1

CloudFront requires SSL/TLS certificates to be in the us-east-1 region, even if your distribution serves a global audience. This is a CloudFront quirk you must accommodate in your Terraform setup.

Create an ACM certificate in us-east-1 and reference it in your distribution:

hcl
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

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

provider "aws" {
  region = "eu-west-1"  # or your primary region
}

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

  subject_alternative_names = [
    "*.example.com",
    "example.com"
  ]

  tags = {
    Name = "cloudfront-cert"
  }

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_route53_record" "cloudfront_validation" {
  for_each = {
    for dvo in aws_acm_certificate.cloudfront.domain_validation_options : dvo.domain_name => {
      name   = dvo.resource_record_name
      record = dvo.resource_record_value
      type   = dvo.resource_record_type
    }
  }

  allow_overwrite = true
  name            = each.value.name
  records         = [each.value.record]
  ttl             = 60
  type            = each.value.type
  zone_id         = aws_route53_zone.main.zone_id
}

resource "aws_acm_certificate_validation" "cloudfront" {
  provider        = aws.us_east_1
  certificate_arn = aws_acm_certificate.cloudfront.arn
  timeouts {
    create = "5m"
  }

  depends_on = [aws_route53_record.cloudfront_validation]
}

resource "aws_cloudfront_distribution" "secure" {
  origin {
    domain_name = aws_lb.main.dns_name
    origin_id   = "ALBOrigin"
    custom_origin_config {
      http_port              = 80
      https_port             = 443
      origin_protocol_policy = "https-only"
      origin_ssl_protocols   = ["TLSv1.2"]
    }
  }

  enabled = true

  default_cache_behavior {
    allowed_methods  = ["GET", "HEAD", "OPTIONS"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = "ALBOrigin"

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

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

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

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

  depends_on = [aws_acm_certificate_validation.cloudfront]
}

The key pattern: use an alias provider for us-east-1, create your certificate there, validate it with Route53, then reference the ARN in your CloudFront distribution. The sni-only SSL method is cheaper and faster than vip.

Common Mistakes

  • Forgetting to restrict S3 access. If you don't use OAC, users can hit your bucket directly and bypass caching, CloudFront protections, and WAF rules. Always block public access and configure the bucket policy.
  • Wrong TTL values. Setting max_ttl lower than default_ttl creates confusion. Ensure min_ttl ≤ default_ttl ≤ max_ttl. CloudFront respects Cache-Control headers from your origin first, then falls back to your configured values.
  • Not forwarding headers to ALB origins. If your application needs cookies or custom headers for authentication, and you don't forward them, users will see stale cached responses. Always forward Authorization, Host, and session cookies.
  • Creating the certificate in the wrong region. ACM certificates for CloudFront must be in us-east-1. If you try to use a certificate from another region, CloudFront will reject it during apply.
  • Not invalidating after deployments. When you push new code or assets, add a CloudFront invalidation to clear the edge cache. Without it, users see old versions for hours. Use aws_cloudfront_invalidation in your pipeline.

Key Takeaways

  • CloudFront with Terraform for Terraform & IaC developers means defining your edge strategy in code: choose S3 for static assets, ALB for dynamic content, and combine them in a single distribution.
  • Use Origin Access Control (OAC) to lock down S3 buckets so only CloudFront can read objects. Block public access entirely and let the bucket policy enforce the constraint.
  • Layer cache behaviors by path pattern: long TTLs for versioned assets, moderate TTLs for images, zero TTL for API responses and dynamic HTML.
  • Always provision ACM certificates in us-east-1 using an alias provider, validate with Route53, and reference the ARN in your viewer_certificate block.
  • Forward headers and cookies strategically: static assets don't need them, but application origins and API routes require headers like Authorization and Host to work correctly.
  • Test cache behavior locally using curl headers like Cache-Control and X-Amz-CloudFront-* to verify TTLs and invalidation work as expected.
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