
Serving Files Directly from S3? You’re Paying 10x More Than You Should 😱
S3 data transfer is expensive. CloudFront caching is not. Here’s how to add CloudFront with Terraform and cut delivery costs by 90%.
Series: Save on AWS with Terraform (21 articles)
- …
- 13Serving Files Directly from S3? You’re Paying 10x More Than You Should 😱 This article
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:
1
2
3
4
5
6
7
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
1
2
3
4
5
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.72With CloudFront (80% cache hit rate)
1
2
3
4
5
6
7
8
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
# 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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
# 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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# 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:
1
2
3
4
5
6
7
8
# 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 avgRule 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
1
2
3
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
1
2
3
4
5
6
7
8
9
# 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
1
2
3
# Better than manual invalidation
# Name files with content hash: main.a1b2c3.js
# Update HTML → old files expire naturally4. Monitor Key Metrics
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 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
1
2
3
4
5
6
7
8
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! ⚡
Series: Save on AWS with Terraform (21 articles)
- …
- 13Serving Files Directly from S3? You’re Paying 10x More Than You Should 😱 This article
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article