How to deploy your Static Site with Terraform, S3, and CloudFront?

How to deploy your Static Site with Terraform, S3, and CloudFront?

ยท

6 min read

In this article, I will show you how to deploy a static website using S3, CloudFront. As a bonus, I will also guide you on how to set it up to serve the site through your domain. We are going to use Terraform, an infrastructure as code (IAC) tool to deploy these resources in AWS. Terraform helps in provisioning, modifying, and deleting of resources in the cloud.

Before we begin, we need to have a domain added in AWS Route 53, Terraform installed and an AWS profile set up in your machine.

S3 bucket initialization

The code below creates a S3 bucket with the name declared in the variable domain_name.

resource "aws_s3_bucket" "web_app_bucket" {
  bucket = var.domain_name

  tags = {
    Name        = "s3 bucket for ${var.domain_name}"
  }
  force_destroy = true
}

The block below will ensure that we block all public access to the S3 bucket.

# Block all public access
resource "aws_s3_bucket_public_access_block" "s3public" {
  bucket = aws_s3_bucket.web_app_bucket.id

  block_public_acls       = false
  block_public_policy     = false
  ignore_public_acls      = false
  restrict_public_buckets = false
}

Enable static website hosting for the S3 bucket.

# Static website configuration
resource "aws_s3_bucket_website_configuration" "web_app_bucket_config" {
  bucket = aws_s3_bucket.web_app_bucket.id

  index_document {
    suffix = "index.html"
  }

  error_document {
    key = "index.html"
  }
}

Create a CloudFront distribution

Create an Origin Access Control (OAC) that secures the S3 bucket and makes it accessible only through the CloudFront distribution.

# Origin Access Control resource
resource "aws_cloudfront_origin_access_control" "static_app_policy" {
  name                              = "static app 2 OAC"
  description                       = "static app 2 OAC policy"
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
}

Create a default cache policy. The code below allows all cookies and query strings to pass through while restricting any headers. Modify these values according to your needs.

# Default cache policy for Cloudfront
resource "aws_cloudfront_cache_policy" "static_app_default_cache_policy" {
  name        = "staticapp2-cache-policy"
  comment     = "staticapp2-cache-policy"
  default_ttl = 50
  max_ttl     = 100
  min_ttl     = 1

  parameters_in_cache_key_and_forwarded_to_origin {
    cookies_config {
      cookie_behavior = "all"
    }

    headers_config {
      header_behavior = "none"
    }

    query_strings_config {
      query_string_behavior = "all"
    }
    enable_accept_encoding_brotli = true
    enable_accept_encoding_gzip =  true
  }
}

Create the CloudFront distribution. We will associate the previously created OAC and default cache policy with the distribution.

Note: We will be updating the code below when we reach the SSL cert section.

#  Cloudfront Distribution for S3
resource "aws_cloudfront_distribution" "static_app_cf" {
  origin {
    domain_name = aws_s3_bucket.web_app_bucket.bucket_regional_domain_name
    origin_id   = var.cf_s3_origin_id
    origin_access_control_id = aws_cloudfront_origin_access_control.static_app_policy.id
  }

  enabled             = true
  is_ipv6_enabled     = true
  comment             = var.domain_name
  default_root_object = "index.html"

  default_cache_behavior {
    allowed_methods  = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = var.cf_s3_origin_id
    cache_policy_id = aws_cloudfront_cache_policy.static_app_default_cache_policy.id

    viewer_protocol_policy = "redirect-to-https"
  }

  price_class = "PriceClass_All"

  restrictions {
    geo_restriction {
        restriction_type = "none"
        locations = []
    }
  }

  viewer_certificate {
    cloudfront_default_certificate = true
  }
}

Create the S3 bucket policy

Create a S3 bucket policy so the files hosted in S3 is accessible only to the CloudFront distribution. First, we generate the IAM policy and then associate that policy with the s3 bucket created in the previous steps. The policy allows read access to all the objects in the bucket only to the CloudFront distribution.

The condition block ensures that the files in S3 are accessible only if the requestor's source ARN (amazon resource name) matches the ARN of the CloudFront distribution.

# Bucket policy for S3 bucket, only allow from cloudfront distribution
data "aws_iam_policy_document" "web_app_s3_bucket_policy" {
  statement {
    effect = "Allow"
    sid = "CloudFrontAllowRead"
    actions   = ["s3:GetObject"]
    resources = ["${aws_s3_bucket.web_app_bucket.arn}/*"]

    principals {
      type        = "Service"
      identifiers = [ "cloudfront.amazonaws.com"]
    }
    condition {
      test = "ArnEquals"
      values = [ aws_cloudfront_distribution.static_app_cf.arn ]
      variable = "aws:SourceArn"
    }
  }
}

