The Complete Guide to Building Enterprise Static Sites with Terraform: Auto Cache Invalidation, Cross-Account Logging & More


Co-authored with AI
TL;DR
๐ฏ What: Complete enterprise-grade Terraform module for AWS static sites that solves real-world production challenges other modules ignore.
๐ Key Problems Solved:
โ Auto cache invalidation - No more manual CloudFront clearing
โ Cross-account logging - Enterprise compliance & centralized logs
โ Wildcard domains - Perfect for PR previews (
pr123.dev.example.com
)โ Enterprise security - OAC, IAM least-privilege, TLS 1.2+
โก Time Savings: 5 minutes setup vs 2-3 hours manual configuration
๐ฆ Get Started:
source = "thu-san/static-site/aws"
enable_cache_invalidation = true
๐ Registries: Terraform | OpenTofu | GitHub
๐ This Guide Covers: Basic setup โ Enterprise features โ PR preview deployments โ Architecture deep-dive โ Troubleshooting
Introduction
Static website hosting on AWS is deceptively simple on the surface - create an S3 bucket, add a CloudFront distribution, configure some DNS records, and you're done. But in real-world enterprise environments, you quickly run into challenges that turn this "simple" setup into a complex, maintenance-heavy infrastructure.
After managing dozens of static site deployments across different organizations, I identified recurring pain points that existing Terraform modules and manual setups consistently failed to address. This led me to create a comprehensive Terraform module that solves these enterprise challenges with built-in automation and intelligent defaults.
In this comprehensive guide, I'll walk you through everything you need to know about deploying enterprise-grade static sites on AWS using this advanced Terraform module, including real-world use cases, architecture decisions, and step-by-step implementation examples.
The Problem with Current Solutions
Manual Cache Management is Broken
Most AWS static site setups require manual CloudFront cache invalidation after content updates. This creates several problems:
Stale Content: Users see outdated versions until caches expire
Manual Overhead: DevOps teams waste time on repetitive invalidation tasks
CI/CD Complexity: Build pipelines need additional invalidation logic
Cost Inefficiency: Blanket
/*
invalidations are expensive and unnecessary
Enterprise Requirements are Afterthoughts
Standard tutorials and modules often ignore enterprise needs:
Cross-Account Logging: Security teams need centralized log aggregation
Compliance: Audit trails across multiple AWS accounts
Wildcard Domains: PR preview environments and multi-tenant architectures
Security: Proper IAM boundaries and least-privilege access
Maintenance Burden
Static sites shouldn't require constant attention, but many setups do:
Certificate renewals and domain validation issues
CloudFront behavior rule conflicts
Security policy updates across environments
Scaling invalidation logic as content grows
The Solution: Enterprise-Ready Terraform Module
I've built a Terraform module that addresses these challenges with intelligent automation and enterprise-grade features. Here's what makes it different:
๐ Built-in Automatic Cache Invalidation
Unlike other modules that require separate tools or manual processes, this module includes a complete Lambda-based invalidation system that responds to S3 events in real-time.
How it works:
S3 bucket events trigger SQS messages when files are uploaded
Lambda function processes events in batches for cost efficiency
Intelligent path mapping determines which CloudFront paths to invalidate
Dead letter queue handles any failed invalidations for debugging
๐ Native Cross-Account CloudFront Logging
Enterprise environments often require centralized logging across AWS accounts. This module supports cross-account CloudWatch log delivery out of the box, enabling:
Security team oversight across multiple development accounts
Centralized compliance and audit trails
Cost optimization through shared logging infrastructure
Simplified access control through dedicated logging accounts
๐ Advanced Domain Management
Full wildcard domain support with automatic certificate management makes this module perfect for:
PR Preview Deployments:
pr123.dev.example.com
,pr456.dev.example.com
Multi-tenant Applications:
client1.app.example.com
,client2.app.example.com
Environment Isolation:
staging.example.com
,prod.example.com
๐ก๏ธ Security-First Architecture
Every component follows security best practices:
Private S3 buckets with CloudFront Origin Access Control (OAC)
Minimum TLS 1.2 enforcement
IAM roles with least-privilege access
All public access blocked on S3 buckets
Complete Implementation Guide
Basic Setup: Getting Started in 5 Minutes
Let's start with the simplest possible configuration and build up to enterprise features.
Step 1: Provider Configuration
The module requires two AWS providers - one for your primary region and one for us-east-1
(required for CloudFront certificates):
# Configure primary provider (your preferred region)
provider "aws" {
region = "eu-west-1" # or your preferred region
}
# Configure us-east-1 provider (required for CloudFront)
provider "aws" {
alias = "us_east_1"
region = "us-east-1"
}
Step 2: Basic Module Configuration
module "static_site" {
source = "thu-san/static-site/aws"
version = "~> 1.2"
# Required parameters
s3_bucket_name = "my-company-website-bucket"
cloudfront_distribution_name = "my-company-website"
# Basic tagging
tags = {
Environment = "production"
Project = "company-website"
Owner = "platform-team"
}
providers = {
aws = aws
aws.us_east_1 = aws.us_east_1
}
}
Step 3: Deploy and Test
# Initialize and apply
terraform init
terraform plan
terraform apply
# Upload test content
aws s3 cp ./website/ s3://my-company-website-bucket/ --recursive
# Access your site
echo "Your site is available at: $(terraform output cloudfront_distribution_domain_name)"
Result: You now have a production-ready static site with:
Private S3 bucket with versioning
CloudFront distribution with optimal caching
HTTPS enforcement and security headers
Automatic compression and HTTP/2
Intermediate Setup: Custom Domain with Automatic DNS
Adding a custom domain with automated certificate management and DNS configuration:
module "static_site" {
source = "thu-san/static-site/aws"
version = "~> 1.2"
s3_bucket_name = "my-company-website-bucket"
cloudfront_distribution_name = "my-company-website"
# Custom domain configuration
domain_names = ["example.com", "www.example.com"]
hosted_zone_name = "example.com" # Your Route53 hosted zone
tags = {
Environment = "production"
Project = "company-website"
}
providers = {
aws = aws
aws.us_east_1 = aws.us_east_1
}
}
What happens automatically:
ACM certificate created in
us-east-1
for both domainsDNS validation records added to Route53
Certificate validation completed automatically
A and AAAA records created pointing to CloudFront
CloudFront configured with custom domain and certificate
Advanced Setup: Enterprise Features
Now let's implement the enterprise features that set this module apart:
Auto Cache Invalidation
module "static_site" {
source = "thu-san/static-site/aws"
version = "~> 1.2"
s3_bucket_name = "my-company-website-bucket"
cloudfront_distribution_name = "my-company-website"
domain_names = ["example.com", "www.example.com"]
hosted_zone_name = "example.com"
# Enable automatic cache invalidation
enable_cache_invalidation = true
invalidation_mode = "custom"
# Smart invalidation patterns
invalidation_path_mappings = [
{
source_pattern = "^assets/images/.*"
invalidation_paths = ["/assets/images/*"]
description = "Invalidate image cache on any image upload"
},
{
source_pattern = "^(index\\.html|about\\.html)$"
invalidation_paths = ["/*"]
description = "Full cache clear on main page changes"
},
{
source_pattern = "^blog/.*\\.html$"
invalidation_paths = ["/blog/*"]
description = "Invalidate blog section on blog updates"
}
]
# Optional: Customize Lambda and SQS settings
invalidation_lambda_config = {
memory_size = 256 # Increase for large sites
timeout = 600 # 10 minutes for complex invalidations
log_retention_days = 14 # Keep logs longer for debugging
}
invalidation_sqs_config = {
batch_size = 50 # Process in smaller batches
batch_window_seconds = 30 # Faster processing
}
providers = {
aws = aws
aws.us_east_1 = aws.us_east_1
}
}
Cross-Account Logging
For enterprise environments with centralized logging:
# Assuming you have a centralized logging account
module "static_site" {
source = "thu-san/static-site/aws"
version = "~> 1.2"
s3_bucket_name = "my-company-website-bucket"
cloudfront_distribution_name = "my-company-website"
domain_names = ["example.com"]
hosted_zone_name = "example.com"
# Cross-account logging configuration
log_delivery_destination_arn = "arn:aws:logs:us-east-1:LOGGING-ACCOUNT-ID:delivery-destination:central-cloudfront-logs"
# Custom log record fields for enhanced monitoring
log_record_fields = [
"timestamp",
"c-ip",
"sc-status",
"cs-method",
"cs-uri-stem",
"cs-uri-query",
"cs-referer",
"cs-user-agent",
"edge-location",
"time-taken"
]
# Custom S3 delivery path for organized logs
s3_delivery_configuration = [
{
suffix_path = "/cloudfront/{DistributionId}/{yyyy}/{MM}/{dd}/{HH}"
enable_hive_compatible_path = true # Better for analytics tools
}
]
providers = {
aws = aws
aws.us_east_1 = aws.us_east_1
}
}
Real-World Use Case: PR Preview Deployments
One of the most powerful applications of this module is creating automated PR preview environments. Here's a complete implementation:
Architecture Overview
Main site:
example.com
serves from S3 rootPR previews:
pr123.dev.example.com
serves from/pr123/
folderAutomatic routing: CloudFront function handles subdomain-to-folder mapping
Wildcard SSL: Single certificate covers all PR subdomains
Complete Implementation
# CloudFront function for intelligent PR routing
resource "aws_cloudfront_function" "pr_router" {
name = "pr-preview-router"
runtime = "cloudfront-js-2.0"
comment = "Routes PR preview requests to appropriate S3 folders"
publish = true
code = <<-EOT
function handler(event) {
var request = event.request;
var host = request.headers.host.value;
// Extract PR number from subdomain (e.g., pr123.dev.example.com)
var prMatch = host.match(/^pr(\d+)\./);
if (prMatch) {
var prNumber = prMatch[1];
// Prepend PR folder to the URI
request.uri = '/pr' + prNumber + request.uri;
}
// Handle directory requests by appending index.html
if (request.uri.endsWith('/')) {
request.uri += 'index.html';
}
// Handle missing file extensions for SPA routing
if (!request.uri.includes('.') && !request.uri.endsWith('/')) {
request.uri += '/index.html';
}
return request;
}
EOT
}
module "pr_preview_site" {
source = "thu-san/static-site/aws"
version = "~> 1.2"
s3_bucket_name = "my-company-pr-previews"
cloudfront_distribution_name = "pr-preview-distribution"
# Wildcard domain configuration
domain_names = [
"dev.example.com", # Main development site
"*.dev.example.com" # Wildcard for PR previews
]
hosted_zone_name = "example.com"
# Attach the PR routing function
cloudfront_function_associations = [{
event_type = "viewer-request"
function_arn = aws_cloudfront_function.pr_router.arn
}]
# Enable auto-invalidation for rapid PR updates
enable_cache_invalidation = true
invalidation_mode = "direct" # Simple 1:1 path mapping
# Subfolder support for better SPA handling
subfolder_root_object = "index.html"
default_root_object = "index.html"
tags = {
Environment = "development"
Project = "pr-previews"
Purpose = "automated-testing"
}
providers = {
aws = aws
aws.us_east_1 = aws.us_east_1
}
}
CI/CD Integration
Here's how you'd integrate this with your CI/CD pipeline:
# GitHub Actions example
name: Deploy PR Preview
on:
pull_request:
types: [opened, synchronize]
jobs:
deploy-preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Build site
run: npm run build
- name: Deploy to S3
run: |
PR_NUMBER=${{ github.event.pull_request.number }}
aws s3 sync ./dist/ s3://my-company-pr-previews/pr${PR_NUMBER}/ --delete
- name: Comment PR with preview URL
uses: actions/github-script@v6
with:
script: |
const prNumber = context.payload.pull_request.number;
const previewUrl = `https://pr${prNumber}.dev.example.com`;
github.rest.issues.createComment({
issue_number: prNumber,
owner: context.repo.owner,
repo: context.repo.repo,
body: `๐ Preview deployed: ${previewUrl}`
});
Result: Every PR automatically gets its own preview URL with instant cache invalidation and HTTPS.
Architecture Deep Dive
Security Design Decisions
Why Origin Access Control (OAC) over Origin Access Identity (OAI)?
OAC is AWS's newer, more secure method for CloudFront-to-S3 communication:
Better security: Uses short-term tokens instead of long-lived credentials
AWS Signature V4: More robust authentication
Future-proof: OAI is being phased out by AWS
IAM Role Design
The module creates minimal IAM roles with specific purposes:
# Cache invalidation Lambda role (created automatically)
data "aws_iam_policy_document" "lambda_assume_role" {
statement {
effect = "Allow"
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
actions = ["sts:AssumeRole"]
}
}
# Minimal permissions for invalidation
data "aws_iam_policy_document" "lambda_permissions" {
statement {
effect = "Allow"
actions = [
"cloudfront:CreateInvalidation",
"cloudfront:GetInvalidation"
]
resources = [aws_cloudfront_distribution.main.arn]
}
statement {
effect = "Allow"
actions = [
"sqs:ReceiveMessage",
"sqs:DeleteMessage",
"sqs:GetQueueAttributes"
]
resources = [aws_sqs_queue.invalidation.arn]
}
}
Cost Optimization Strategies
Intelligent Invalidation Batching
The Lambda function implements several cost-saving features:
Path Deduplication: Removes duplicate invalidation paths
Wildcard Optimization: Converts multiple similar paths to wildcards
Batch Processing: Groups invalidations to minimize API calls
# Example of intelligent path optimization (in Lambda)
def optimize_invalidation_paths(paths):
"""Optimize paths to minimize CloudFront invalidation costs"""
# Remove duplicates
unique_paths = list(set(paths))
# Group by directory for wildcard opportunities
directories = {}
for path in unique_paths:
dir_path = '/'.join(path.split('/')[:-1]) + '/'
if dir_path not in directories:
directories[dir_path] = []
directories[dir_path].append(path)
optimized = []
for dir_path, dir_paths in directories.items():
if len(dir_paths) > 3: # Use wildcard if >3 files in directory
optimized.append(dir_path + '*')
else:
optimized.extend(dir_paths)
return optimized[:1000] # CloudFront max 1000 paths per invalidation
SQS Batch Processing
SQS batching reduces Lambda invocations and costs:
Batch Window: Collect events for 60 seconds before processing
Batch Size: Process up to 100 events per Lambda invocation
Dead Letter Queue: Handle failures without losing events
Monitoring and Observability
CloudWatch Metrics
The module automatically creates useful CloudWatch dashboards and alarms:
# Key metrics to monitor (created automatically)
resource "aws_cloudwatch_metric_alarm" "cache_hit_rate" {
alarm_name = "${var.cloudfront_distribution_name}-cache-hit-rate"
comparison_operator = "LessThanThreshold"
evaluation_periods = "2"
metric_name = "CacheHitRate"
namespace = "AWS/CloudFront"
period = "300"
statistic = "Average"
threshold = "80"
alarm_description = "This metric monitors CloudFront cache hit rate"
dimensions = {
DistributionId = aws_cloudfront_distribution.main.id
}
}
Lambda Function Monitoring
Built-in monitoring for the invalidation system:
Execution Duration: Track Lambda performance
Error Rate: Monitor failed invalidations
DLQ Messages: Alert on systematic failures
Cost Tracking: Monitor invalidation API costs
Troubleshooting Common Issues
Certificate Validation Failures
Problem: ACM certificate stuck in "Pending Validation"
Solution: Ensure your Route53 hosted zone is properly configured:
# Verify hosted zone configuration
data "aws_route53_zone" "main" {
name = var.hosted_zone_name
}
# Check if hosted zone exists
output "hosted_zone_id" {
value = data.aws_route53_zone.main.zone_id
}
Cache Invalidation Not Working
Problem: Files uploaded to S3 don't trigger invalidations
Debugging steps:
Check SQS Queue: Verify messages are being sent
Lambda Logs: Check CloudWatch logs for errors
IAM Permissions: Ensure Lambda can access CloudFront
S3 Event Notifications: Verify bucket events are configured
# Debug SQS queue
aws sqs get-queue-attributes \
--queue-url $(terraform output sqs_queue_url) \
--attribute-names All
# Check Lambda logs
aws logs describe-log-streams \
--log-group-name $(terraform output lambda_log_group_name)
Cross-Account Logging Issues
Problem: Logs not appearing in destination account
Common causes:
Incorrect destination ARN
Missing permissions in destination account
Log delivery destination not properly configured
Verification:
# Test log delivery destination
aws logs describe-destinations \
--destination-name-prefix central-cloudfront-logs
Performance Optimization
CloudFront Configuration
The module uses optimized CloudFront settings:
# Optimal caching behaviors (configured automatically)
cache_behavior {
# Static assets - long cache
path_pattern = "/assets/*"
viewer_protocol_policy = "redirect-to-https"
cache_policy_id = data.aws_cloudfront_cache_policy.caching_optimized.id
compress = true
}
cache_behavior {
# HTML files - short cache for faster updates
path_pattern = "*.html"
viewer_protocol_policy = "redirect-to-https"
cache_policy_id = data.aws_cloudfront_cache_policy.caching_disabled.id
compress = true
}
S3 Optimization
Transfer Acceleration: Enabled for faster uploads
Versioning: Enabled for rollback capability
Lifecycle Policies: Optional for cost management
Migration from Existing Setups
From Manual AWS Setup
If you have an existing manual S3 + CloudFront setup:
Inventory current resources:
# List existing S3 buckets aws s3 ls # List CloudFront distributions aws cloudfront list-distributions
Import existing resources (optional):
# Import S3 bucket terraform import module.static_site.aws_s3_bucket.main your-existing-bucket # Import CloudFront distribution terraform import module.static_site.aws_cloudfront_distribution.main DISTRIBUTION_ID
Plan migration:
terraform plan
From Other Terraform Modules
Migration strategy depends on the existing module, but generally:
Parallel deployment: Deploy new module alongside existing
DNS cutover: Update Route53 records to point to new distribution
Cleanup: Remove old resources after verification
Advanced Customization
Custom CloudFront Functions
You can extend the module with custom CloudFront functions:
resource "aws_cloudfront_function" "security_headers" {
name = "security-headers"
runtime = "cloudfront-js-2.0"
publish = true
code = <<-EOT
function handler(event) {
var response = event.response;
var headers = response.headers;
// Add security headers
headers['strict-transport-security'] = {
value: 'max-age=31536000; includeSubdomains; preload'
};
headers['x-content-type-options'] = { value: 'nosniff' };
headers['x-frame-options'] = { value: 'DENY' };
headers['referrer-policy'] = { value: 'strict-origin-when-cross-origin' };
return response;
}
EOT
}
module "static_site" {
source = "thu-san/static-site/aws"
# ... other configuration ...
# Attach custom function
cloudfront_function_associations = [
{
event_type = "viewer-response"
function_arn = aws_cloudfront_function.security_headers.arn
}
]
}
Environment-Specific Configurations
Use Terraform workspaces or separate variable files:
# terraform.tfvars.prod
s3_bucket_name = "mycompany-website-prod"
domain_names = ["example.com", "www.example.com"]
enable_cache_invalidation = true
# terraform.tfvars.staging
s3_bucket_name = "mycompany-website-staging"
domain_names = ["staging.example.com"]
enable_cache_invalidation = false # Save costs in staging
What's Next?
Planned Features
I'm actively developing additional features:
Multi-region deployments: Global content replication
Advanced analytics: Custom CloudWatch dashboards
Blue-green deployments: Zero-downtime content updates
Content optimization: Automatic image compression and WebP conversion
Contributing
The module is open source and welcomes contributions:
GitHub Repository: terraform-aws-static-site
Terraform Registry: thu-san/static-site/aws
OpenTofu Registry: thu-san/static-site/aws
Getting Help
Issues: Report bugs or request features on GitHub
Discussions: Join the community discussion for questions
Documentation: Comprehensive examples in the repository
Conclusion
Building enterprise-grade static sites on AWS doesn't have to be complex or maintenance-heavy. This Terraform module encapsulates years of best practices and lessons learned from production deployments.
The key differentiators - automatic cache invalidation, cross-account logging, and wildcard domain support - solve real-world problems that development teams face every day. Whether you're deploying a simple marketing site or a complex multi-tenant application with PR previews, this module provides the enterprise features you need with the simplicity you want.
Try it out in your next project and let me know how it works for you. I'm always looking for feedback and real-world use cases to continue improving the module.
Have you implemented similar enterprise features in your static site deployments? What challenges did you face? Share your experience in the comments below!
Subscribe to my newsletter
Read articles from Thu San directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
