Quick take — A reusable hashicorp/aws ~> 5.0 module for aws_cloudfront_distribution: S3 Origin Access Control, custom + S3 origins, managed cache/origin-request policies, ACM TLS, WAF, and custom error responses. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.
Quickstart (copy-paste)
Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):
provider "aws" {
region = "us-east-1"
}
module "cloudfront" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-cloudfront?ref=v1.0.0"
name = "..." # Logical name; used in comment, OAC name, and `Name` tag.
origins = ["...", "..."] # Origins to fetch from; `use_oac` for private S3, `custo…
default_cache_behavior = {} # Catch-all behavior; `cache_policy_name` is an AWS-manag…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
Amazon CloudFront is AWS’s global content delivery network (CDN). It puts your content on hundreds of edge locations so that a viewer in Mumbai, Frankfurt, or São Paulo terminates TLS and is served close to home, while CloudFront fetches from your origin (an S3 bucket, an ALB, an API Gateway, or any custom HTTP server) only on a cache miss. Beyond raw caching it is also a security perimeter: it terminates HTTPS with an ACM certificate, can sit in front of a private S3 bucket via Origin Access Control (OAC) so the bucket is never public, attaches an AWS WAF web ACL, and enforces geo-restrictions — all at the edge, before traffic ever reaches your account.
aws_cloudfront_distribution is one of the gnarlier resources in the AWS provider. It nests origin, default_cache_behavior, ordered_cache_behavior, viewer_certificate, restrictions, custom_error_response, and logging_config blocks, each with its own required-vs-optional minefield. Three things bite people repeatedly: the ACM certificate must live in us-east-1 no matter where the rest of your stack runs; you should drive caching with managed cache and origin-request policies rather than the deprecated forwarded_values block; and CloudFront-to-S3 access should use OAC (the modern replacement for Origin Access Identity). This module wraps all of that into one var-driven building block that bakes in TLS-1.2 minimums, optional OAC wiring, managed-policy lookups, and consistent tagging — so every distribution your org ships is secure and uniform by default.
When to use it
- You front a static site or SPA stored in a private S3 bucket and want CloudFront to be the only thing that can read it (OAC), with SPA-friendly
404 → /index.htmlrewrites via custom error responses. - You put a CDN in front of a dynamic origin — an ALB, API Gateway, or custom HTTP app — to terminate TLS at the edge, offload static paths, and shrink origin egress.
- You need a custom domain (
cdn.example.com) on HTTPS with an ACM certificate and anaws_route53_recordalias pointing at the distribution. - You want a security edge: an attached WAF web ACL, viewer-protocol redirect-to-HTTPS, a TLS-1.2+ floor, and geo allow/deny lists, standardised across every property.
- You are running many distributions (per app, per environment) and want one validated module so cache policies, logging, and price classes are consistent rather than hand-rolled each time.
If you only need object storage with no edge caching, you do not need this — serve from S3 directly. If your “CDN” is purely internal east-west traffic, CloudFront is the wrong tool.
Module structure
terraform-module-aws-cloudfront/
├── versions.tf # provider + Terraform version pins
├── main.tf # OAC, managed-policy lookups, aws_cloudfront_distribution
├── variables.tf # var-driven inputs with validations
└── outputs.tf # id, arn, domain_name, hosted_zone_id, OAC id
# versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
# main.tf
locals {
tags = merge(
var.tags,
{
Name = var.name
ManagedBy = "terraform"
},
)
# Create an OAC only when at least one origin opts into it.
use_oac = length([for o in var.origins : o if o.use_oac]) > 0
}
# ---------------------------------------------------------------------------
# Origin Access Control — modern, SigV4-signed access to private S3 origins.
# Replaces the legacy Origin Access Identity. The S3 bucket policy must grant
# cloudfront.amazonaws.com read access scoped to this distribution's ARN.
# ---------------------------------------------------------------------------
resource "aws_cloudfront_origin_access_control" "this" {
count = local.use_oac ? 1 : 0
name = "${var.name}-oac"
description = "OAC for ${var.name}"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
# ---------------------------------------------------------------------------
# Managed policy lookups — drive caching declaratively instead of the
# deprecated forwarded_values block. Names map to AWS-managed policies, e.g.
# "Managed-CachingOptimized", "Managed-CachingDisabled", "Managed-AllViewer".
# ---------------------------------------------------------------------------
data "aws_cloudfront_cache_policy" "default" {
name = var.default_cache_behavior.cache_policy_name
}
data "aws_cloudfront_origin_request_policy" "default" {
count = var.default_cache_behavior.origin_request_policy_name != null ? 1 : 0
name = var.default_cache_behavior.origin_request_policy_name
}
data "aws_cloudfront_cache_policy" "ordered" {
for_each = { for b in var.ordered_cache_behaviors : b.path_pattern => b }
name = each.value.cache_policy_name
}
data "aws_cloudfront_origin_request_policy" "ordered" {
for_each = {
for b in var.ordered_cache_behaviors :
b.path_pattern => b if b.origin_request_policy_name != null
}
name = each.value.origin_request_policy_name
}
resource "aws_cloudfront_distribution" "this" {
enabled = var.enabled
is_ipv6_enabled = var.is_ipv6_enabled
comment = var.comment
default_root_object = var.default_root_object
aliases = var.aliases
price_class = var.price_class
web_acl_id = var.web_acl_id
http_version = var.http_version
retain_on_delete = var.retain_on_delete
wait_for_deployment = var.wait_for_deployment
dynamic "origin" {
for_each = { for o in var.origins : o.origin_id => o }
content {
origin_id = origin.value.origin_id
domain_name = origin.value.domain_name
origin_path = origin.value.origin_path
connection_attempts = origin.value.connection_attempts
connection_timeout = origin.value.connection_timeout
origin_access_control_id = origin.value.use_oac ? aws_cloudfront_origin_access_control.this[0].id : null
# Custom (non-S3) origins: ALB, API Gateway, any HTTP server.
dynamic "custom_origin_config" {
for_each = origin.value.custom_origin_config != null ? [origin.value.custom_origin_config] : []
content {
http_port = custom_origin_config.value.http_port
https_port = custom_origin_config.value.https_port
origin_protocol_policy = custom_origin_config.value.origin_protocol_policy
origin_ssl_protocols = custom_origin_config.value.origin_ssl_protocols
}
}
dynamic "custom_header" {
for_each = origin.value.custom_headers
content {
name = custom_header.value.name
value = custom_header.value.value
}
}
}
}
# -------------------------------------------------------------------------
# Default behavior — matches anything not caught by an ordered behavior.
# -------------------------------------------------------------------------
default_cache_behavior {
target_origin_id = var.default_cache_behavior.target_origin_id
viewer_protocol_policy = var.default_cache_behavior.viewer_protocol_policy
allowed_methods = var.default_cache_behavior.allowed_methods
cached_methods = var.default_cache_behavior.cached_methods
compress = var.default_cache_behavior.compress
cache_policy_id = data.aws_cloudfront_cache_policy.default.id
origin_request_policy_id = try(data.aws_cloudfront_origin_request_policy.default[0].id, null)
response_headers_policy_id = var.default_cache_behavior.response_headers_policy_id
dynamic "function_association" {
for_each = var.default_cache_behavior.function_associations
content {
event_type = function_association.value.event_type
function_arn = function_association.value.function_arn
}
}
}
# -------------------------------------------------------------------------
# Ordered behaviors — first match wins, e.g. /api/* to the ALB origin.
# -------------------------------------------------------------------------
dynamic "ordered_cache_behavior" {
for_each = { for b in var.ordered_cache_behaviors : b.path_pattern => b }
content {
path_pattern = ordered_cache_behavior.value.path_pattern
target_origin_id = ordered_cache_behavior.value.target_origin_id
viewer_protocol_policy = ordered_cache_behavior.value.viewer_protocol_policy
allowed_methods = ordered_cache_behavior.value.allowed_methods
cached_methods = ordered_cache_behavior.value.cached_methods
compress = ordered_cache_behavior.value.compress
cache_policy_id = data.aws_cloudfront_cache_policy.ordered[ordered_cache_behavior.key].id
origin_request_policy_id = try(data.aws_cloudfront_origin_request_policy.ordered[ordered_cache_behavior.key].id, null)
response_headers_policy_id = ordered_cache_behavior.value.response_headers_policy_id
}
}
# SPA-friendly rewrites and friendly error pages.
dynamic "custom_error_response" {
for_each = { for e in var.custom_error_responses : tostring(e.error_code) => e }
content {
error_code = custom_error_response.value.error_code
response_code = custom_error_response.value.response_code
response_page_path = custom_error_response.value.response_page_path
error_caching_min_ttl = custom_error_response.value.error_caching_min_ttl
}
}
# -------------------------------------------------------------------------
# TLS — a custom-domain cert (ACM, us-east-1) OR the default *.cloudfront.net.
# -------------------------------------------------------------------------
viewer_certificate {
cloudfront_default_certificate = var.acm_certificate_arn == null
acm_certificate_arn = var.acm_certificate_arn
ssl_support_method = var.acm_certificate_arn != null ? "sni-only" : null
minimum_protocol_version = var.acm_certificate_arn != null ? var.minimum_protocol_version : "TLSv1"
}
restrictions {
geo_restriction {
restriction_type = var.geo_restriction.restriction_type
locations = var.geo_restriction.locations
}
}
dynamic "logging_config" {
for_each = var.logging_config != null ? [var.logging_config] : []
content {
bucket = logging_config.value.bucket
prefix = logging_config.value.prefix
include_cookies = logging_config.value.include_cookies
}
}
tags = local.tags
}
# variables.tf
variable "name" {
description = "Logical name for the distribution; used in the comment, OAC name, and Name tag."
type = string
validation {
condition = can(regex("^[a-zA-Z0-9._-]{1,128}$", var.name))
error_message = "name must be 1-128 chars of letters, digits, dots, hyphens or underscores."
}
}
variable "enabled" {
description = "Whether the distribution accepts end-user requests for content."
type = bool
default = true
}
variable "comment" {
description = "Free-text comment shown in the console (<= 128 chars)."
type = string
default = "Managed by Terraform"
validation {
condition = length(var.comment) <= 128
error_message = "comment must be 128 characters or fewer (AWS limit)."
}
}
variable "aliases" {
description = "Custom domain names (CNAMEs) served by this distribution, e.g. [\"cdn.example.com\"]. Each must be covered by the ACM certificate."
type = list(string)
default = []
}
variable "default_root_object" {
description = "Object returned for requests to the root URL, typically index.html for static sites."
type = string
default = null
}
variable "is_ipv6_enabled" {
description = "Serve content over IPv6 in addition to IPv4."
type = bool
default = true
}
variable "http_version" {
description = "Maximum HTTP version viewers can use: http1.1, http2, http2and3, or http3."
type = string
default = "http2and3"
validation {
condition = contains(["http1.1", "http2", "http2and3", "http3"], var.http_version)
error_message = "http_version must be one of: http1.1, http2, http2and3, http3."
}
}
variable "price_class" {
description = "Edge-location footprint: PriceClass_100 (NA/EU), PriceClass_200 (+Asia/ME/Africa), PriceClass_All (everywhere)."
type = string
default = "PriceClass_100"
validation {
condition = contains(["PriceClass_100", "PriceClass_200", "PriceClass_All"], var.price_class)
error_message = "price_class must be PriceClass_100, PriceClass_200, or PriceClass_All."
}
}
variable "web_acl_id" {
description = "ARN of an AWS WAFv2 web ACL (must be CLOUDFRONT scope, created in us-east-1). Null to skip WAF."
type = string
default = null
}
variable "origins" {
description = <<-EOT
Origins CloudFront fetches from. Set use_oac = true for a PRIVATE S3 bucket
(domain_name is the bucket's regional REST endpoint). For ALB/API Gateway/HTTP
origins, set custom_origin_config and leave use_oac = false.
EOT
type = list(object({
origin_id = string
domain_name = string
origin_path = optional(string)
use_oac = optional(bool, false)
connection_attempts = optional(number, 3)
connection_timeout = optional(number, 10)
custom_headers = optional(list(object({
name = string
value = string
})), [])
custom_origin_config = optional(object({
http_port = optional(number, 80)
https_port = optional(number, 443)
origin_protocol_policy = optional(string, "https-only")
origin_ssl_protocols = optional(list(string), ["TLSv1.2"])
}))
}))
validation {
condition = length(var.origins) > 0
error_message = "Provide at least one origin."
}
validation {
condition = length(distinct([for o in var.origins : o.origin_id])) == length(var.origins)
error_message = "Every origin must have a unique origin_id."
}
validation {
# OAC is for S3 only; an origin cannot be both OAC and a custom HTTP origin.
condition = alltrue([
for o in var.origins : !(o.use_oac && o.custom_origin_config != null)
])
error_message = "An origin cannot set both use_oac = true and custom_origin_config."
}
}
variable "default_cache_behavior" {
description = "Behavior for any request not matched by an ordered behavior. cache_policy_name references an AWS-managed cache policy."
type = object({
target_origin_id = string
cache_policy_name = optional(string, "Managed-CachingOptimized")
origin_request_policy_name = optional(string)
response_headers_policy_id = optional(string)
viewer_protocol_policy = optional(string, "redirect-to-https")
allowed_methods = optional(list(string), ["GET", "HEAD", "OPTIONS"])
cached_methods = optional(list(string), ["GET", "HEAD"])
compress = optional(bool, true)
function_associations = optional(list(object({
event_type = string
function_arn = string
})), [])
})
validation {
condition = contains(["allow-all", "https-only", "redirect-to-https"], var.default_cache_behavior.viewer_protocol_policy)
error_message = "viewer_protocol_policy must be allow-all, https-only, or redirect-to-https."
}
}
variable "ordered_cache_behaviors" {
description = "Path-pattern behaviors evaluated in order (e.g. /api/* to a dynamic origin with caching disabled)."
type = list(object({
path_pattern = string
target_origin_id = string
cache_policy_name = optional(string, "Managed-CachingDisabled")
origin_request_policy_name = optional(string)
response_headers_policy_id = optional(string)
viewer_protocol_policy = optional(string, "redirect-to-https")
allowed_methods = optional(list(string), ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"])
cached_methods = optional(list(string), ["GET", "HEAD"])
compress = optional(bool, true)
}))
default = []
validation {
condition = length(distinct([for b in var.ordered_cache_behaviors : b.path_pattern])) == length(var.ordered_cache_behaviors)
error_message = "Each ordered_cache_behaviors entry must have a unique path_pattern."
}
}
variable "custom_error_responses" {
description = "Custom error handling, e.g. map 403/404 to /index.html for SPAs with response_code 200."
type = list(object({
error_code = number
response_code = optional(number)
response_page_path = optional(string)
error_caching_min_ttl = optional(number, 10)
}))
default = []
}
variable "acm_certificate_arn" {
description = "ARN of an ACM certificate IN us-east-1 covering all aliases. Null uses the default *.cloudfront.net certificate."
type = string
default = null
validation {
condition = var.acm_certificate_arn == null || can(regex("^arn:aws:acm:us-east-1:[0-9]{12}:certificate/", var.acm_certificate_arn))
error_message = "acm_certificate_arn must be an ACM certificate ARN in us-east-1 (CloudFront requirement)."
}
}
variable "minimum_protocol_version" {
description = "Minimum TLS version for viewer connections when using a custom certificate."
type = string
default = "TLSv1.2_2021"
validation {
condition = contains(
["TLSv1.2_2018", "TLSv1.2_2019", "TLSv1.2_2021"],
var.minimum_protocol_version
)
error_message = "minimum_protocol_version must be TLSv1.2_2018, TLSv1.2_2019, or TLSv1.2_2021 (no TLS 1.0/1.1)."
}
}
variable "geo_restriction" {
description = "Geo restriction. type 'none', 'whitelist' (allow listed), or 'blacklist' (deny listed); locations are ISO 3166-1 alpha-2 codes."
type = object({
restriction_type = optional(string, "none")
locations = optional(list(string), [])
})
default = {}
validation {
condition = contains(["none", "whitelist", "blacklist"], var.geo_restriction.restriction_type)
error_message = "geo_restriction.restriction_type must be none, whitelist, or blacklist."
}
}
variable "logging_config" {
description = "Standard access logging to an S3 bucket (bucket must be the bucket's domain name, e.g. logs.s3.amazonaws.com). Null disables logging."
type = object({
bucket = string
prefix = optional(string, "")
include_cookies = optional(bool, false)
})
default = null
}
variable "retain_on_delete" {
description = "Disable rather than delete the distribution on terraform destroy (CloudFront deletes can be slow)."
type = bool
default = false
}
variable "wait_for_deployment" {
description = "Block terraform apply until the distribution finishes deploying to all edges (can take 5-15 min)."
type = bool
default = true
}
variable "tags" {
description = "Tags applied to the distribution."
type = map(string)
default = {}
}
# outputs.tf
output "id" {
description = "The distribution ID (e.g. E2QWRUHEXAMPLE); use for invalidations and WAF associations."
value = aws_cloudfront_distribution.this.id
}
output "arn" {
description = "The ARN of the distribution (reference in S3 bucket policies for OAC scoping)."
value = aws_cloudfront_distribution.this.arn
}
output "domain_name" {
description = "The CloudFront domain (e.g. d111111abcdef8.cloudfront.net); the alias target for Route 53."
value = aws_cloudfront_distribution.this.domain_name
}
output "hosted_zone_id" {
description = "CloudFront's fixed hosted zone ID for Route 53 alias records (Z2FDTNDATAQYW2)."
value = aws_cloudfront_distribution.this.hosted_zone_id
}
output "status" {
description = "Current distribution status (Deployed or InProgress)."
value = aws_cloudfront_distribution.this.status
}
output "etag" {
description = "Current version identifier of the distribution's configuration."
value = aws_cloudfront_distribution.this.etag
}
output "origin_access_control_id" {
description = "ID of the created Origin Access Control, or null when no origin uses OAC."
value = try(aws_cloudfront_origin_access_control.this[0].id, null)
}
How to use it
This example fronts a private S3 bucket (OAC, SPA error rewrites) and routes /api/* to an ALB origin with caching disabled, on a custom domain with an ACM certificate from us-east-1.
# CloudFront's ACM certificate MUST be in us-east-1, regardless of your app region.
provider "aws" {
alias = "us_east_1"
region = "us-east-1"
}
module "cloudfront" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-cloudfront?ref=v1.0.0"
name = "web-prod"
comment = "Production web app + API edge"
aliases = ["cdn.example.com"]
default_root_object = "index.html"
price_class = "PriceClass_200"
web_acl_id = aws_wafv2_web_acl.cloudfront.arn
origins = [
{
origin_id = "s3-static"
domain_name = aws_s3_bucket.site.bucket_regional_domain_name
use_oac = true
},
{
origin_id = "alb-api"
domain_name = aws_lb.api.dns_name
custom_origin_config = {
origin_protocol_policy = "https-only"
origin_ssl_protocols = ["TLSv1.2"]
}
},
]
default_cache_behavior = {
target_origin_id = "s3-static"
cache_policy_name = "Managed-CachingOptimized"
}
ordered_cache_behaviors = [
{
path_pattern = "/api/*"
target_origin_id = "alb-api"
cache_policy_name = "Managed-CachingDisabled"
origin_request_policy_name = "Managed-AllViewerExceptHostHeader"
},
]
# SPA: serve index.html (200) for client-side routes instead of S3's 403/404.
custom_error_responses = [
{ error_code = 403, response_code = 200, response_page_path = "/index.html" },
{ error_code = 404, response_code = 200, response_page_path = "/index.html" },
]
acm_certificate_arn = aws_acm_certificate.cdn.arn # validated, in us-east-1
logging_config = {
bucket = "${aws_s3_bucket.cdn_logs.bucket_domain_name}"
prefix = "web-prod/"
}
tags = {
Environment = "prod"
Team = "web-platform"
}
}
# Downstream: point the custom domain at the distribution via a Route 53 alias.
resource "aws_route53_record" "cdn" {
zone_id = var.public_zone_id
name = "cdn.example.com"
type = "A"
alias {
name = module.cloudfront.domain_name
zone_id = module.cloudfront.hosted_zone_id
evaluate_target_health = false
}
}
# Downstream: bucket policy that lets ONLY this distribution read the S3 origin.
data "aws_iam_policy_document" "s3_oac" {
statement {
actions = ["s3:GetObject"]
resources = ["${aws_s3_bucket.site.arn}/*"]
principals {
type = "Service"
identifiers = ["cloudfront.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "AWS:SourceArn"
values = [module.cloudfront.arn]
}
}
}
resource "aws_s3_bucket_policy" "site" {
bucket = aws_s3_bucket.site.id
policy = data.aws_iam_policy_document.s3_oac.json
}
With Terragrunt
Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.
1. Root config — live/terragrunt.hcl (inherited by every module):
remote_state {
backend = "s3"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...s3 state bucket/container + key per path...
}
}
2. Module config — live/prod/cloudfront/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-cloudfront?ref=v1.0.0"
}
inputs = {
name = "..."
origins = ["...", "..."]
default_cache_behavior = {}
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/cloudfront && terragrunt apply # this module
terragrunt run-all apply # every module under live/prod
Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.
Inputs
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
name |
string |
— | Yes | Logical name; used in comment, OAC name, and Name tag. |
enabled |
bool |
true |
No | Whether the distribution accepts viewer requests. |
comment |
string |
"Managed by Terraform" |
No | Console comment (≤128 chars). |
aliases |
list(string) |
[] |
No | Custom CNAMEs; each must be covered by the ACM cert. |
default_root_object |
string |
null |
No | Root object (e.g. index.html). |
is_ipv6_enabled |
bool |
true |
No | Serve over IPv6 as well as IPv4. |
http_version |
string |
"http2and3" |
No | Max HTTP version: http1.1/http2/http2and3/http3. |
price_class |
string |
"PriceClass_100" |
No | Edge footprint: _100/_200/_All. |
web_acl_id |
string |
null |
No | WAFv2 web ACL ARN (CLOUDFRONT scope, us-east-1). |
origins |
list(object) |
— | Yes | Origins to fetch from; use_oac for private S3, custom_origin_config for HTTP. |
default_cache_behavior |
object |
— | Yes | Catch-all behavior; cache_policy_name is an AWS-managed policy. |
ordered_cache_behaviors |
list(object) |
[] |
No | Path-pattern behaviors evaluated in order. |
custom_error_responses |
list(object) |
[] |
No | Error mappings (e.g. 403/404 → /index.html for SPAs). |
acm_certificate_arn |
string |
null |
No | ACM cert ARN in us-east-1; null = default cert. |
minimum_protocol_version |
string |
"TLSv1.2_2021" |
No | Minimum viewer TLS (custom cert only). |
geo_restriction |
object |
{} |
No | none/whitelist/blacklist + ISO-3166 alpha-2 codes. |
logging_config |
object |
null |
No | S3 standard access logging; null disables. |
retain_on_delete |
bool |
false |
No | Disable instead of delete on destroy. |
wait_for_deployment |
bool |
true |
No | Block apply until edges finish deploying. |
tags |
map(string) |
{} |
No | Tags applied to the distribution. |
Outputs
| Name | Description |
|---|---|
id |
Distribution ID; used for invalidations and WAF associations. |
arn |
Distribution ARN; reference in S3 bucket policies for OAC scoping. |
domain_name |
CloudFront domain (d….cloudfront.net); Route 53 alias target. |
hosted_zone_id |
CloudFront’s fixed hosted zone ID (Z2FDTNDATAQYW2) for alias records. |
status |
Current status (Deployed or InProgress). |
etag |
Current configuration version identifier. |
origin_access_control_id |
Created OAC ID, or null when no origin uses OAC. |
Enterprise scenario
A retail company serves its React storefront and checkout API to customers across India, the EU, and the Middle East. They instantiate this module once per environment: the static SPA lives in a private S3 bucket reachable only through OAC (the bucket has zero public access), /api/* is routed to a regional ALB with Managed-CachingDisabled, and a CLOUDFRONT-scope WAF web ACL with rate limiting and managed rule groups is attached via web_acl_id. They pick PriceClass_200 so Mumbai and Dubai edges are in play without paying for South America, enforce TLSv1.2_2021, ship access logs to a central S3 logging bucket, and front it all with cdn.shop.example.com via a Route 53 alias — giving security a private origin and an edge WAF, and finance a single tagged, right-sized distribution per environment.
Best practices
- Use OAC, never a public bucket. Set
use_oac = trueon S3 origins and scope the bucket policy to the distribution ARN with anAWS:SourceArncondition so only CloudFront can read it. OAC (SigV4) is the supported successor to Origin Access Identity — do not reach for OAI on new builds. - Drive caching with managed policies, not
forwarded_values. ReferenceManaged-CachingOptimizedfor static assets andManaged-CachingDisabled(plus an origin-request policy) for dynamic/api/*. The legacyforwarded_valuesblock is deprecated and silently over-forwards cookies/headers, wrecking your cache hit ratio and origin bill. - Pin TLS and redirect to HTTPS. Keep
minimum_protocol_version = "TLSv1.2_2021"andviewer_protocol_policy = "redirect-to-https"; remember the ACM certificate must live inus-east-1even when the rest of the stack is elsewhere — the module’s validation enforces that region. - Right-size
price_classfor cost.PriceClass_Allbills the most; if your audience is regional,PriceClass_100/_200cuts edge cost with no functional loss. Pair CloudFront with the right cache policy to maximise hit ratio — cache misses are origin egress you pay for twice. - Attach WAF and geo controls at the edge. Pass a CLOUDFRONT-scope
web_acl_idfor rate limiting / managed rules, and usegeo_restrictionto allow/deny countries before traffic reaches your origin. Blocking at the edge is cheaper and safer than at the application. - Name, tag, and log every distribution. Propagate
Environment/Teamtags for cost allocation, enablelogging_configto a central bucket for audit and cache-analysis, and preferretain_on_delete = truefor production so an accidentaldestroydisables rather than tears down a customer-facing edge.