# Static Website on AWS with S3, CloudFront and ACM using Terraform

> Host a static website on AWS with a private S3 bucket, CloudFront with Origin Access Control, an ACM certificate and Route 53, using Terraform or OpenTofu.

- Source: https://www.itwonderlab.com/terraform-s3-static-website-cloudfront/
- Published: 2026-05-03
- Updated: 2026-05-03
- Author: Javier Ruiz Jiménez (https://www.javierruizjimenez.com/)
- Site: IT Wonder Lab (https://www.itwonderlab.com/)

---

## S3 + CloudFront + ACM + Route 53

The recommended way to host a static site on AWS is a **private** [S3](https://www.itwonderlab.com/aws-s3/) bucket that only [CloudFront](https://www.itwonderlab.com/amazon-cloudfront/) can read, with HTTPS from an [ACM](https://www.itwonderlab.com/aws-acm/) certificate and a [Route 53](https://www.itwonderlab.com/aws-route-53/) DNS record. The bucket is not a public website endpoint.

```
Browser -> Route 53 -> CloudFront (HTTPS, cache) -> S3 (private, via OAC)
```

### Providers

CloudFront certificates must be created in `us-east-1`, so you need a second provider:

```hcl title="providers.tf"
provider "aws" {
  region = "eu-west-1"
}

provider "aws" {
  alias  = "us_east_1"
  region = "us-east-1"
}

variable "domain_name" {
  type    = string
  default = "www.example.com"
}

variable "zone_name" {
  type    = string
  default = "example.com"
}

data "aws_route53_zone" "this" {
  name = var.zone_name
}
```

### Private bucket

```hcl title="bucket.tf"
resource "aws_s3_bucket" "site" {
  bucket = "ditwl-site-${var.domain_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
}
```

More hardening options are in [S3 with Terraform](https://www.itwonderlab.com/aws-terraform-tutorial-aws-s3/).

### Certificate with DNS validation

```hcl title="acm.tf"
resource "aws_acm_certificate" "site" {
  provider          = aws.us_east_1
  domain_name       = var.domain_name
  validation_method = "DNS"

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_route53_record" "validation" {
  for_each = {
    for o in aws_acm_certificate.site.domain_validation_options : o.domain_name => {
      name   = o.resource_record_name
      record = o.resource_record_value
      type   = o.resource_record_type
    }
  }

  zone_id         = data.aws_route53_zone.this.zone_id
  name            = each.value.name
  type            = each.value.type
  records         = [each.value.record]
  ttl             = 60
  allow_overwrite = true
}

resource "aws_acm_certificate_validation" "site" {
  provider                = aws.us_east_1
  certificate_arn         = aws_acm_certificate.site.arn
  validation_record_fqdns = [for r in aws_route53_record.validation : r.fqdn]
}
```

The [`for_each`](https://www.itwonderlab.com/terraform-for-each-vs-count/) creates one validation record per domain name. See [Route 53 with Terraform](https://www.itwonderlab.com/aws-with-terraform-tutorial-aws-route-53/).

### CloudFront with Origin Access Control

```hcl title="cloudfront.tf"
resource "aws_cloudfront_origin_access_control" "site" {
  name                              = "ditwl-site-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"
  aliases             = [var.domain_name]
  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"
  }

  custom_error_response {
    error_code         = 404
    response_code      = 404
    response_page_path = "/404.html"
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  viewer_certificate {
    acm_certificate_arn      = aws_acm_certificate_validation.site.certificate_arn
    ssl_support_method       = "sni-only"
    minimum_protocol_version = "TLSv1.2_2021"
  }
}
```

### Allow only this distribution to read the bucket

```hcl title="policy.tf"
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
}
```

### DNS record

```hcl title="dns.tf"
resource "aws_route53_record" "site" {
  zone_id = data.aws_route53_zone.this.zone_id
  name    = var.domain_name
  type    = "A"

  alias {
    name                   = aws_cloudfront_distribution.site.domain_name
    zone_id                = aws_cloudfront_distribution.site.hosted_zone_id
    evaluate_target_health = false
  }
}
```

### Upload and invalidate

```shell
$ aws s3 sync ./public s3://ditwl-site-www.example.com --delete
$ aws cloudfront create-invalidation --distribution-id EXXXXXXXXXXXX --paths "/*"
```

Terraform creates the infrastructure, and your site build pipeline deploys the files. Do not manage thousands of files with `aws_s3_object`.

### Notes

- CloudFront takes several minutes to deploy and to delete.
- With OAC there are no public objects and no static website endpoint, so directory URLs such as `/about/` do not serve `index.html` automatically. Add a CloudFront Function that rewrites them, or use a build that generates `about.html`.
- For `example.com` and `www.example.com`, add both names to `aliases` and to the certificate (`subject_alternative_names`).
- Cost: CloudFront and S3 are charged by usage. The free tier covers a small site. See [cost estimation](https://www.itwonderlab.com/terraform-cost-estimation-infracost/).
