resource "random_id" "bucket_suffix" {
byte_length = 4
}
resource "aws_s3_bucket" "frontend" {
bucket = "${var.project_name}-frontend-${var.environment}-${random_id.bucket_suffix.hex}"
force_destroy = true
}
resource "aws_s3_bucket_public_access_block" "frontend" {
bucket = aws_s3_bucket.frontend.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_cloudfront_origin_access_control" "oac" {
name = "${var.project_name}-oac-${var.environment}"
description = "OAC for S3 frontend distribution"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
resource "aws_s3_bucket_policy" "frontend" {
bucket = aws_s3_bucket.frontend.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowCloudFrontServicePrincipalReadOnly"
Effect = "Allow"
Principal = {
Service = "cloudfront.amazonaws.com"
}
Action = "s3:GetObject"
Resource = "${aws_s3_bucket.frontend.arn}/*"
Condition = {
StringEquals = {
"AWS:SourceArn" = aws_cloudfront_distribution.cdn.arn
}
}
}
]
})
}
locals {
lambda_url_domain = replace(replace(aws_lambda_function_url.backend_url.function_url, "https://", ""), "/", "")
}
resource "aws_cloudfront_distribution" "cdn" {
enabled = true
is_ipv6_enabled = true
default_root_object = "index.html"
price_class = "PriceClass_100"
# Origin 1: S3 Bucket (Frontend React 19 SPA)
origin {
domain_name = aws_s3_bucket.frontend.bucket_regional_domain_name
origin_id = "S3Origin"
origin_access_control_id = aws_cloudfront_origin_access_control.oac.id
}
# Origin 2: Lambda Function URL (FastAPI Backend)
origin {
domain_name = local.lambda_url_domain
origin_id = "LambdaOrigin"
custom_origin_config {
http_port = 80
https_port = 443
origin_protocol_policy = "https-only"
origin_ssl_protocols = ["TLSv1.2"]
}
}
# Default behavior: S3 static files
default_cache_behavior {
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "S3Origin"
viewer_protocol_policy = "redirect-to-https"
compress = true
forwarded_values {
query_string = false
cookies {
forward = "none"
}
}
min_ttl = 0
default_ttl = 86400
max_ttl = 31536000
}
# API route: forward to Lambda
ordered_cache_behavior {
path_pattern = "/api/*"
allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "LambdaOrigin"
viewer_protocol_policy = "redirect-to-https"
compress = true
forwarded_values {
query_string = true
headers = ["Accept", "Accept-Language", "Authorization", "Content-Type", "Origin", "Referer", "User-Agent"]
cookies {
forward = "all"
}
}
min_ttl = 0
default_ttl = 0
max_ttl = 0
}
# Health route: forward to Lambda
ordered_cache_behavior {
path_pattern = "/health"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "LambdaOrigin"
viewer_protocol_policy = "redirect-to-https"
compress = true
forwarded_values {
query_string = false
headers = ["Accept", "Accept-Language", "Authorization", "Content-Type", "Origin", "Referer", "User-Agent"]
cookies {
forward = "none"
}
}
min_ttl = 0
default_ttl = 0
max_ttl = 0
}
# Swagger UI docs route: forward to Lambda
ordered_cache_behavior {
path_pattern = "/docs*"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "LambdaOrigin"
viewer_protocol_policy = "redirect-to-https"
forwarded_values {
query_string = true
headers = ["Accept", "Accept-Language", "Authorization", "Content-Type", "Origin", "Referer", "User-Agent"]
cookies {
forward = "none"
}
}
min_ttl = 0
default_ttl = 0
max_ttl = 0
}
# ReDoc UI route: forward to Lambda
ordered_cache_behavior {
path_pattern = "/redoc*"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "LambdaOrigin"
viewer_protocol_policy = "redirect-to-https"
forwarded_values {
query_string = true
headers = ["Accept", "Accept-Language", "Authorization", "Content-Type", "Origin", "Referer", "User-Agent"]
cookies {
forward = "none"
}
}
min_ttl = 0
default_ttl = 0
max_ttl = 0
}
# OpenAPI schema route: forward to Lambda
ordered_cache_behavior {
path_pattern = "/openapi.json"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "LambdaOrigin"
viewer_protocol_policy = "redirect-to-https"
forwarded_values {
query_string = false
headers = ["Accept", "Accept-Language", "Authorization", "Content-Type", "Origin", "Referer", "User-Agent"]
cookies {
forward = "none"
}
}
min_ttl = 0
default_ttl = 0
max_ttl = 0
}
# Prometheus metrics route: forward to Lambda
ordered_cache_behavior {
path_pattern = "/metrics"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "LambdaOrigin"
viewer_protocol_policy = "redirect-to-https"
forwarded_values {
query_string = false
headers = ["Accept", "Accept-Language", "Authorization", "Content-Type", "Origin", "Referer", "User-Agent"]
cookies {
forward = "none"
}
}
min_ttl = 0
default_ttl = 0
max_ttl = 0
}
# SPA error responses for client-side routing
custom_error_response {
error_code = 403
response_code = 200
response_page_path = "/index.html"
}
custom_error_response {
error_code = 404
response_code = 200
response_page_path = "/index.html"
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
viewer_certificate {
cloudfront_default_certificate = true
}
}