Guide: Using Terraform to Deploy CloudFront Flat-Rate
07 Feb, 2026
Hosting a static site on AWS usually comes with a lingering fear: What if I get DDoS’d? What if my site goes viral while I’m asleep? The standard pay-as-you-go model scales infinite traffic into an infinite bill.
Since November 2025, AWS offers a new protection mechanism: the CloudFront Flat-Rate plan. It provides a hard cost ceiling ($0/month for the Free tier), bundling WAF, DDoS protection, and Route53 DNS. For workloads staying within the AWS ecosystem, it is the safest hosting option without financial risk.
However, this cost certainty comes with architectural tradeoffs. The Flat-Rate plan enforces specific constraints, requiring managed cache policies, mandating an auto-managed WAF, and removing certain configuration options. If you try to deploy a standard Terraform CloudFront module, it will likely fail.
This guide walks through building a Flat-Rate-compatible deployment—S3 bucket, OAC, CloudFront, ACM, and Route53.
If you just want the working code, skip to The Fast Way.
A domain name and access to your registrar (Namecheap, GoDaddy, etc.) to update nameservers
Architecture Overview
Here is the request flow we are building:
The pieces:
S3 bucket — stores the static files (HTML, CSS, JS). Fully private, with direct no public access.
Origin Access Control (OAC) — lets CloudFront access the private S3 bucket using IAM-based signing. Note: The flat-rate plan requires OAC; legacy OAI is not supported.
CloudFront distribution — serves content from edge locations worldwide.
ACM certificate — free TLS certificate for the domain. Must be in us-east-1 for CloudFront.
Route53 hosted zone — DNS for the domain.
Step 1: Enable the Flat-Rate Plan
The flat-rate plan is a billing configuration, not a Terraform resource. You enable it in the AWS Console:
If you don’t have it enabled, you should see a banner with the option to do so.
You can also find it under billing inside each distribution’s details.
You can have up to 3 free plan distributions per AWS account.
Step 2: Write the Terraform Code
Create a new directory for your project and set up the provider configuration.
Provider Configuration
CloudFront requires ACM certificates in us-east-1. If your infrastructure is in another region, you need two AWS providers:
# providers.tfprovider "aws" { region = "eu-central-1" # or your preferred region}provider "aws" { alias = "us_east_1" region = "us-east-1"}
Variables
# variables.tfvariable "domain_name" { description = "The domain name" type = string}variable "bucket_name" { description = "S3 bucket name for static content. Must be globally unique." type = string}
S3 Bucket
The bucket is fully private. We use BucketOwnerEnforced to disable legacy ACLs entirely, simplifying permissions.
# main.tfresource "aws_s3_bucket" "content" { bucket = var.bucket_name}# Follow Amazon's recommendation to keep ACLs disabled by applying the Bucket owner enforced setting and using bucket policy to share data with users outside the account as neededresource "aws_s3_bucket_ownership_controls" "content" { bucket = aws_s3_bucket.content.id rule { object_ownership = "BucketOwnerEnforced" }}# Block all public access at the bucket levelresource "aws_s3_bucket_public_access_block" "content" { bucket = aws_s3_bucket.content.id block_public_acls = true block_public_policy = true ignore_public_acls = true restrict_public_buckets = true}
Origin Access Control
OAC is how CloudFront authenticates to S3. It uses SigV4 signing, which is more secure than the legacy OAI approach and supports more S3 features.
The flat-rate plan strictly requires OAC. Legacy Origin Access Identity (OAI) is not supported.
S3 Bucket Policy
This policy allows only a specific CloudFront distribution to read objects. We use the AWS:SourceArn condition to lock access to this specific distribution, preventing other distributions from accessing it, thus enforcing the least privileges principle.
CloudFront requires a TLS certificate to serve HTTPS traffic. The certificate must be requested in the us-east-1 (N. Virginia) region, even if the S3 bucket and other resources are elsewhere. We use the aliased provider for this.
The create_before_destroy lifecycle rule is a best practice. If the certificate needs to be replaced (e.g., adding a subdomain), Terraform creates the new certificate before deleting the old one, preventing downtime.
Route53 Hosted Zone
A public hosted zone is required for the domain’s DNS records.
Option A: If DNS is managed by an external registrar
Note: This is the default option implemented here since it is the setup I use. If the domain is managed outside AWS (e.g., Namecheap, GoDaddy), use a resource block to create a new zone:
resource "aws_route53_zone" "main" { name = var.domain_name}
With Option A, a manual step is needed during the first terraform apply to switch the nameservers to the newly created Route53 zone. This is explained in detail in Step 6: Deploy and Verify.
Option B: If the DNS is managed by Route53
If a hosted zone already exists in the AWS account, use a data block to load it:
## IMPORTANT ## # This code is intentionally commented out to make sure that you don't accidentally copy it and break the code. # If you go with Option B, you need to use the line below instead of the code in Option A (ie. data instead of resource). # You also need to replace all usages of aws_route53_zone.main.zone_id with data.aws_route53_zone.main.zone_id throughout the code. # data "aws_route53_zone" "main" { name = var.domain_name }
DNS Validation
To prove ownership of the domain, ACM requires specific CNAME records. Terraform can read these requirements from the certificate resource and create the records in Route53 automatically.
resource "aws_route53_record" "acm_validation" { for_each = { for dvo in aws_acm_certificate.main.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" "main" { provider = aws.us_east_1 certificate_arn = aws_acm_certificate.main.arn validation_record_fqdns = [for record in aws_route53_record.acm_validation : record.fqdn]}
This configuration blocks Terraform from proceeding until the certificate is issued and valid. It may take a couple of minutes.
Step 4: The CloudFront Distribution
This is where the flat-rate constraints are applied. Some caching and security options are not compatible with flat-rate plans. They are noted by comments in the code.
resource "aws_cloudfront_distribution" "main" { enabled = true is_ipv6_enabled = true default_root_object = "index.html" aliases = [var.domain_name, "www.${var.domain_name}"] origin { domain_name = aws_s3_bucket.content.bucket_regional_domain_name origin_id = "S3-${var.bucket_name}" origin_access_control_id = aws_cloudfront_origin_access_control.main.id } default_cache_behavior { allowed_methods = ["GET", "HEAD", "OPTIONS"] cached_methods = ["GET", "HEAD"] target_origin_id = "S3-${var.bucket_name}" viewer_protocol_policy = "redirect-to-https" compress = true # Flat-rate Constraint: Must use AWS-managed cache policies. # Custom cache policies are not supported. cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6" # CachingOptimized } # Map S3 access-denied (403) and not-found (404) to a custom 404 page custom_error_response { error_code = 403 response_code = 404 response_page_path = "/404.html" } custom_error_response { error_code = 404 response_code = 404 response_page_path = "/404.html" } restrictions { geo_restriction { restriction_type = "none" } } viewer_certificate { acm_certificate_arn = aws_acm_certificate_validation.main.certificate_arn ssl_support_method = "sni-only" minimum_protocol_version = "TLSv1.2_2021" } # Flat-rate Constraint: The plan auto-attaches a WAF web ACL. # Terraform doesn't manage it, so changes to this ID must be ignored. lifecycle { ignore_changes = [web_acl_id] } depends_on = [aws_acm_certificate_validation.main]}
Notes
cache_policy_id: This is set to the AWS-managed CachingOptimized policy (658327ea-f89d-4fab-a63d-7e88639e58f6). The flat-rate plan does not support custom cache policies, and attempting to use a custom policy will result in an enrollment error.
No price_class: In standard deployments, PriceClass_100 might be used to limit costs. On the flat-rate plan, AWS controls edge location selection, so this argument is omitted.
lifecycle { ignore_changes = [web_acl_id] }: The flat-rate plan automatically attaches a managed WAF web ACL to the distribution. If this field is not ignored, Terraform will attempt to detach the WAF on every apply, creating a conflict.
Step 5: Route53 Alias Records
Finally, the domain must be pointed to the CloudFront distribution. We use Route53 Alias records, which are distinct from CNAMEs. Aliases allow pointing the apex domain directly to CloudFront and are resolved internally by AWS, generally resulting in faster lookups and zero query charges.
# Apex domain (A record for IPv4)resource "aws_route53_record" "apex" { zone_id = aws_route53_zone.main.zone_id name = var.domain_name type = "A" alias { name = aws_cloudfront_distribution.main.domain_name zone_id = aws_cloudfront_distribution.main.hosted_zone_id evaluate_target_health = false }}# Apex domain (AAAA record for IPv6)resource "aws_route53_record" "apex_ipv6" { zone_id = aws_route53_zone.main.zone_id name = var.domain_name type = "AAAA" alias { name = aws_cloudfront_distribution.main.domain_name zone_id = aws_cloudfront_distribution.main.hosted_zone_id evaluate_target_health = false }}# www subdomain (A record)resource "aws_route53_record" "www" { zone_id = aws_route53_zone.main.zone_id name = "www.${var.domain_name}" type = "A" alias { name = aws_cloudfront_distribution.main.domain_name zone_id = aws_cloudfront_distribution.main.hosted_zone_id evaluate_target_health = false }}# www subdomain (AAAA record)resource "aws_route53_record" "www_ipv6" { zone_id = aws_route53_zone.main.zone_id name = "www.${var.domain_name}" type = "AAAA" alias { name = aws_cloudfront_distribution.main.domain_name zone_id = aws_cloudfront_distribution.main.hosted_zone_id evaluate_target_health = false }}
Outputs
To simplify the deployment commands later, define these outputs in outputs.tf.
output "cloudfront_distribution_id" { value = aws_cloudfront_distribution.main.id}output "route53_name_servers" { value = aws_route53_zone.main.name_servers}output "bucket_name" { value = aws_s3_bucket.content.id}
Step 6: Deploy and Verify
With the code complete, the infrastructure can be deployed.
1. Update Nameservers
Note: If DNS is managed by an external registrar other than Route53 (ex: namecheap, godaddy, etc…), a manual step is needed to switch the nameservers to the new route53 zone before domain validation can finish. This is not needed if an existing route53_zone was imported in Step 3.
If DNS is with external registrar: Terraform will create the Zone and Certificate, then hang at the aws_acm_certificate_validation step. Do not cancel it.
Find the newly created Hosted Zone for the domain.
Copy the 4 values listed under the NS record.
Update Registrar: Log in to the domain registrar (e.g., Namecheap, GoDaddy) and replace the existing nameservers with these four values.
Finish: Once the DNS changes propagate (usually in a minute or so), ACM will validate the certificate, and Terraform will automatically resume and complete the deployment.
2. Upload Content
Once Terraform finishes successfully, the bucket is ready. Use AWS CLI to sync the static site files:
# Assumes the static site build is in the ./dist folderaws s3 sync ./dist s3://$(terraform output -raw bucket_name) --delete
3. Invalidate Cache
CloudFront caches content aggressively. To ensure the new files are served immediately, create an invalidation:
The site is now up and running. Once DNS has propagated, check:
HTTPS:https://example.com should serve the site’s home page.
www redirect: Visit https://www.example.com — should serve the same content.
Error pages: Visit https://example.com/nonexistent — should show the custom 404 page.
HTTP redirect: Visit http://example.com — should redirect to HTTPS.
The Fast Way: Using the Module
The code above is encapsulated in my terraform-aws-cloudfront-flat-rate module. Feel free to use it in your projects, or checkout the code and modify it for your needs.
Option 1: Static Site (S3 Origin)
The modules/static-site submodule handles the S3 bucket, OAC, policies, and distribution automatically.
The module abstracts away the flat-rate constraints—hardcoded cache policies, WAF lifecycle ignores, and price class omission—preventing common deployment errors.
Flat-Rate Tradeoffs
While the flat-rate plan eliminates billing uncertainty, it imposes architectural limitations. These restrictions apply to all flat-rate tiers.
Feature
Pay-As-You-Go
Flat-Rate Plan
Cost Model
Volume-based (Requests + Data Transfer)
Fixed monthly price ($0 for Free Tier)
DDoS Protection
Shield Standard (Basic)
Shield Standard + Auto-mitigation
WAF
Charged per rule/request
Included (Managed Rules)
Cache Policy
Fully customizable
Restricted (Managed policies only)
Edge Compute
Lambda@Edge & CloudFront Functions
CloudFront Functions only
Price Class
Selectable (All, 200, 100)
Managed (AWS selects edge locations)
Logs
Real-time & Standard logs
Standard logs only
The flat-rate plan is ideal for personal sites, documentation, portfolios, and predictable workloads. If the architecture requires Lambda@Edge for complex routing or custom caching logic based on headers, the standard pay-as-you-go model remains the necessary choice.
The CloudFront Flat-Rate plan effectively mitigates the primary risk of self-hosting on AWS: uncapped costs during traffic spikes. If the plan’s pre-ordained managed cache policies and auto-managed WAF fulfill your requirements, static site can be hosted with enterprise-grade performance, global distribution, and DDoS protection for zero (or fixed) monthly cost.
For those managing multiple sites or looking to simplify their codebase, the terraform-aws-cloudfront-flat-rate module abstracts these complexities.
The infrastructure is now ready to handle traffic—whether it’s a trickle of visitors or a viral spike—without the anxiety of an unexpected bill.
Stop rotating access keys. Learn how to let GitHub Actions assume IAM roles directly using OpenID Connect (OIDC) for a more secure, zero-secret deployment pipeline.