DEV Community

Cover image for Serving Files Directly from S3? You’re Paying 10x More Than You Should 😱
Suhas Mallesh
Suhas Mallesh

Posted on • Edited on

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%.

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!) 💰
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!) 🎉
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

💰 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
Enter fullscreen mode Exit fullscreen mode

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%
}
Enter fullscreen mode Exit fullscreen mode

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 "/*"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
        }
      }
    ]
  })
}
Enter fullscreen mode Exit fullscreen mode

📊 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
Enter fullscreen mode Exit fullscreen mode

⚠️ 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!
}
Enter fullscreen mode Exit fullscreen mode

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)