Intro

Deploying a static site in AWS can be a bit of an affair. Thankfully, Devops tools like Terraform make things a bit more sane.

In a previous post I covered this topic, using Pulumi as the build tool. I really like Pulumi, but I don't see alot of demand for it in my region.

In this post, I will show you how to deploy a static website in AWS with Terraform Cloud.

Note
The Terraform code used to deploy the site can be found here.

Pre-Flight Check

Terraform Cloud

This post assumes that you already have your Terraform Cloud environment setup and able to deploy resources into AWS.

To get Terraform Cloud setup, check out this post. For connecting Terraform Cloud with AWS, check out this post.

Domain Name

You will also need to register a domain name in Route 53. The process to register a domain can be found in the the docs here. I have previously registered stratuslabs.net and will use that domain for this lab.

Software Versions

The following software versions where used in this post.

  • Ubuntu - 22.04.1 LTS
  • Terraform CLI - 1.3.7

Lab Diagram

The following diagram shows the services we will use to build and host the static site.

blog/aws-static-website-with-terraform/aws-static-website-terraform.png

I have covered the services used in another post, so I won't go into them in detail here. The only difference, is that I am using Origin Access Control instead of an Origin Access Identity which was not supported by Pulumi at the time of writing.

Alright, you got all that together? Let's crack on!

In the next sections I will cover the Terraform code used to deploy this lab. The code is split into multiple files based on its function.

Terraform Cloud

First, configure your connection to Terraform Cloud in the backend.tf file. Set your organization and the name of the workspace. For this project, I have created a workspace called aws-static-site.

backend.tf
terraform {
  cloud {
    organization = "<ORGANIZATION>"

    workspaces {
      name = "aws-static-site"
    }
  }
}

Project Variables

We define the variables for the project in the terraform.tfvars file.

terraform.tfvars
project_name = "stratus_labs"
domain_name  = "stratuslabs.net"
environment  = "dev"

AWS Provider

The AWS provider configuration is defined in the provider.tf file.

provider.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 4.0"
    }
  }
}

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

S3

We have two files to define our S3 buckets. One for our website, and one for the sites logfiles.

Web Bucket

The web_bucket.tf file contains the config related to our website.

www_bucket.tf
resource "aws_s3_bucket" "web_bucket" {
  bucket = "www.${var.domain_name}"

  tags = {
    project     = var.project_name
    environment = var.environment
  }
}

resource "aws_s3_bucket_website_configuration" "web_bucket_config" {
  bucket = aws_s3_bucket.web_bucket.bucket

  index_document {
    suffix = "index.html"
  }

  error_document {
    key = "error.html"
  }

}

resource "aws_s3_object" "site" {
  bucket       = aws_s3_bucket.web_bucket.bucket
  for_each     = fileset("./_site/", "**")
  key          = each.value
  source       = "./_site/${each.value}"
  content_type = "text/html"
  etag         = filemd5("./_site/${each.value}")
}

resource "aws_s3_bucket_acl" "web_bucket_acl" {
  bucket = aws_s3_bucket.web_bucket.id
  acl    = "private"
}

data "aws_iam_policy_document" "allow_access_from_cloud_front" {
  version = "2012-10-17"

  statement {
    sid = "PolicyForCloudFrontPrivateContent"
    actions = [
      "s3:GetObject",
    ]
    resources = [
      "${aws_s3_bucket.web_bucket.arn}/*",
    ]
    principals {
      type        = "Service"
      identifiers = ["cloudfront.amazonaws.com"]
    }
    condition {
      test     = "StringEquals"
      variable = "AWS:SourceArn"
      values   = [aws_cloudfront_distribution.web_distribution.arn]
    }
  }
}

resource "aws_s3_bucket_policy" "allow_access_from_cloud_front" {
  bucket = aws_s3_bucket.web_bucket.bucket
  policy = data.aws_iam_policy_document.allow_access_from_cloud_front.json
}
Note
I have the index.html and error.html files in a directory named _site/.

Log Bucket

We will also configure a dedicated S3 bucket named log_bucket.tf to store the access logs for our website.

log_bucket.tf
resource "aws_s3_bucket" "log_bucket" {
  bucket        = "log.www.${var.domain_name}"
  force_destroy = true

  tags = {
    project     = var.project_name
    environment = var.environment
  }
}

