Agent skill
terraform-aws
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.
Install this agent skill to your Project
npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/devops/terraform-aws-rcmx-claude-plugins-2adb473d
SKILL.md
Terraform for Serverless & Static Sites
Focused guidance for creating serverless and static site infrastructure on AWS using Terraform.
This skill covers: Route53, S3, CloudFront, ACM, and Lambda.
Overview
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.
Core Principles
Infrastructure as Code Fundamentals
Follow these foundational principles when writing Terraform:
- Declarative Configuration: Define desired state, not procedural steps
- Immutable Infrastructure: Replace rather than modify resources
- Version Control: All Terraform code in Git with proper branching
- Modularity: Reusable modules for common patterns
- Security First: Always encrypt data, use HTTPS, follow least privilege
File Organization
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 configuration
State Management
Remote State Configuration
Always 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"
}
}
State Management Best Practices
- Enable Encryption: Always encrypt state files (contains secrets like Lambda env vars)
- State Locking: Use DynamoDB for state locking (prevents conflicts)
- Separate States: Different state files per environment/site
- Backup Strategy: Enable S3 versioning on state bucket
- Access Control: Restrict state bucket access via IAM
Security Best Practices
Secrets Management
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
}
}
}
Security Checklist
- No hardcoded credentials or secrets
- Sensitive variables marked with
sensitive = true - S3 buckets block public access unless explicitly needed for static hosting
- CloudFront uses HTTPS only (or redirect HTTP to HTTPS)
- ACM certificates use DNS validation
- Lambda functions use IAM roles with least privilege
- S3 encryption enabled for non-public buckets
- CloudFront uses Origin Access Identity (OAI) for S3
Configuration Strategies
Locals vs Tfvars: Choosing the Right Approach
Recommended: Locals-First for Static Sites
For 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
Provider Configuration
Version Constraints
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
}
}
Resource Patterns
S3 Static Website Hosting
# 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 Distribution
# 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
# 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]
}
Route53 DNS
# 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
# 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 for CloudFront
# 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
}
}
}
Module Design
Static Site Module Structure
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
}
Workflow Best Practices
Development Workflow
# 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
Deployment Process
For static sites:
- Build: Generate static files (Next.js, Hugo, etc.)
- Terraform Apply: Ensure infrastructure is up to date
- Upload: Sync files to S3 (
aws s3 sync build/ s3://bucket/) - Invalidate: Clear CloudFront cache (
aws cloudfront create-invalidation)
For Lambda:
- Package: Create deployment zip with dependencies
- Terraform Apply: Deploy infrastructure and code together
- Test: Invoke function to verify deployment
Common Patterns
Complete Static Site Stack
# 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}"
}
Serverless API with CloudFront
# 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
}
Troubleshooting
CloudFront Updates Take Time
Issue: CloudFront changes take 15-30 minutes to deploy
Solution: Use terraform apply -target for other resources, plan CloudFront updates during maintenance windows
ACM Certificate Validation Stuck
Issue: Certificate validation pending
Solution:
- Verify DNS records are created correctly
- Check Route53 hosted zone is correct
- Wait up to 30 minutes for DNS propagation
- Use
terraform refreshto check validation status
S3 Bucket Not Empty Error
Issue: 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
}
}
}
Lambda Deployment Package Too Large
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
}
Summary
When working with Terraform for static sites and serverless:
- Structure: Use locals for environment config, modules for reusable components
- State: Remote state with encryption and locking
- Security: HTTPS everywhere, OAI for S3, least privilege IAM
- Certificates: ACM in us-east-1 for CloudFront, DNS validation
- CloudFront: Use OAI, enable compression, redirect HTTP→HTTPS
- Lambda: Proper IAM roles, CloudWatch logs, appropriate timeouts
- Route53: Use alias records for CloudFront
- Workflow: Plan → Review → Apply → Test
For complete examples, see the examples/ directory.
Didn't find tool you were looking for?