Most teams treat CloudFront as a CDN toggle they flip on before launch and never think about again. That is a mistake. CloudFront combined with AWS WAF is a full security and performance layer that sits in front of everything your users touch. When configured properly it absorbs DDoS traffic, blocks malicious requests before they reach your origin, caches aggressively to cut latency and origin costs, and gives you a global footprint without any infrastructure to manage.
In this article I will walk you through building a production-grade CloudFront distribution with WAF, real caching rules, custom error handling, and the operational patterns that actually hold up under traffic.
How CloudFront Actually Works
CloudFront is a content delivery network with over 450 points of presence globally. When a user makes a request, it is routed to the nearest edge location. If the edge has a cached copy of the response, it is returned immediately without touching your origin. If not, CloudFront fetches from your origin, caches the response according to your cache policy, and serves it.
The performance improvement comes from two places. First, the physical distance between the user and the edge is smaller than the distance to your origin, which reduces round-trip latency. Second, for cached responses, your origin receives a fraction of the total request volume, which reduces load and cost on your backend.
AWS WAF sits in front of CloudFront and inspects every request before it reaches the cache or your origin. It evaluates rules you define, rate limits, IP reputation lists, geographic blocks, SQL injection patterns, XSS patterns, and your own custom logic. Requests that match a block rule are dropped at the edge before they ever touch your infrastructure.
Setting Up CloudFront with Terraform
Everything in production belongs in code. Here is a complete CloudFront distribution for an application backend with S3 static assets:
resource "aws_cloudfront_distribution" "main" {
enabled = true
is_ipv6_enabled = true
http_version = "http2and3"
price_class = "PriceClass_All"
aliases = ["app.yourdomain.com"]
web_acl_id = aws_wafv2_web_acl.main.arn
comment = "Main application distribution"
origin {
domain_name = aws_lb.app.dns_name
origin_id = "alb-origin"
custom_origin_config {
http_port = 80
https_port = 443
origin_protocol_policy = "https-only"
origin_ssl_protocols = ["TLSv1.2"]
}
custom_header {
name = "X-Origin-Verify"
value = var.origin_verify_secret
}
}
origin {
domain_name = aws_s3_bucket.static_assets.bucket_regional_domain_name
origin_id = "s3-static"
origin_access_control_id = aws_cloudfront_origin_access_control.s3.id
}
default_cache_behavior {
target_origin_id = "alb-origin"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
cached_methods = ["GET", "HEAD"]
compress = true
cache_policy_id = aws_cloudfront_cache_policy.api.id
origin_request_policy_id = aws_cloudfront_origin_request_policy.api.id
}
ordered_cache_behavior {
path_pattern = "/static/*"
target_origin_id = "s3-static"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
compress = true
cache_policy_id = data.aws_cloudfront_cache_policy.caching_optimized.id
}
ordered_cache_behavior {
path_pattern = "/api/*"
target_origin_id = "alb-origin"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
cached_methods = ["GET", "HEAD"]
compress = true
cache_policy_id = data.aws_cloudfront_cache_policy.caching_disabled.id
origin_request_policy_id = aws_cloudfront_origin_request_policy.api.id
}
custom_error_response {
error_code = 404
response_code = 404
response_page_path = "/errors/404.html"
error_caching_min_ttl = 300
}
custom_error_response {
error_code = 500
response_code = 500
response_page_path = "/errors/500.html"
error_caching_min_ttl = 0
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
viewer_certificate {
acm_certificate_arn = aws_acm_certificate.main.arn
ssl_support_method = "sni-only"
minimum_protocol_version = "TLSv1.2_2021"
}
logging_config {
bucket = aws_s3_bucket.cloudfront_logs.bucket_domain_name
prefix = "cloudfront/"
include_cookies = false
}
}
A few decisions worth explaining here.
The X-Origin-Verify custom header is sent on every request from CloudFront to your ALB. Your ALB listener rule should check for this header and return a 403 for any request that does not include it. This ensures users cannot bypass CloudFront and WAF by hitting your ALB directly. The secret value should live in Secrets Manager and rotate periodically.
HTTP/3 is enabled via http_version = “http2and3”. HTTP/3 uses QUIC instead of TCP, which significantly improves performance for users on high-latency or lossy connections like mobile networks. CloudFront handles the QUIC negotiation automatically.
The /api/* path pattern uses the managed caching-disabled policy. API responses should never be cached at the CDN layer unless you have a very specific reason. Dynamic API responses cached at the edge will serve stale data to users, and the bugs this causes are subtle and hard to diagnose in production.
Error pages point to S3 static files. When your origin is down, CloudFront can still serve a branded error page from S3 instead of a raw HTTP error code. The 500 error TTL is 0, which means it is not cached and users will immediately see recovery when your origin comes back up.
Cache Policies That Actually Work
Cache policy design is where most CloudFront configurations go wrong. Teams either cache too aggressively and serve stale data, or not aggressively enough and defeat the purpose of having a CDN.
resource "aws_cloudfront_cache_policy" "api" {
name = "api-cache-policy"
default_ttl = 0
max_ttl = 0
min_ttl = 0
parameters_in_cache_key_and_forwarded_to_origin {
cookies_config {
cookie_behavior = "none"
}
headers_config {
header_behavior = "none"
}
query_strings_config {
query_string_behavior = "none"
}
enable_accept_encoding_gzip = true
enable_accept_encoding_brotli = true
}
}
resource "aws_cloudfront_cache_policy" "authenticated_pages" {
name = "authenticated-pages-policy"
default_ttl = 0
max_ttl = 0
min_ttl = 0
parameters_in_cache_key_and_forwarded_to_origin {
cookies_config {
cookie_behavior = "whitelist"
cookies {
items = ["session_id", "auth_token"]
}
}
headers_config {
header_behavior = "whitelist"
headers {
items = ["Authorization"]
}
}
query_strings_config {
query_string_behavior = "none"
}
enable_accept_encoding_gzip = true
enable_accept_encoding_brotli = true
}
}
resource "aws_cloudfront_cache_policy" "public_pages" {
name = "public-pages-policy"
default_ttl = 300
max_ttl = 3600
min_ttl = 60
parameters_in_cache_key_and_forwarded_to_origin {
cookies_config {
cookie_behavior = "none"
}
headers_config {
header_behavior = "none"
}
query_strings_config {
query_string_behavior = "whitelist"
query_strings {
items = ["page", "category", "lang"]
}
}
enable_accept_encoding_gzip = true
enable_accept_encoding_brotli = true
}
}
The authenticated_pages policy includes session cookies and the Authorization header in the cache key. This means each unique session gets its own cached response. In practice this means authenticated pages rarely get cache hits since every user has a different session. For truly personalized pages, bypass caching entirely. For pages that are identical for all authenticated users but different from unauthenticated ones, a simpler approach is to use a Lambda@Edge function to strip the session cookie from the cache key and add a generic “authenticated” flag instead.
The public_pages policy includes query strings like page and category in the cache key. This means /blog?page=2 and /blog?page=3 are cached separately, which is correct behavior. Any query strings not in the whitelist are stripped before being forwarded to the origin and not included in the cache key. If your origin ignores certain tracking parameters like utm_source, exclude them from the cache key to improve your hit rate.
WAF Configuration
AWS WAF v2 is where you define your security rules. A solid production configuration starts with the AWS managed rule groups and adds custom rules for your specific threats.
resource "aws_wafv2_web_acl" "main" {
name = "main-web-acl"
scope = "CLOUDFRONT"
provider = aws.us_east_1
default_action {
allow {}
}
rule {
name = "AWSManagedRulesCommonRuleSet"
priority = 10
override_action {
none {}
}
statement {
managed_rule_group_statement {
name = "AWSManagedRulesCommonRuleSet"
vendor_name = "AWS"
rule_action_override {
name = "SizeRestrictions_BODY"
action_to_use {
count {}
}
}
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "CommonRuleSet"
sampled_requests_enabled = true
}
}
rule {
name = "AWSManagedRulesKnownBadInputsRuleSet"
priority = 20
override_action {
none {}
}
statement {
managed_rule_group_statement {
name = "AWSManagedRulesKnownBadInputsRuleSet"
vendor_name = "AWS"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "KnownBadInputs"
sampled_requests_enabled = true
}
}
rule {
name = "AWSManagedRulesAmazonIpReputationList"
priority = 30
override_action {
none {}
}
statement {
managed_rule_group_statement {
name = "AWSManagedRulesAmazonIpReputationList"
vendor_name = "AWS"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "IPReputationList"
sampled_requests_enabled = true
}
}
rule {
name = "RateLimitPerIP"
priority = 40
action {
block {}
}
statement {
rate_based_statement {
limit = 2000
aggregate_key_type = "IP"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "RateLimitPerIP"
sampled_requests_enabled = true
}
}
rule {
name = "BlockSuspiciousUserAgents"
priority = 50
action {
block {}
}
statement {
or_statement {
statement {
byte_match_statement {
search_string = "sqlmap"
field_to_match {
single_header {
name = "user-agent"
}
}
text_transformation {
priority = 0
type = "LOWERCASE"
}
positional_constraint = "CONTAINS"
}
}
statement {
byte_match_statement {
search_string = "nikto"
field_to_match {
single_header {
name = "user-agent"
}
}
text_transformation {
priority = 0
type = "LOWERCASE"
}
positional_constraint = "CONTAINS"
}
}
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "SuspiciousUserAgents"
sampled_requests_enabled = true
}
}
rule {
name = "GeoBlockHighRiskCountries"
priority = 60
action {
count {}
}
statement {
geo_match_statement {
country_codes = var.blocked_country_codes
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "GeoBlock"
sampled_requests_enabled = true
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "MainWebACL"
sampled_requests_enabled = true
}
}
The SizeRestrictions_BODY rule from the Common Rule Set is overridden to count instead of block. This is intentional. If your application accepts large file uploads, the default body size restriction will block legitimate requests. Setting it to count lets you see how often it fires before deciding whether to block or adjust the threshold.
The geo-block rule is set to count rather than block. This is the right approach when you are first rolling out WAF. Count mode lets you see what would have been blocked without actually blocking it. Once you have reviewed the sampled requests and confirmed there is no legitimate traffic from those countries, switch the action to block.
The rate limit of 2000 requests per 5-minute window per IP is a starting point. The right number depends on your application. A single-page application where the browser makes 20 API calls on load needs a higher limit than a simple server-rendered website. Review your legitimate traffic patterns before setting a rate limit in production.
WAF for CloudFront must be deployed in us-east-1 regardless of where your application runs. This is an AWS requirement. The provider = aws.us_east_1 reference in the resource ensures the Web ACL is created in the correct region.
CloudFront Functions for Edge Logic
CloudFront Functions are lightweight JavaScript functions that run at the edge with sub-millisecond execution time. They are the right tool for request manipulation, redirects, and header injection that does not need full Lambda capability.
resource "aws_cloudfront_function" "security_headers" {
name = "security-headers"
runtime = "cloudfront-js-2.0"
publish = true
code = <<-EOF
function handler(event) {
var response = event.response;
var headers = response.headers;
headers['strict-transport-security'] = {
value: 'max-age=63072000; includeSubDomains; preload'
};
headers['x-content-type-options'] = {
value: 'nosniff'
};
headers['x-frame-options'] = {
value: 'DENY'
};
headers['x-xss-protection'] = {
value: '1; mode=block'
};
headers['referrer-policy'] = {
value: 'strict-origin-when-cross-origin'
};
headers['permissions-policy'] = {
value: 'camera=(), microphone=(), geolocation=()'
};
delete headers['x-powered-by'];
delete headers['server'];
return response;
}
EOF
}
resource "aws_cloudfront_function" "url_normalizer" {
name = "url-normalizer"
runtime = "cloudfront-js-2.0"
publish = true
code = <<-EOF
function handler(event) {
var request = event.request;
var uri = request.uri;
if (uri !== uri.toLowerCase()) {
return {
statusCode: 301,
statusDescription: 'Moved Permanently',
headers: {
location: { value: uri.toLowerCase() }
}
};
}
if (uri.endsWith('/') && uri !== '/') {
return {
statusCode: 301,
statusDescription: 'Moved Permanently',
headers: {
location: { value: uri.slice(0, -1) }
}
};
}
return request;
}
EOF
}
The security headers function runs on every response viewer event. Injecting these headers at the edge means they are present on every response regardless of whether it came from cache or your origin, and your application code does not need to set them. The function also strips the Server and X-Powered-By headers, which leak information about your backend technology stack.
The URL normalizer function solves a cache fragmentation problem. Without it, /Products and /products are treated as different cache keys, which doubles your miss rate and can cause duplicate content issues for SEO. Running this at the edge before the cache lookup ensures consistent cache keys without any origin involvement.
Observability: What to Watch
CloudFront publishes metrics to CloudWatch automatically. The ones that matter most in production are these.
CacheHitRate tells you what percentage of requests are being served from cache. For static assets this should be above 95 percent. For public pages it should be above 70 percent. If it drops significantly, check whether your cache policies changed, whether your origin is sending Cache-Control: no-cache headers, or whether a deployment invalidated more of your cache than expected.
5xxErrorRate tells you when CloudFront is receiving errors from your origin. A spike here usually means your origin is overloaded, deployed a broken version, or your ALB health checks are failing. Because CloudFront can serve cached responses even when the origin is down, a moderate 5xx rate may not immediately surface as user impact, but it will once the cache entries expire.
WAF BlockedRequests in CloudWatch tells you how many requests are being blocked by each rule. Set an alarm if this spikes sharply, since it could mean an active attack or a misconfigured rule blocking legitimate traffic.
resource "aws_cloudwatch_metric_alarm" "cache_hit_rate_low" {
alarm_name = "cloudfront-low-cache-hit-rate"
comparison_operator = "LessThanThreshold"
evaluation_periods = 3
metric_name = "CacheHitRate"
namespace = "AWS/CloudFront"
period = 300
statistic = "Average"
threshold = 70
alarm_description = "CloudFront cache hit rate dropped below 70%"
alarm_actions = [aws_sns_topic.alerts.arn]
dimensions = {
DistributionId = aws_cloudfront_distribution.main.id
Region = "Global"
}
}
resource "aws_cloudwatch_metric_alarm" "origin_5xx" {
alarm_name = "cloudfront-high-5xx-rate"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "5xxErrorRate"
namespace = "AWS/CloudFront"
period = 60
statistic = "Average"
threshold = 5
alarm_description = "CloudFront origin 5xx error rate exceeded 5%"
alarm_actions = [aws_sns_topic.alerts.arn]
dimensions = {
DistributionId = aws_cloudfront_distribution.main.id
Region = "Global"
}
}
Cache Invalidation
When you deploy new content, cached responses at edge locations need to be cleared. CloudFront gives you two mechanisms.
Cache invalidation sends a request to CloudFront to remove specific paths from all edge caches. It is immediate but costs money above the first 1000 invalidations per month, and it does not prevent the old content from being served during the propagation window, which can take a minute or two globally.
resource "null_resource" "cache_invalidation" {
triggers = {
deployment_version = var.deployment_version
}
provisioner "local-exec" {
command = <<-EOF
aws cloudfront create-invalidation \
--distribution-id ${aws_cloudfront_distribution.main.id} \
--paths "/index.html" "/static/js/*" "/static/css/*"
EOF
}
}
The better approach for static assets is versioned filenames. When your build tool generates /static/js/main.a1b2c3d4.js instead of /static/js/main.js, you never need to invalidate it. The old cached version stays valid because nothing references it anymore. The new version gets its own cache entry. Reserve invalidations for files that cannot be versioned, like index.html and robots.txt.
Closing Thoughts
CloudFront and WAF together give you a security and performance layer that would take significant engineering effort to build yourself. The key is configuration. A default CloudFront distribution with no cache policies and no WAF rules gives you a CDN with a public IP. A properly configured distribution with WAF, meaningful cache policies, security headers, and observability gives you a production-grade edge that absorbs attacks, accelerates your users, and costs less to operate than serving everything from origin.
The patterns in this article hold up at scale. Start with count mode on WAF rules, review the sampled requests, then move to block. Get your cache hit rate above 80 percent before optimizing anything else. Build your invalidation strategy around versioned assets so deployments are clean and cheap.
Enjoy the cloud.
Osama
#AWS #CloudFront #AWSWAF #CDN #CloudSecurity #WebPerformance #CloudArchitecture #Terraform #InfrastructureAsCode #CloudNative #AmazonWebServices #SolutionsArchitect #DevSecOps #EdgeComputing #CloudComputing #TechBlog #BackendEngineering #SRE #DDoSProtection #CloudInfrastructure