# Associate bucket policy to S3 bucket
resource "aws_s3_bucket_policy" "web_app_policy" {
  bucket = aws_s3_bucket.web_app_bucket.id
  policy = data.aws_iam_policy_document.web_app_s3_bucket_policy.json
}

Create a SSL certificate for our domain name

The aws_acm_certificate block issues a new SSL cert with our domain name. It will be created by AWS Certificate Manager (ACM).

The aws_acm_certificate_validation ensures that the SSL certificate created by ACM is validated. This resource provides a way to automate the validation process. It is used in conjunction with the aws_acm_certificate resource, which represents the ACM certificate request itself. To use this resource block, you specify the aws_acm_certificate resource's ARN (Amazon Resource Name) as an argument. Terraform then waits for the certificate request to be validated by polling the ACM service. Once the certificate is validated, Terraform completes the provisioning process.

The aws_route53_record will insert a DNS entry in the Route 53 hosted zone of our domain. The hosted zone id is passed in as a variable. This record is used to validate that we own the domain for which we are issuing the certificate.

# ACM cert for the domain name validation
resource "aws_acm_certificate" "domain_cert" {
  provider = aws.us_east_1 
  domain_name       = var.domain_name
  validation_method = "DNS"

  lifecycle {
    create_before_destroy = true
  }
}

# validating the certs
resource "aws_acm_certificate_validation" "domain_cert_validation" {
  provider = aws.us_east_1 
  certificate_arn         = aws_acm_certificate.domain_cert.arn
  validation_record_fqdns = [for record in aws_route53_record.validation : record.fqdn]
}

# record for domain name validation
resource "aws_route53_record" "validation" {

  for_each = {
    for dvo in aws_acm_certificate.domain_cert.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         = var.hosted_zone_id

}

Modify the CloudFront creation block to associate the SSL cert.

The aliases will have the domain name.

The viewer_certificate block is where we associate the SSL cert.

We will also add an explicit depends_on array so that the CloudFront distribution waits for the SSL cert to be issued and validated.

#  Cloudfront Distribution for S3
resource "aws_cloudfront_distribution" "static_app_cf" {
  origin {
    domain_name = aws_s3_bucket.web_app_bucket.bucket_regional_domain_name
    origin_id   = var.cf_s3_origin_id
    origin_access_control_id = aws_cloudfront_origin_access_control.static_app_policy.id
  }

  ####### added 
  aliases = [ var.domain_name ]

  enabled             = true
  is_ipv6_enabled     = true
  comment             = var.domain_name
  default_root_object = "index.html"

  default_cache_behavior {
    allowed_methods  = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = var.cf_s3_origin_id
    cache_policy_id = aws_cloudfront_cache_policy.static_app_default_cache_policy.id

    viewer_protocol_policy = "redirect-to-https"
  }

  price_class = "PriceClass_All"

  restrictions {
    geo_restriction {
        restriction_type = "none"
        locations = []
    }
  }

  ####### modified
  viewer_certificate {
    acm_certificate_arn = aws_acm_certificate.domain_cert.arn
    ssl_support_method = "sni-only"
    minimum_protocol_version = "TLSv1.2_2021"
  }

  ####### added 
  depends_on = [ aws_acm_certificate.domain_cert, aws_route53_record.validation ]
}

Create a DNS record to serve the website through your domain

The route 53 record resource will insert a DNS A record as an alias which point to the CloudFront distribution.

# Route 53 record for domain
resource "aws_route53_record" "staticapp" {
    zone_id = var.hosted_zone_id
    name    = var.domain_name
    type    = "A"
    allow_overwrite = true

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

Deploy the project in AWS using Terraform

Initialize the terraform.

terraform init

Validate terraform.

terraform validate

Execute terraform plan command. This command creates an execution plan, which lets you preview the changes that Terraform plans to make.

terraform plan

Execute terraform. When running the command below, you should provide all the required variable values in the terraform.tfvars file.

terraform apply

Navigate to the newly created S3 bucket in AWS and upload all the static website files. In my case, I have uploaded a simple index.html file.

After uploading the file(s), you should be able to see your website through the domain URL. If you visit the Network tab in the Developer Tools, you should see a Hit from cloudfront value in the X-cache header. This means that your website was served by CloudFront cache.

Github repo

The final code of this article can be found at https://github.com/this-santhoshss/static-site-terraform-aws

ย