CloudFront, ACM, and Route 53

In this lecture we're going to create a public certificate with AWS ACM, use it to distribute our static website using AWS CloudFront, and assign a domain name to our website using AWS Route53.

NOTE: You need a valid domain to follow along which will cost you. Plus, Route53 is not covered under the Free Tier and you'll have to pay for it as well (USD 1).

First an overview of the services we'll be using:

CloudFront

Amazon CloudFront is a web service that speeds up distribution of your static and dynamic web content, such as .html, .css, .js, and image files, to your users. CloudFront delivers your content through a worldwide network of data centers called edge locations. When a user requests content that you're serving with CloudFront, the request is routed to the edge location that provides the lowest latency (time delay), so that content is delivered with the best possible performance. (more here)

ACM

AWS Certificate Manager (ACM) is a service that lets you easily provision, manage, and deploy public and private Secure Sockets Layer/Transport Layer Security (SSL/TLS) certificates for use with AWS services and your internal connected resources. SSL/TLS certificates are used to secure network communications and establish the identity of websites over the Internet as well as resources on private networks. AWS Certificate Manager removes the time-consuming manual process of purchasing, uploading, and renewing SSL/TLS certificates.

With AWS Certificate Manager, you can quickly request a certificate, deploy it on ACM-integrated AWS resources, such as Elastic Load Balancing, Amazon CloudFront distributions, and APIs on Amazon API Gateway, and let AWS Certificate Manager handle certificate renewals. It also enables you to create private certificates for your internal resources and manage the certificate lifecycle centrally. Public and private certificates provisioned through AWS Certificate Manager for use with ACM-integrated services are free. You pay only for the AWS resources you create to run your application. (source)

Route53

Amazon Route 53 is a highly available and scalable Domain Name System (DNS) web service. You can use Route 53 to perform three main functions in any combination: domain registration, DNS routing, and health checking. (more here)

Demo

We're going to first manually do this in the class, and then via Terraform and CodeBuild.

Using Terraform

We need to first change the module we created in the previous lecture a little bit. We need to have a new output (S3 domain name), and also add a new policy so CodeBuild can create a CloudFront distribution.

modules/static_website/outputs.tf

output "bucket_regional_domain_name" {
  value = aws_s3_bucket.site_bucket.bucket_regional_domain_name
}

modules/static_website/main.tf

# attach an IAM policy to CodeBuild role
# for accessing the S3 bucket
resource "aws_iam_role_policy" "this" {
  role = aws_iam_role.cbuild_role.name

  policy = <<POLICY
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Resource": [
        "*"
      ],
      "Action": [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ]
    },
    {
      "Effect": "Allow",
      "Action": [
        "s3:*"
      ],
      "Resource": ["${aws_s3_bucket.site_bucket.arn}", "${aws_s3_bucket.site_bucket.arn}/*"]
    },
    {
      "Effect": "Allow",
      "Action": [
        "cloudfront:*"
      ],
      "Resource": ["*"]
    }
  ]
}
POLICY
}

For the root module:

main.tf

module "static_website_1" {
  source = "./modules/static_website"

  bucket_name  = var.bucket_name
  repo_address = var.repo_address
  project_name = var.project_name
}

# locals work pretty much like variables
# they're mainly used to store long expressions
# so we can avoid typing them over and over
locals {
  origin_id   = module.static_website_1.bucket_regional_domain_name
  domain_name = var.domain_name
}

# create a CloudFront distribution
resource "aws_cloudfront_distribution" "s3_distribution" {
  origin {
    domain_name = module.static_website_1.bucket_regional_domain_name
    # this id can be anything
    origin_id   = local.origin_id
  }

  enabled = true

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  default_cache_behavior {
    allowed_methods  = ["GET", "HEAD"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = local.origin_id

    forwarded_values {
      query_string = false

      cookies {
        forward = "none"
      }
    }

    # redirect everything to HTTPS
    viewer_protocol_policy = "redirect-to-https"
    min_ttl                = 1
    default_ttl            = 86400
    max_ttl                = 31536000
    compress               = true
  }

  # the least expensive class
  price_class = "PriceClass_100"

  aliases = [local.domain_name]

  # default entry page of the website
  default_root_object = "index.html"

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

# request an ACM certificate
# note that the certificate needs to be in
# the `us-east-1` region for CloudFront to fetch it
resource "aws_acm_certificate" "cert" {
  domain_name               = local.domain_name
  validation_method         = "DNS"
  subject_alternative_names = ["*.${local.domain_name}"]

  # note the use of provider here 
  # that uses an alias to point to another region
  provider = aws.us_east_1
}

# get the hosted zone associated with our domain
data "aws_route53_zone" "my_zone" {
  name         = local.domain_name
  private_zone = false
}

# add DNS records for certificate validation
resource "aws_route53_record" "alias_record" {
  for_each = {
    for dvo in aws_acm_certificate.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         = data.aws_route53_zone.my_zone.zone_id
}

# validate the certificate using DNS records
# validation needs to be in the same region as
# the certificate
resource "aws_acm_certificate_validation" "acm_validation" {
  certificate_arn         = aws_acm_certificate.cert.arn
  validation_record_fqdns = [for record in aws_route53_record.alias_record : record.fqdn]

  # note the use of provider here 
  # that uses an alias to point to another region
  provider = aws.us_east_1
}

# create an alias for our CloudFront distribution
# alias records can map a domain to an AWS service
# they're also free of charge
resource "aws_route53_record" "www" {
  zone_id = data.aws_route53_zone.my_zone.zone_id
  name    = local.domain_name
  type    = "A"

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

variables.tf

variable "bucket_name" {
  type = string
}

variable "project_name" {
  type = string
}

variable "repo_address" {
  type = string
}

variable "domain_name" {
  type = string
}

outputs.tf

# we need the distribution id for invalidations
output "distribution_id" {
  value = aws_cloudfront_distribution.s3_distribution.id
}

versions.tf

terraform {
  required_providers {
    aws = {
      source = "hashicorp/aws"
    }
  }
}

provider "aws" {
  region = "ca-central-1"
}

# using the same provider for a different region
# using `alias`, we can have multiple configuration
# of the same provider
provider "aws" {
  region = "us-east-1"
  alias  = "us_east_1"
}

buildspec File

Finally, we need to add an invalidation command to our buildspec.yml so every change invalidates the distribution. This will make sure that users always see the latest version.

buildspec.yml

version: 0.2
phases:
  build:
    commands:
      - aws s3 sync . $BUCKET_NAME --exclude ".git/*"
      - aws cloudfront create-invalidation --distribution-id <DIST_ID> --paths "/*"
  post_build:
    commands:
      - echo Success!