This skill should be used when the user asks to "create terraform configuration", "deploy static site", "set up cloudfront", "configure route53", "create lambda function", "ssl certificate", or mentions S3 website hosting, CDN, serverless, JAMstack, or static site infrastructure.
This skill inherits all available tools. When active, it can use any tool Claude has access to.
examples/static-site/README.mdreferences/aws-resources.mdFocused guidance for creating serverless and static site infrastructure on AWS using Terraform.
This skill covers: Route53, S3, CloudFront, ACM, and Lambda.
This skill provides best practices for managing serverless and static site infrastructure as code using Terraform. It covers the essential AWS services for JAMstack applications, static websites, and serverless functions.
Follow these foundational principles when writing Terraform:
Structure Terraform projects consistently. Modern projects typically use a locals-first approach:
static-site-infra/
├── environments/
│ ├── dev/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── locals.tf
│ │ ├── outputs.tf
│ │ ├── versions.tf
│ │ └── backend.tf
│ ├── staging/
│ └── prod/
├── modules/
│ ├── static-site/
│ ├── lambda-function/
│ └── cdn/
├── .terraform.lock.hcl
└── README.md
File conventions:
main.tf - Primary resource definitionsvariables.tf - Input variable declarations (minimal, project-level only)locals.tf - Environment-specific configuration and computed valuesoutputs.tf - Output value declarationsversions.tf - Terraform and provider version constraintsbackend.tf - Remote state configurationAlways use remote state for team environments:
# backend.tf
terraform {
backend "s3" {
bucket = "company-terraform-state"
key = "static-site/prod/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-state-lock"
}
}
Never hardcode credentials or sensitive data:
# ❌ WRONG
resource "aws_lambda_function" "api" {
environment {
variables = {
API_KEY = "sk-1234567890" # NEVER DO THIS
}
}
}
# ✅ CORRECT - Use AWS Secrets Manager
data "aws_secretsmanager_secret_version" "api_key" {
secret_id = "prod/api/key"
}
resource "aws_lambda_function" "api" {
environment {
variables = {
API_KEY_ARN = data.aws_secretsmanager_secret_version.api_key.arn
}
}
}
# ✅ CORRECT - Use variables (set via environment)
variable "api_key" {
description = "API key for external service"
type = string
sensitive = true
}
resource "aws_lambda_function" "api" {
environment {
variables = {
API_KEY = var.api_key
}
}
}
sensitive = trueFor static sites and serverless apps, use locals for environment-specific configuration:
# locals.tf
locals {
environments = {
dev = {
domain_name = "dev.example.com"
lambda_memory = 512
cloudfront_price_class = "PriceClass_100"
}
prod = {
domain_name = "www.example.com"
lambda_memory = 1024
cloudfront_price_class = "PriceClass_All"
}
}
env = local.environments[var.environment]
common_tags = {
Environment = var.environment
ManagedBy = "Terraform"
Project = var.project_name
}
}
# main.tf
resource "aws_lambda_function" "api" {
memory_size = local.env.lambda_memory
tags = local.common_tags
}
Use tfvars for: Project name, AWS region, shared configuration
Always pin provider versions:
# versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
# provider.tf
provider "aws" {
region = var.aws_region
default_tags {
tags = local.common_tags
}
}
# ACM certificates must be in us-east-1 for CloudFront
provider "aws" {
alias = "us_east_1"
region = "us-east-1"
default_tags {
tags = local.common_tags
}
}
# S3 bucket for static website
resource "aws_s3_bucket" "website" {
bucket = "${var.project_name}-${var.environment}-site"
tags = merge(
local.common_tags,
{
Name = "${var.project_name}-${var.environment}-website"
}
)
}
# Block public access for CloudFront OAI pattern
resource "aws_s3_bucket_public_access_block" "website" {
bucket = aws_s3_bucket.website.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# Versioning for rollback capability
resource "aws_s3_bucket_versioning" "website" {
bucket = aws_s3_bucket.website.id
versioning_configuration {
status = "Enabled"
}
}
# Server-side encryption
resource "aws_s3_bucket_server_side_encryption_configuration" "website" {
bucket = aws_s3_bucket.website.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
# Lifecycle rules for old versions
resource "aws_s3_bucket_lifecycle_configuration" "website" {
bucket = aws_s3_bucket.website.id
rule {
id = "cleanup-old-versions"
status = "Enabled"
noncurrent_version_expiration {
noncurrent_days = 90
}
}
}
# CloudFront Origin Access Identity for S3
resource "aws_cloudfront_origin_access_identity" "website" {
comment = "OAI for ${var.project_name}-${var.environment}"
}
# S3 bucket policy to allow CloudFront OAI
resource "aws_s3_bucket_policy" "website" {
bucket = aws_s3_bucket.website.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowCloudFrontOAI"
Effect = "Allow"
Principal = {
AWS = aws_cloudfront_origin_access_identity.website.iam_arn
}
Action = "s3:GetObject"
Resource = "${aws_s3_bucket.website.arn}/*"
}
]
})
}
# CloudFront distribution
resource "aws_cloudfront_distribution" "website" {
enabled = true
is_ipv6_enabled = true
default_root_object = "index.html"
aliases = [local.env.domain_name]
price_class = local.env.cloudfront_price_class
origin {
domain_name = aws_s3_bucket.website.bucket_regional_domain_name
origin_id = "S3-${aws_s3_bucket.website.id}"
s3_origin_config {
origin_access_identity = aws_cloudfront_origin_access_identity.website.cloudfront_access_identity_path
}
}
default_cache_behavior {
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "S3-${aws_s3_bucket.website.id}"
forwarded_values {
query_string = false
cookies {
forward = "none"
}
}
viewer_protocol_policy = "redirect-to-https"
min_ttl = 0
default_ttl = 3600
max_ttl = 86400
compress = true
}
# Custom error response for SPA routing
custom_error_response {
error_code = 404
response_code = 200
response_page_path = "/index.html"
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
viewer_certificate {
acm_certificate_arn = aws_acm_certificate.website.arn
ssl_support_method = "sni-only"
minimum_protocol_version = "TLSv1.2_2021"
}
tags = local.common_tags
}
# ACM certificate (must be in us-east-1 for CloudFront)
resource "aws_acm_certificate" "website" {
provider = aws.us_east_1
domain_name = local.env.domain_name
validation_method = "DNS"
lifecycle {
create_before_destroy = true
}
tags = local.common_tags
}
# Route53 DNS validation records
resource "aws_route53_record" "cert_validation" {
for_each = {
for dvo in aws_acm_certificate.website.domain_validation_options : dvo.domain_name => {
name = dvo.resource_record_name
record = dvo.resource_record_value
type = dvo.resource_record_type
}
}
allow_overwrite = true
name = each.value.name
records = [each.value.record]
ttl = 60
type = each.value.type
zone_id = data.aws_route53_zone.main.zone_id
}
# Certificate validation
resource "aws_acm_certificate_validation" "website" {
provider = aws.us_east_1
certificate_arn = aws_acm_certificate.website.arn
validation_record_fqdns = [for record in aws_route53_record.cert_validation : record.fqdn]
}
# Data source for existing hosted zone
data "aws_route53_zone" "main" {
name = var.root_domain
}
# A record for CloudFront (IPv4)
resource "aws_route53_record" "website_ipv4" {
zone_id = data.aws_route53_zone.main.zone_id
name = local.env.domain_name
type = "A"
alias {
name = aws_cloudfront_distribution.website.domain_name
zone_id = aws_cloudfront_distribution.website.hosted_zone_id
evaluate_target_health = false
}
}
# AAAA record for CloudFront (IPv6)
resource "aws_route53_record" "website_ipv6" {
zone_id = data.aws_route53_zone.main.zone_id
name = local.env.domain_name
type = "AAAA"
alias {
name = aws_cloudfront_distribution.website.domain_name
zone_id = aws_cloudfront_distribution.website.hosted_zone_id
evaluate_target_health = false
}
}
# Lambda function
resource "aws_lambda_function" "api" {
filename = "lambda.zip"
function_name = "${var.project_name}-${var.environment}-api"
role = aws_iam_role.lambda.arn
handler = "index.handler"
runtime = "python3.11"
source_code_hash = filebase64sha256("lambda.zip")
environment {
variables = {
ENVIRONMENT = var.environment
TABLE_NAME = aws_dynamodb_table.data.name
}
}
timeout = 30
memory_size = local.env.lambda_memory
tags = local.common_tags
}
# IAM role for Lambda
resource "aws_iam_role" "lambda" {
name = "${var.project_name}-${var.environment}-lambda-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
Service = "lambda.amazonaws.com"
}
Action = "sts:AssumeRole"
}]
})
tags = local.common_tags
}
# Lambda basic execution policy
resource "aws_iam_role_policy_attachment" "lambda_logs" {
role = aws_iam_role.lambda.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
# CloudWatch log group
resource "aws_cloudwatch_log_group" "lambda" {
name = "/aws/lambda/${aws_lambda_function.api.function_name}"
retention_in_days = 14
tags = local.common_tags
}
# Lambda function URL (for simple APIs)
resource "aws_lambda_function_url" "api" {
function_name = aws_lambda_function.api.function_name
authorization_type = "NONE"
cors {
allow_credentials = false
allow_origins = ["https://${local.env.domain_name}"]
allow_methods = ["GET", "POST"]
allow_headers = ["content-type"]
max_age = 86400
}
}
# Lambda@Edge must be in us-east-1
resource "aws_lambda_function" "edge" {
provider = aws.us_east_1
filename = "edge-function.zip"
function_name = "${var.project_name}-${var.environment}-edge"
role = aws_iam_role.lambda_edge.arn
handler = "index.handler"
runtime = "python3.11"
publish = true # Required for Lambda@Edge
source_code_hash = filebase64sha256("edge-function.zip")
timeout = 5 # Max 5 seconds for viewer-facing functions
memory_size = 128
tags = local.common_tags
}
# IAM role for Lambda@Edge
resource "aws_iam_role" "lambda_edge" {
provider = aws.us_east_1
name = "${var.project_name}-${var.environment}-lambda-edge-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
Service = [
"lambda.amazonaws.com",
"edgelambda.amazonaws.com"
]
}
Action = "sts:AssumeRole"
}
]
})
tags = local.common_tags
}
# Attach to CloudFront distribution
resource "aws_cloudfront_distribution" "website_with_edge" {
# ... other configuration ...
default_cache_behavior {
# ... other settings ...
lambda_function_association {
event_type = "viewer-request"
lambda_arn = aws_lambda_function.edge.qualified_arn
include_body = false
}
}
}
Create a reusable module for static sites:
# modules/static-site/main.tf
resource "aws_s3_bucket" "website" {
bucket = var.bucket_name
}
resource "aws_cloudfront_distribution" "website" {
# ... configuration using variables
}
# modules/static-site/variables.tf
variable "bucket_name" {
description = "S3 bucket name for static site"
type = string
}
variable "domain_name" {
description = "Custom domain name"
type = string
}
variable "certificate_arn" {
description = "ACM certificate ARN"
type = string
}
# modules/static-site/outputs.tf
output "cloudfront_domain" {
description = "CloudFront distribution domain"
value = aws_cloudfront_distribution.website.domain_name
}
output "bucket_name" {
description = "S3 bucket name"
value = aws_s3_bucket.website.id
}
# 1. Initialize
terraform init
# 2. Format code
terraform fmt -recursive
# 3. Validate configuration
terraform validate
# 4. Plan changes
terraform plan -out=tfplan
# 5. Review plan carefully
terraform show tfplan
# 6. Apply if plan looks good
terraform apply tfplan
# 7. Verify outputs
terraform output
For static sites:
aws s3 sync build/ s3://bucket/)aws cloudfront create-invalidation)For Lambda:
# Full example combining all services
module "static_site" {
source = "./modules/static-site"
project_name = var.project_name
environment = var.environment
domain_name = local.env.domain_name
root_domain = var.root_domain
tags = local.common_tags
}
# Outputs for CI/CD
output "bucket_name" {
description = "S3 bucket for uploads"
value = module.static_site.bucket_name
}
output "cloudfront_distribution_id" {
description = "CloudFront distribution ID for cache invalidation"
value = module.static_site.distribution_id
}
output "website_url" {
description = "Website URL"
value = "https://${local.env.domain_name}"
}
# Lambda function for API
resource "aws_lambda_function" "api" {
# ... configuration ...
}
# Lambda function URL
resource "aws_lambda_function_url" "api" {
function_name = aws_lambda_function.api.function_name
authorization_type = "NONE"
}
# CloudFront distribution for caching
resource "aws_cloudfront_distribution" "api" {
origin {
domain_name = replace(aws_lambda_function_url.api.function_url, "https://", "")
origin_id = "lambda-api"
custom_origin_config {
http_port = 80
https_port = 443
origin_protocol_policy = "https-only"
origin_ssl_protocols = ["TLSv1.2"]
}
}
# Cache based on query strings and headers
default_cache_behavior {
allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
cached_methods = ["GET", "HEAD", "OPTIONS"]
target_origin_id = "lambda-api"
forwarded_values {
query_string = true
headers = ["Authorization"]
cookies {
forward = "none"
}
}
viewer_protocol_policy = "redirect-to-https"
min_ttl = 0
default_ttl = 0 # No caching for API by default
max_ttl = 3600
}
# ... rest of CloudFront config
}
Issue: CloudFront changes take 15-30 minutes to deploy
Solution: Use terraform apply -target for other resources, plan CloudFront updates during maintenance windows
Issue: Certificate validation pending
Solution:
terraform refresh to check validation statusIssue: Can't destroy S3 bucket with objects
Solution:
# Empty bucket before destroy
aws s3 rm s3://bucket-name --recursive
# Or use lifecycle policy
resource "aws_s3_bucket_lifecycle_configuration" "cleanup" {
bucket = aws_s3_bucket.website.id
rule {
id = "expire-all"
status = "Enabled"
expiration {
days = 1
}
}
}
Issue: Lambda zip > 50MB
Solution: Use S3 for deployment package:
resource "aws_s3_object" "lambda_zip" {
bucket = aws_s3_bucket.deployments.id
key = "lambda/${var.version}/function.zip"
source = "lambda.zip"
etag = filemd5("lambda.zip")
}
resource "aws_lambda_function" "api" {
s3_bucket = aws_s3_bucket.deployments.id
s3_key = aws_s3_object.lambda_zip.key
# ... rest of config
}
When working with Terraform for static sites and serverless:
For complete examples, see the examples/ directory.