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

ResourcePurpose
aws_s3_bucketStores the site files
aws_s3_bucket_public_access_blockBlocks every form of public access
aws_s3_bucket_versioningKeeps previous versions, so a bad deploy can be rolled back
aws_cloudfront_origin_access_controlLets CloudFront sign its requests to S3
aws_cloudfront_distributionServes the site over HTTPS from edge locations
aws_s3_bucket_policyAllows 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. Use PriceClass_All if most visitors are elsewhere.
  • Managed cache policy. CachingOptimized is 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.

FAQ

Should the S3 bucket be public for a static website?

No. Keep the bucket private and let CloudFront read it through Origin Access Control. The bucket policy in this example allows reads only from your distribution, so nobody can bypass CloudFront to reach the files.

What is the difference between Origin Access Control and Origin Access Identity?

Origin Access Control (OAC) is the newer method and AWS recommends it for new distributions. It signs requests with SigV4 and supports buckets encrypted with KMS. Origin Access Identity (OAI) is the legacy method.

How do I use my own domain?

Request an ACM certificate in us-east-1 for the domain, add it to viewer_certificate with ssl_support_method set to sni-only, list the domain under aliases, and point a Route 53 alias record at the distribution.

Platform evaluation

See OrchesTerra on your infrastructure

Request access to generate architecture from a repository, review the plan, and run governed reconciliation across AWS, Azure, GCP, and OCI.