resource "aws_s3_bucket_acl" "log_bucket_acl" {
  bucket = aws_s3_bucket.log_bucket.id
  acl    = "private"
}

Route53

Our domain related config is defined in a file named www_domain.tf.

www_domain.tf
# Get data for the pre-purchased domain.
data "aws_route53_zone" "domain" {
  name = var.domain_name
}

locals {
  record_types = toset(["A", "AAAA"])
}

resource "aws_route53_record" "bare_record" {
  zone_id  = data.aws_route53_zone.domain.zone_id
  name     = var.domain_name
  for_each = local.record_types
  type     = each.value

  alias {
    name                   = aws_cloudfront_distribution.web_distribution.domain_name
    zone_id                = aws_cloudfront_distribution.web_distribution.hosted_zone_id
    evaluate_target_health = true
  }
}

resource "aws_route53_record" "www_record" {
  zone_id  = data.aws_route53_zone.domain.zone_id
  name     = "www.${var.domain_name}"
  for_each = local.record_types
  type     = each.value

  alias {
    name                   = aws_cloudfront_distribution.web_distribution.domain_name
    zone_id                = aws_cloudfront_distribution.web_distribution.hosted_zone_id
    evaluate_target_health = true
  }
}

Certificate Manager

We want to serve our site via https. The TLS related configuration is defined in a file named www_certificate.tf.

www_certificate.tf
resource "aws_acm_certificate" "certificate" {
  domain_name       = var.domain_name
  validation_method = "DNS"
  subject_alternative_names = [
    "www.${var.domain_name}"
  ]
  tags = {
    project     = var.project_name
    environment = var.environment
  }

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_route53_record" "certificate_validation" {
  for_each = {
    for dvo in aws_acm_certificate.certificate.domain_validation_options : dvo.domain_name => {
      name   = dvo.resource_record_name
      record = dvo.resource_record_value
      type   = dvo.resource_record_type
    }
  }

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


resource "aws_acm_certificate_validation" "certificate_validation" {
  certificate_arn         = aws_acm_certificate.certificate.arn
  validation_record_fqdns = [for record in aws_route53_record.certificate_validation : record.fqdn]
}

CloudFront

We want to cache our websites and assets close to our users by using CloudFront as a Content Delivery Network (CDN). The CDN related config is defined in a file name www_cdn.tf.

www_cdn.tf
resource "aws_cloudfront_origin_access_control" "web_bucket_access_policy" {
  name                              = "allow_to_s3_from_cloud_front"
  description                       = "Allows access to an S3 web bucket from CloudFront"
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
}

resource "aws_cloudfront_distribution" "web_distribution" {
  origin {
    domain_name              = aws_s3_bucket.web_bucket.bucket_regional_domain_name
    origin_access_control_id = aws_cloudfront_origin_access_control.web_bucket_access_policy.id
    origin_id                = aws_s3_bucket.web_bucket.id
  }

  enabled             = true
  is_ipv6_enabled     = true
  comment             = "CloudFront distribution for our Website"
  default_root_object = "index.html"

  logging_config {
    include_cookies = false
    bucket          = "log.www.stratuslabs.net.s3.amazonaws.com"
  }

  aliases = [var.domain_name, "www.${var.domain_name}"]

  default_cache_behavior {
    allowed_methods  = ["GET", "HEAD"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = aws_s3_bucket.web_bucket.id

    forwarded_values {
      query_string = false

      cookies {
        forward = "none"
      }
    }

    viewer_protocol_policy = "redirect-to-https"
    min_ttl                = 0
    default_ttl            = 3600
    max_ttl                = 86400
  }

  # For this lab, we only deploy to minimal regions.
  price_class = "PriceClass_100"

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  viewer_certificate {
    acm_certificate_arn      = aws_acm_certificate.certificate.arn
    ssl_support_method       = "sni-only"
    minimum_protocol_version = "TLSv1.2_2021"

  }

  tags = {
    project     = var.project_name
    environment = var.environment
  }

}

And that my friends is all the Terraform code required.

Ship It

All we need to do to deploy the site is run terraform apply and wait about 5 minutes.

You can see this site in all it's glory here.

Outro

In this post, we covered the Terraform code required to deploy a Static Website to AWS using Route53, S3 and CloudFront as well as utilize Certificate Manager for TLS certificates. The Terraform code is executed on Terraform Cloud which is also used for the remote state backend.