AWS · Terraform example
Terraform Example: Static Website on S3 and CloudFront
A complete Terraform example for hosting a static website on AWS: a private S3 bucket served through CloudFront with Origin Access Control and HTTPS.
Updated Nov 2026
This example hosts a static website (a React, Vue, or Astro build, or plain HTML) on AWS. Files live in a private S3 bucket. CloudFront serves them over HTTPS and is the only thing allowed to read the bucket.
What it creates
| Resource | Purpose |
|---|---|
aws_s3_bucket | Stores the site files |
aws_s3_bucket_public_access_block | Blocks every form of public access |
aws_s3_bucket_versioning | Keeps previous versions, so a bad deploy can be rolled back |
aws_cloudfront_origin_access_control | Lets CloudFront sign its requests to S3 |
aws_cloudfront_distribution | Serves the site over HTTPS from edge locations |
aws_s3_bucket_policy | Allows reads from this distribution only |
The Terraform
Save as main.tf. It works unchanged with OpenTofu.
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.region
}
variable "region" {
type = string
default = "us-east-1"
}
variable "bucket_name" {
type = string
description = "Globally unique name for the site bucket."
}
# Private bucket: only CloudFront can read it.
resource "aws_s3_bucket" "site" {
bucket = var.bucket_name
}
resource "aws_s3_bucket_public_access_block" "site" {
bucket = aws_s3_bucket.site.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_versioning" "site" {
bucket = aws_s3_bucket.site.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_cloudfront_origin_access_control" "site" {
name = "${var.bucket_name}-oac"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
resource "aws_cloudfront_distribution" "site" {
enabled = true
default_root_object = "index.html"
price_class = "PriceClass_100"
origin {
domain_name = aws_s3_bucket.site.bucket_regional_domain_name
origin_id = "s3-site"
origin_access_control_id = aws_cloudfront_origin_access_control.site.id
}
default_cache_behavior {
target_origin_id = "s3-site"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
compress = true
# AWS managed policy "CachingOptimized"
cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6"
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
viewer_certificate {
cloudfront_default_certificate = true
}
}
# Allow reads from this distribution only.
data "aws_iam_policy_document" "site" {
statement {
actions = ["s3:GetObject"]
resources = ["${aws_s3_bucket.site.arn}/*"]
principals {
type = "Service"
identifiers = ["cloudfront.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "AWS:SourceArn"
values = [aws_cloudfront_distribution.site.arn]
}
}
}
resource "aws_s3_bucket_policy" "site" {
bucket = aws_s3_bucket.site.id
policy = data.aws_iam_policy_document.site.json
depends_on = [aws_s3_bucket_public_access_block.site]
}
output "site_url" {
value = "https://${aws_cloudfront_distribution.site.domain_name}"
}
Deploy it
terraform init
terraform plan -var="bucket_name=my-site-example-123" -out=tfplan
terraform apply tfplan
# Upload the build output and clear the cache
aws s3 sync ./dist s3://my-site-example-123 --delete
aws cloudfront create-invalidation --distribution-id <id> --paths "/*"
The first apply takes a few minutes while CloudFront deploys.
Choices this example makes
- Private bucket, not S3 website hosting. S3 website endpoints need a public bucket and do not support HTTPS. CloudFront with OAC keeps the bucket private.
PriceClass_100. Serves from edge locations in North America and Europe only, which is the cheapest option. UsePriceClass_Allif most visitors are elsewhere.- Managed cache policy.
CachingOptimizedis AWS's recommended policy for static files. Give HTML short cache times in your upload step if you deploy often. - Versioning on. Old objects add a little storage cost. Add a lifecycle rule to expire noncurrent versions after 30 days if that matters.
Cost
For a small site, expect a few US dollars a month or less: S3 storage, CloudFront requests, and data transfer. CloudFront has a free tier. Check current pricing for your traffic.
Before production
- Add a custom domain and ACM certificate (see the FAQ).
- Add a CloudFront function or custom error response if your app uses client-side routing, so deep links return
index.html. - Store state remotely with locking, not on a laptop.
- Run the plan through policy checks and review before apply. See who approves a terraform apply.
- Schedule a plan to catch changes made in the console. See the Terraform drift detection guide.
