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

Thu SanThu San
13 min read

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:

  1. S3 bucket events trigger SQS messages when files are uploaded

  2. Lambda function processes events in batches for cost efficiency

  3. Intelligent path mapping determines which CloudFront paths to invalidate

  4. 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:

  1. ACM certificate created in us-east-1 for both domains

  2. DNS validation records added to Route53

  3. Certificate validation completed automatically

  4. A and AAAA records created pointing to CloudFront

  5. 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 root

  • PR previews: pr123.dev.example.com serves from /pr123/ folder

  • Automatic 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:

  1. Path Deduplication: Removes duplicate invalidation paths

  2. Wildcard Optimization: Converts multiple similar paths to wildcards

  3. 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:

  1. Check SQS Queue: Verify messages are being sent

  2. Lambda Logs: Check CloudWatch logs for errors

  3. IAM Permissions: Ensure Lambda can access CloudFront

  4. 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:

  1. Incorrect destination ARN

  2. Missing permissions in destination account

  3. 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:

  1. Inventory current resources:

     # List existing S3 buckets
     aws s3 ls
    
     # List CloudFront distributions
     aws cloudfront list-distributions
    
  2. 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
    
  3. Plan migration:

     terraform plan
    

From Other Terraform Modules

Migration strategy depends on the existing module, but generally:

  1. Parallel deployment: Deploy new module alongside existing

  2. DNS cutover: Update Route53 records to point to new distribution

  3. 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:

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!

0
Subscribe to my newsletter

Read articles from Thu San directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Thu San
Thu San