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.

Prerequisites

Before we start, ensure you have:

  • An AWS Account
  • Terraform >= 1.5.0 installed locally
  • A domain name and access to your registrar (Namecheap, GoDaddy, etc.) to update nameservers

Architecture Overview

Here is the request flow we are building:

BrowserCloudFront(CDN + WAF)S3 Bucket(Private)Route53(DNS)ACM(Certificate)HTTPS RequestOAC Auth (SigV4)Alias RecordTLS Certificate

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:

  1. Go to CloudFront console
  2. If you don’t have it enabled, you should see a banner with the option to do so.
  3. 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.tf

provider "aws" {
  region = "eu-central-1" # or your preferred region
}

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

Variables

# variables.tf

variable "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.tf

resource "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 needed
resource "aws_s3_bucket_ownership_controls" "content" {
  bucket = aws_s3_bucket.content.id
  rule {
    object_ownership = "BucketOwnerEnforced"
  }
}

# Block all public access at the bucket level
resource "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.

resource "aws_cloudfront_origin_access_control" "main" {
  name                              = "${var.bucket_name}-oac"
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
}

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.

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

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid       = "AllowCloudFrontServicePrincipal"
        Effect    = "Allow"
        Principal = { Service = "cloudfront.amazonaws.com" }
        Action    = "s3:GetObject"
        Resource  = "${aws_s3_bucket.content.arn}/*"
        Condition = {
          StringEquals = {
            "AWS:SourceArn" = aws_cloudfront_distribution.main.arn
          }
        }
      }
    ]
  })
}

Step 3: ACM Certificate and Route53

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.

resource "aws_acm_certificate" "main" {
  provider                  = aws.us_east_1
  domain_name               = var.domain_name
  subject_alternative_names = ["www.${var.domain_name}"]
  validation_method         = "DNS"

  lifecycle {
    create_before_destroy = true
  }
}

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

  1. 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.
  2. 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.
  3. 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.

  1. Run Apply: Initialize and start the apply.
    terraform init
    terraform apply -var="domain_name=example.com" -var="bucket_name=example-com-content"
  2. 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.
    1. Get Nameservers: While Terraform is waiting:
      • Open the AWS Route53 Console.
      • Find the newly created Hosted Zone for the domain.
      • Copy the 4 values listed under the NS record.
    2. Update Registrar: Log in to the domain registrar (e.g., Namecheap, GoDaddy) and replace the existing nameservers with these four values.
  3. 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 folder
aws 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:

aws cloudfront create-invalidation \
  --distribution-id $(terraform output -raw cloudfront_distribution_id) \
  --paths "/*"

Step 7: Verify

The site is now up and running. Once DNS has propagated, check:

  1. HTTPS: https://example.com should serve the site’s home page.
  2. www redirect: Visit https://www.example.com — should serve the same content.
  3. Error pages: Visit https://example.com/nonexistent — should show the custom 404 page.
  4. 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.

module "static_site" {
  source = "aemarzouk/cloudfront-flat-rate/aws//modules/static-site"

  domain_name = "example.com"

  providers = {
    aws           = aws
    aws.us_east_1 = aws.us_east_1
  }
}

Option 2: Custom Origin (API Gateway, ALB)

The root module supports custom origins for dynamic workloads.

module "api_cdn" {
  source = "aemarzouk/cloudfront-flat-rate/aws"

  domain_name         = "api.example.com"
  default_root_object = ""
  enable_www_redirect = false

  origin = {
    domain_name = "abc123.execute-api.eu-central-1.amazonaws.com"
    origin_id   = "api-gateway"
    type        = "custom"
    custom_origin_config = {
      origin_protocol_policy = "https-only"
    }
  }

  error_responses = {}

  providers = {
    aws           = aws
    aws.us_east_1 = aws.us_east_1
  }
}

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.

FeaturePay-As-You-GoFlat-Rate Plan
Cost ModelVolume-based (Requests + Data Transfer)Fixed monthly price ($0 for Free Tier)
DDoS ProtectionShield Standard (Basic)Shield Standard + Auto-mitigation
WAFCharged per rule/requestIncluded (Managed Rules)
Cache PolicyFully customizableRestricted (Managed policies only)
Edge ComputeLambda@Edge & CloudFront FunctionsCloudFront Functions only
Price ClassSelectable (All, 200, 100)Managed (AWS selects edge locations)
LogsReal-time & Standard logsStandard 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.

For a deeper dive into the pricing analysis and decision framework, see CloudFront Flat-Rate Plans: Zero-Cost Hosting Without the Tradeoff.

Conclusion

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.

Related Posts

Keyless AWS Deployments from GitHub Actions with OIDC

  • 10 Feb, 2026

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.

Keyless AWS Deployments from GitHub Actions with OIDC Read More

Ask me about Ahmed's career, projects & publications!