Deploying a Static Site on AWS with Terraform (S3 + CloudFront)
The Problem
You built a static site. Now you need to host it. You could pay $15/month for a managed host or you could use AWS, where S3 storage costs fractions of a cent and CloudFront has a generous free tier.
The catch? Getting it right on AWS means wiring together a private S3 bucket, a CloudFront distribution, an Origin Access Control, a least-privilege bucket policy, and an ACM certificate. Click through the console and you’ll have ten browser tabs open and a bucket that’s accidentally public.
This post walks through doing it entirely in Terraform reproducible, reviewable, and secure by default.
What You’ll Build
- A private S3 bucket — no public access ever.
- CloudFront as the only path in secured via Origin Access Control (OAC).
- HTTPS on your domain via ACM.
- Everything tagged, versioned and encrypted.
Project Structure
Step 1: The S3 Bucket Module
modules/s3/main.tf
resource "random_id" "suffix" {
byte_length = 4
}
resource "aws_s3_bucket" "s3" {
bucket = "${var.bucket_name}-${var.environment}-${random_id.suffix.hex}"
tags = {
Name = "${var.bucket_name}-${var.environment}"
Environment = var.environment
}
}Why random_id? Bucket names are globally unique across all AWS accounts. Hardcoding mybucket works exactly once — the second time, Terraform fails with BucketAlreadyExists. The random suffix makes the name unique and stable in state so it never changes on re-apply.
modules/s3/outputs.tf
output "bucket_id" {
value= aws_s3_bucket.s3.id
}
output "bucket_arn" {
value = aws_s3_bucket.s3.arn
}
output "bucket_regional_domain_name" {
value = aws_s3_bucket.s3.bucket_regional_domain_name
}Key lesson: Outputs are declared inside the module and referenced outside as module.s3.bucket_arn. You never pass outputs into the module block — that's for inputs only
Get Madhuchamara’s stories in your inbox
Join Medium for free to get updates from this writer.
modules/s3/variables.tf
variable "bucket_name" {}
variable "environment" {}Step 2: CloudFront with Origin Access Control
modules/cloudfront/main.tf
data "aws_iam_policy_document" "origin_bucket_policy" {
statement {
sid = var.sid
effect = var.effect
principals {
type = var.type
identifiers = var.identifiers
}
actions = [
"s3:GetObject",
"s3:PutObject",
]
resources = [
"${var.bucket_arn}/*",
]
condition {
test = "StringEquals"
variable = "AWS:SourceArn"
values = [aws_cloudfront_distribution.s3_distribution.arn]
}
}
}
resource "aws_s3_bucket_policy" "b" {
bucket = var.bucket_id
policy = data.aws_iam_policy_document.origin_bucket_policy.json
}
locals {
s3_origin_id = "myS3Origin"
}
# Free flat-rate plan doesn't support legacy forwarded_values/TTL settings, so use AWS managed policies
data "aws_cloudfront_cache_policy" "caching_optimized" {
name = "Managed-CachingOptimized"
}
data "aws_cloudfront_origin_request_policy" "cors_s3_origin" {
name = "Managed-CORS-S3Origin"
}
# Flat-rate pricing plans require a WAF web ACL on the distribution (CLOUDFRONT scope must be in us-east-1)
resource "aws_wafv2_web_acl" "cloudfront" {
name = "${var.environment}-cloudfront-web-acl"
scope = "CLOUDFRONT"
default_action {
allow {}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "${var.environment}-cloudfront-web-acl"
sampled_requests_enabled = true
}
}
resource "aws_cloudfront_origin_access_control" "default" {
name = "default-oac"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
resource "aws_cloudfront_distribution" "s3_distribution" {
origin {
domain_name = var.bucket_regional_domain_name
origin_access_control_id = aws_cloudfront_origin_access_control.default.id
origin_id = local.s3_origin_id
}
enabled = true
is_ipv6_enabled = true
web_acl_id = aws_wafv2_web_acl.cloudfront.arn
comment = "Some comment"
default_root_object = "index.html"
#aliases = ["mysite.${local.my_domain}", "yoursite.${local.my_domain}"]
default_cache_behavior {
allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
cached_methods = ["GET", "HEAD"]
target_origin_id = local.s3_origin_id
cache_policy_id = data.aws_cloudfront_cache_policy.caching_optimized.id
viewer_protocol_policy = "allow-all"
}
price_class = "PriceClass_200"
restrictions {
geo_restriction {
restriction_type = "whitelist"
locations = ["US", "CA", "GB", "DE", "LK"]
}
}
tags = {
Environment = var.environment
}
viewer_certificate {
cloudfront_default_certificate = true
}
}
using cloudfront free tier.What OAC buys you: CloudFront signs every request to S3 with SigV4, and the bucket policy (next step) only accepts requests from this specific distribution. The bucket stays fully private — no “public read” checkbox, no accidental exposure.
modules/cloudfront/variables.tf
variable "bucket_id" {}
variable "bucket_arn" {}
variable "bucket_regional_domain_name" {}
variable "environment" {}
variable "sid" {}
variable "effect" {}
variable "type" {}
variable "identifiers" {}Step 3: CloudFront + S3 main.tf
main.tf
module "s3"{
source = "./modules/s3"
bucket_name = var.bucket_name
environment = var.environment
}
module "cloudfront" {
source = "./modules/cloudfront"
bucket_id = module.s3.bucket_id
bucket_arn = module.s3.bucket_arn
bucket_regional_domain_name = module.s3.bucket_regional_domain_name
environment = var.environment
sid = var.sid
effect = var.effect
type = var.type
identifiers = var.identifiers
depends_on = [module.s3]
}variables.tf
variable "bucket_name" {
default = "static-site-dev-2026"
}
variable "environment" {
default = "dev"
}
variable "sid" {
default = "AllowCloudFrontServicePrincipal"
}
variable "effect" {
default = "Allow"
}
variable "type" {
default = "Service"
}
variable "identifiers" {
type = list(string)
default = ["cloudfront.amazonaws.com"]
}terraform.tfvars
bucket_name = "static-site-2026"
environment = "dev"
sid = "AllowCloudFrontServicePrincipal"
effect = "Allow"
type = "Service"
identifiers = ["cloudfront.amazonaws.com"]Step 5: Deploy
terraform init
terraform plan
terraform apply- Finally Access the sitewith Cloudfront url (ex: https://a3y32dvzam1388.cloudfront.net/)
Wrapping Up
The console makes this look like a five-minute task. Done properly — private origin, OAC, least privilege, HTTPS, unique names — it’s about 150 lines of Terraform. But those 150 lines are reviewable, reproducible, and won’t quietly expose your bucket at 2 a.m.
Full code is on GitHub: terraform-aws-s3-cloudfront
Keep Learning…









