S3 data transfer is expensive. CloudFront caching is not. Here’s how to add CloudFront with Terraform and cut delivery costs by 90%.
Quick sanity check: How are your users downloading files from S3?
If the answer is “directly from S3”, you’re leaving serious money on the table.
Here’s why:
S3 direct data transfer out: $0.09/GB
CloudFront data transfer out: $0.0085/GB
Serving 1TB/month directly from S3: $92.16/month
Serving 1TB/month via CloudFront: $8.50/month
Annual savings: $1,044 (91% reduction!) 💰
And that’s before caching even kicks in. With caching, popular files get served from CloudFront edge locations — S3 barely gets touched at all.
💸 The Full Cost Breakdown
Serving a static website with assets (1TB/month traffic):
Without CloudFront
S3 requests: 1M GET × $0.0004/1K = $0.40
S3 data transfer: 1,024GB × $0.09 = $92.16
Total: $92.56/month
Annual: $1,110.72
With CloudFront (80% cache hit rate)
CloudFront data transfer: 1,024GB × $0.0085 = $8.70
Origin requests (20%): 200GB × $0.09 = $18
S3 requests: 200K × $0.0004/1K = $0.08
Total: $26.78/month
Annual: $321.36
Savings: $789/year (71% reduction!) 🎉
And you get:
- ⚡ Faster load times (edge locations worldwide)
- 🔒 Free HTTPS with ACM certificates
- 🛡️ DDoS protection (Shield Standard)
- 🌍 Global CDN with 400+ edge locations
🛠️ Terraform Implementation
Basic CloudFront + S3 Setup
# cloudfront-s3.tf
# S3 bucket for content
resource "aws_s3_bucket" "content" {
bucket = "my-app-content"
}
# Block all public access - CloudFront only!
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
}
# CloudFront Origin Access Control (OAC)
resource "aws_cloudfront_origin_access_control" "content" {
name = "s3-oac"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
# CloudFront Distribution
resource "aws_cloudfront_distribution" "cdn" {
enabled = true
default_root_object = "index.html"
price_class = "PriceClass_100" # US/EU only (cheapest)
origin {
domain_name = aws_s3_bucket.content.bucket_regional_domain_name
origin_id = "s3-origin"
origin_access_control_id = aws_cloudfront_origin_access_control.content.id
}
default_cache_behavior {
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "s3-origin"
viewer_protocol_policy = "redirect-to-https"
cache_policy_id = data.aws_cloudfront_cache_policy.managed.id
compress = true # Gzip/Brotli - reduces transfer costs!
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
viewer_certificate {
cloudfront_default_certificate = true # Use custom cert for production
}
tags = {
Name = "content-cdn"
}
}
# Use AWS Managed Cache Policy
data "aws_cloudfront_cache_policy" "managed" {
name = "Managed-CachingOptimized"
}
# S3 bucket policy - allow CloudFront only
resource "aws_s3_bucket_policy" "content" {
bucket = aws_s3_bucket.content.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
Service = "cloudfront.amazonaws.com"
}
Action = "s3:GetObject"
Resource = "${aws_s3_bucket.content.arn}/*"
Condition = {
StringEquals = {
"AWS:SourceArn" = aws_cloudfront_distribution.cdn.arn
}
}
}
]
})
}
output "cloudfront_url" {
value = "https://${aws_cloudfront_distribution.cdn.domain_name}"
}
output "cloudfront_id" {
value = aws_cloudfront_distribution.cdn.id
}
Production Setup with Custom Domain + HTTPS
# production-cloudfront.tf
# ACM Certificate (must be in us-east-1 for CloudFront!)
resource "aws_acm_certificate" "cdn" {
provider = aws.us_east_1 # REQUIRED for CloudFront
domain_name = "cdn.example.com"
validation_method = "DNS"
subject_alternative_names = [
"assets.example.com",
"static.example.com"
]
lifecycle {
create_before_destroy = true
}
}
resource "aws_cloudfront_distribution" "production" {
enabled = true
aliases = ["cdn.example.com"] # Custom domain
default_root_object = "index.html"
price_class = "PriceClass_All" # Global (best performance)
origin {
domain_name = aws_s3_bucket.content.bucket_regional_domain_name
origin_id = "s3-origin"
origin_access_control_id = aws_cloudfront_origin_access_control.content.id
# Optional: Add custom headers to identify origin
custom_header {
name = "X-Origin-Verify"
value = random_password.origin_secret.result
}
}
# Default: Cache everything aggressively
default_cache_behavior {
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "s3-origin"
viewer_protocol_policy = "redirect-to-https"
compress = true
cache_policy_id = data.aws_cloudfront_cache_policy.managed.id
# Add security headers
response_headers_policy_id = aws_cloudfront_response_headers_policy.security.id
}
# Different behavior for HTML - shorter TTL
ordered_cache_behavior {
path_pattern = "*.html"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "s3-origin"
viewer_protocol_policy = "redirect-to-https"
compress = true
cache_policy_id = aws_cloudfront_cache_policy.short_ttl.id
}
# No caching for API calls (if routing API through same distribution)
ordered_cache_behavior {
path_pattern = "/api/*"
allowed_methods = ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "api-origin"
viewer_protocol_policy = "redirect-to-https"
compress = true
cache_policy_id = data.aws_cloudfront_cache_policy.no_cache.id
origin_request_policy_id = data.aws_cloudfront_origin_request_policy.all.id
}
viewer_certificate {
acm_certificate_arn = aws_acm_certificate.cdn.arn
ssl_support_method = "sni-only"
minimum_protocol_version = "TLSv1.2_2021"
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
}
# Short TTL cache policy for HTML
resource "aws_cloudfront_cache_policy" "short_ttl" {
name = "html-short-ttl"
default_ttl = 60 # 1 minute
max_ttl = 300 # 5 minutes
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" }
}
}
# Security response headers
resource "aws_cloudfront_response_headers_policy" "security" {
name = "security-headers"
security_headers_config {
strict_transport_security {
access_control_max_age_sec = 31536000
include_subdomains = true
override = true
}
content_type_options {
override = true
}
xss_protection {
mode_block = true
protection = true
override = true
}
}
}
data "aws_cloudfront_cache_policy" "no_cache" {
name = "Managed-CachingDisabled"
}
data "aws_cloudfront_origin_request_policy" "all" {
name = "Managed-AllViewer"
}
resource "random_password" "origin_secret" {
length = 32
special = false
}
SPA (Single Page App) Setup
# For React/Vue/Angular apps with client-side routing
resource "aws_cloudfront_distribution" "spa" {
enabled = true
default_root_object = "index.html"
origin {
domain_name = aws_s3_bucket.spa.bucket_regional_domain_name
origin_id = "spa-origin"
origin_access_control_id = aws_cloudfront_origin_access_control.spa.id
}
default_cache_behavior {
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "spa-origin"
viewer_protocol_policy = "redirect-to-https"
compress = true
cache_policy_id = data.aws_cloudfront_cache_policy.managed.id
}
# Return index.html for all routes (client-side routing)
custom_error_response {
error_code = 403
response_code = 200
response_page_path = "/index.html"
error_caching_min_ttl = 0
}
custom_error_response {
error_code = 404
response_code = 200
response_page_path = "/index.html"
error_caching_min_ttl = 0
}
restrictions {
geo_restriction { restriction_type = "none" }
}
viewer_certificate {
cloudfront_default_certificate = true
}
}
💰 Price Class Comparison
Choose based on your user geography:
# Cheapest - US & Europe only
price_class = "PriceClass_100" # ~$0.0085/GB
# Middle - All regions except Asia/Pacific expensive
price_class = "PriceClass_200" # ~$0.012/GB avg
# All regions - Best performance globally
price_class = "PriceClass_All" # ~$0.020/GB avg
Rule of thumb:
- 90%+ US/EU users →
PriceClass_100(cheapest) - Global audience →
PriceClass_All(still 10x cheaper than raw S3 transfer)
💡 Pro Tips
1. Enable Compression
default_cache_behavior {
compress = true # Reduces transfer by 60-70%
}
Gzip/Brotli compression is free and saves huge on transfer costs.
2. Use Cache Invalidation Wisely
# After deploying new assets
aws cloudfront create-invalidation \
--distribution-id E1234567890 \
--paths "/index.html" "/static/main.*.js"
# Wildcard invalidation (use sparingly - $0.005 per path after first 1,000)
aws cloudfront create-invalidation \
--distribution-id E1234567890 \
--paths "/*"
3. Automate Cache Busting with Asset Hashes
# Better than manual invalidation
# Name files with content hash: main.a1b2c3.js
# Update HTML → old files expire naturally
4. Monitor Key Metrics
resource "aws_cloudwatch_dashboard" "cdn" {
dashboard_name = "cloudfront-performance"
dashboard_body = jsonencode({
widgets = [
{
type = "metric"
properties = {
metrics = [
["AWS/CloudFront", "CacheHitRate", { stat = "Average" }],
[".", "BytesDownloaded", { stat = "Sum" }],
[".", "4xxErrorRate", { stat = "Average" }]
]
period = 3600
title = "CloudFront Key Metrics"
}
}
]
})
}
📊 Real Savings by Traffic Level
| Monthly Traffic | S3 Direct | CloudFront (PriceClass_100) | Savings |
|---|---|---|---|
| 100GB | $9.22 | $2.28 | 75% |
| 500GB | $46.08 | $6.63 | 86% |
| 1TB | $92.16 | $8.70 | 91% |
| 5TB | $460.80 | $43.52 | 91% |
| 10TB | $921.60 | $87.04 | 91% |
The more traffic you have, the more you save. 📈
🚀 Quick Start
# 1. Deploy CloudFront distribution
terraform apply
# 2. Wait for deployment (~10 minutes)
# CloudFront distributions take time to propagate globally
# 3. Test CDN URL
curl -I https://abc123.cloudfront.net/index.html
# Check for cache hit headers:
# X-Cache: Hit from cloudfront ← Cached!
# X-Cache: Miss from cloudfront ← Origin fetch
# 4. Update your app to use CDN URL
# Old: https://bucket.s3.amazonaws.com/file.jpg
# New: https://cdn.example.com/file.jpg
# 5. Monitor cache hit rate in CloudWatch
⚠️ Gotchas
1. ACM Certificate Must Be in us-east-1
provider "aws" {
alias = "us_east_1"
region = "us-east-1"
}
resource "aws_acm_certificate" "cdn" {
provider = aws.us_east_1 # Always!
}
2. Changes Take 10-30 Minutes to Propagate
Plan your deployments accordingly. Invalidations also take a few minutes.
3. S3 Bucket Must Not Be Public
With OAC, your bucket stays private. CloudFront authenticates to S3 using IAM — much more secure than public buckets.
4. Cache Invalidation Costs Money After 1,000/Month
Use asset hashing (content-based filenames) instead of bulk invalidations.
🎯 Summary
CloudFront over S3 gives you:
- 91% lower data transfer costs
- Faster load times globally
- Free HTTPS
- DDoS protection
- Better security (private S3 bucket)
Implementation:
- ~50 lines of Terraform
- 15 minutes to deploy
- Propagates globally in 10-30 min
Cost:
- Pay per GB transferred (10x less than S3)
- No minimum fee
- Free tier: 1TB/month for 12 months
If you’re serving any significant traffic from S3, adding CloudFront is the single highest-ROI change you can make today. 🚀
Using CloudFront with S3? Share your cache hit rate in the comments! 💬
Follow for more AWS cost optimization with Terraform! ⚡
Top comments (0)