Sitemap

Deploying a Static Site on AWS with Terraform (S3 + CloudFront)

4 min readOct 29, 2026

--

Press enter or click to view image in full size

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…

--

--