Skip to content

Instantly share code, notes, and snippets.

@nicolasdao
Last active August 1, 2021 12:05
Show Gist options
  • Save nicolasdao/d90015ff90aae77c0a599621f5a8f432 to your computer and use it in GitHub Desktop.
Save nicolasdao/d90015ff90aae77c0a599621f5a8f432 to your computer and use it in GitHub Desktop.
Terraform recipes. Keywords: terraform recipes

TERRAFORM RECIPES

Terraform guide: https://gist.github.com/nicolasdao/53387b44bf74f91d379ebdb52cfdc125

Table of contents

AWS

Lambda

Simplest lambda

locals {
  lambda_pkg = "lambda/index.zip" 
}

resource "aws_iam_role" "iam_for_lambda" {
  name = "iam_for_lambda"

  assume_role_policy = <<-EOF
  {
    "Version": "2012-10-17",
    "Statement": [
      {
        "Action": "sts:AssumeRole",
        "Principal": {
          "Service": "lambda.amazonaws.com"
        },
        "Effect": "Allow",
        "Sid": ""
      }
    ]
  }
  EOF
}

resource "aws_lambda_function" "useragent_routing" {
  filename         = local.lambda_pkg
  function_name    = "useragent_routing"
  role             = aws_iam_role.iam_for_lambda.arn
  handler          = "index.handler"
  source_code_hash = filebase64sha256(local.lambda_pkg)
  runtime          = "nodejs14.x"
}

Enable CloudWatch logging

Add the following two new resources:

resource "aws_iam_policy" "lambda_logging" {
  name        = "lambda_logging"
  path        = "/"
  description = "IAM policy for logging from a lambda"
  policy = <<-EOF
  {
    "Version": "2012-10-17",
    "Statement": [
      {
        "Action": [
          "logs:CreateLogGroup",
          "logs:CreateLogStream",
          "logs:PutLogEvents"
        ],
        "Resource": "arn:aws:logs:*:*:*",
        "Effect": "Allow"
      }
    ]
  }
  EOF
}

resource "aws_iam_role_policy_attachment" "lambda_logs" {
  role       = aws_iam_role.iam_for_lambda.name
  policy_arn = aws_iam_policy.lambda_logging.arn
}

Then update the aws_lambda_function as follow:

resource "aws_lambda_function" "useragent_routing" {
  ...
  depends_on = [aws_iam_role_policy_attachment.lambda_logs]
}

Static website

Simplest setup with CloudFront

This is the smallest configuration to provision:

  • A public S3 bucket configured as a static website (it would work even without CloudFront).
  • A CloudFront distribution.

INFO: All the properties below are required.

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 3.0"
    }
  }
}

# Configure the default AWS Provider
provider "aws" {
  allowed_account_ids = ["196799624576"]
  region = "ap-southeast-2"
}

locals {
  domain = "cloudlesslabs.com"
}

# Create a public bucket to host static website.
# Doc: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket#static-website-hosting
resource "aws_s3_bucket" "website_bucket" {
  bucket = local.domain
  acl    = "public-read"
  policy = <<-POLICY
  {
    "Version": "2012-10-17",
    "Statement": [
      {
        "Sid": "PublicReadGetObject",
        "Effect": "Allow",
        "Principal": "*",
        "Action": "s3:GetObject",
        "Resource": "arn:aws:s3:::${local.domain}/*"
      }
    ]
  }
  POLICY

  website {
    index_document = "index.html"
    error_document = "error.html"
  }

  versioning {
    enabled = true
  }
}

# Creates a CloudFront distribution for the static website.
# Doc: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudfront_distribution#restrictions-arguments
resource "aws_cloudfront_distribution" "s3_distribution" {
  origin {
    domain_name = aws_s3_bucket.website_bucket.bucket_regional_domain_name
    origin_id   = local.domain # You can add whatever you want here. The domain makes sense.
  }
  enabled             = true
  is_ipv6_enabled     = true
  default_root_object = "index.html"
  default_cache_behavior {
    allowed_methods        = ["GET", "HEAD"]
    cached_methods         = ["GET", "HEAD"]
    target_origin_id       = local.domain
    forwarded_values {
      query_string = false
      cookies {
        forward    = "none"
      }
    }
    viewer_protocol_policy = "redirect-to-https"
    min_ttl                = 0
    default_ttl            = 3600
    max_ttl                = 86400
  }
  viewer_certificate {
    cloudfront_default_certificate = true
  }
  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }
}

CloudFront with custom domain

Depending on the situation, adding a custom domain may not be a single step process (i.e., the terraform apply command may have to be run more than once). This happens when the authoritative DNS is not AWS Route 53. In that case, you are required to manually verify the domain ownership before linking it to CloudFront. Three steps are involved:

  1. Create an SSL cert with Terraform and validate the domain manually.
  2. Add that SSL cert and the custom domain to the CloudFront distribution via Terraform.
  3. Manually add a CNAME from www (or whatever subdomain set up in step 1) to the CloudFront distribution URL (e.g., 12345567.cloudfront.net) in the authoritative DNS.

This section focuses on the use case where the authoritative DNS is not AWS Route 53. If that was the case, it would be possible to fully automate this process as we could interact with AWS Route 53 via Terraform to validate the DNS challenge (with Terrafform's resource aws_acm_certificate_validation) and configure the CNAME records.

In practice, we need to:

Create new provider in us-east-1

# Configure the AWS Provider for ACM (ACM only works for CloudFront in us-east-1)
provider "aws" {
  alias               = "acm"
  allowed_account_ids = ["196799624576"]
  region              = "us-east-1"
}

Create new SSL cert

resource "aws_acm_certificate" "cert" {
  provider                  = aws.acm
  domain_name               = local.domain
  subject_alternative_names = ["*.${local.domain}"]
  validation_method         = "DNS"

  # This is important, otherwise future update will delete the cert first leaving the 
  # domain certifcateless. With this option, a new cert is created, then linked to the domain
  # then the old domain is destroyed.
  lifecycle {
    create_before_destroy = true
  }
}

output "dns_challenge" {
  value       = aws_acm_certificate.cert.domain_validation_options
  description = "DNS challenge to verify the domain ownership."
}

Run:

terraform apply

When that's done, use the challenge details to verify the domain ownership. Wait until the domain is verified (this can take a few hours).

Update the authoritative DNS

  1. Use the DNS challenge details from the step above to create new DNS records in the authoritative DNS.
  2. Create a new CNAME record between www (or whatever subdomain set up in the step above) and the CloudFront distribution URL (e.g., 12345567.cloudfront.net).
  3. If you're using AWS Route 53, then create a new A record between the apex domain and the CloudFront distribution URL (e.g., 12345567.cloudfront.net). This will also allow the apex domain to access the static website (more details about this setup in the annex section Configuring custom domain on any CloudFront distribution).

Steps 2 and 3 are not related to verifying the domain ownership, but we'll need them later.

Associate the SSL cert and custom domain to the CloudFront resource

# Creates a CloudFront distribution for the static website.
# Doc: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudfront_distribution#restrictions-arguments
resource "aws_cloudfront_distribution" "s3_distribution" {
  origin {
    domain_name = aws_s3_bucket.website_bucket.bucket_regional_domain_name
    origin_id   = local.domain # You can add whatever you want here. The domain makes sense.
  }
  enabled             = true
  is_ipv6_enabled     = true
  default_root_object = "index.html"
  default_cache_behavior {
    allowed_methods        = ["GET", "HEAD"]
    cached_methods         = ["GET", "HEAD"]
    target_origin_id       = local.domain
    forwarded_values {
      query_string = false
      cookies {
        forward    = "none"
      }
    }
    viewer_protocol_policy = "redirect-to-https"
    min_ttl                = 0
    default_ttl            = 3600
    max_ttl                = 86400
  }
  # Add CNAME custom domain
  aliases = ["www.${local.domain}", local.domain]
  viewer_certificate {
    # cloudfront_default_certificate = true
    acm_certificate_arn = "${aws_acm_certificate.cert.arn}"
    ssl_support_method  = "sni-only"
  }
  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }
  depends_on = [
  	aws_acm_certificate.cert
  ]
}

Notice that we needed aliases property to be set to the number of domain/subdomain we wish to configure on the CloudFront distro. Without this, CloudFront will block traffic coming from those domains.

Run:

terraform apply

This may take up to 4 or 5 minutes.

When that's done, your new domain should be setup as the authoritative DNS was already configured in item 2 of the Update the authoritative DNS step.

CloudFront with compression

Compression is set up on a per cache behavior basis via a cache policy. If you have more than one cache behavior that requires compression, than the cache policy with the compression config must be attached to all of them. This setup requires 2 steps:

  1. Creating a cache policy with brotli config
  2. Attach the cache policy to the CloufFront distribution

Creating a cache policy with brotli config

# Create a cache policy to compress using brotli and gzip (fallback)
# Doc: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudfront_cache_policy#enable_accept_encoding_gzip
resource "aws_cloudfront_cache_policy" "brotli" {
  name                   = "brotli-compression"
  min_ttl                = 0 
  default_ttl            = 3600
  max_ttl                = 86400
  parameters_in_cache_key_and_forwarded_to_origin {
    cookies_config {
      cookie_behavior       = "none"
    }
    headers_config {
      header_behavior       = "none"
    }
    query_strings_config {
      query_string_behavior = "none"
    }
    enable_accept_encoding_brotli = true
    enable_accept_encoding_gzip   = true
  }
}

Attach the cache policy to the CloufFront distribution

  • Delete the forwarded_values property in the previous default_cache_behavior as it cannot be set when the cache_policy_id is set.
  • Add the cache_policy_id and compress properties as follow:
resource "aws_cloudfront_distribution" "s3_distribution" {
  ...
  default_cache_behavior {
    compress        = true 
    cache_policy_id = aws_cloudfront_cache_policy.brotli.id
  }
  ...
}

CloudFront with Lambda@Edge and custom cache-key

In this example, we'll extend the example above so that a Lambda#Edge is executed when the cache is missed. That Lambda will check is the request is from a desktop or mobile device and will serve the content accordingly. Once the content is cached, that lambda is not executed until the cache expires or flushed (for more details about that type of setup, please refer to this document about CloudFront Edge Compute.

The following sample will provision the following resources:

Lambda@Edge configured to log to CloudWatch and routing based on the request user-agent

There are 2 steps in this section:

  1. Create an index.js Lambda and zip it
  2. Provision a new Lambda in us-east-1
Create an index.js Lambda and zip it

Let's first create a Lambda function that can serve different content based on the user-agent header (in our case, mobile vs desktop). Because of the high cardinality of the user-agent, AWS CloudFront exposes the following 4 custom headers:

  • cloudfront-is-desktop-viewer
  • cloudfront-is-mobile-viewer
  • cloudfront-is-tablet-viewer
  • cloudfront-is-smarttv-viewer

By default, those headers are undefined. We'll toggle them in the Header configuration on the cache behavior section.

index.js

/**
 * Appends the 'rootFile' if 'pathname' is a folder.
 * 
 * @param  {String} pathname  Anything that comes after the domain, excl. query string and search params.
 * @param  {String} rootFile  e.g., 'index.html'
 * @return {Boolean}      
 */
const appendsRootFile = (pathname, rootFile='index.html') => {
  if (!pathname)
    return `/${rootFile}`
  if (/\/$/.test(pathname))
    return `${pathname}${rootFile}`
  const paths = pathname.split('/')
  const lastEl = paths[paths.length-1]
  if (!lastEl)
    return `${pathname}/${rootFile}` 

  return /\./.test(lastEl) ? pathname : `${pathname}/${rootFile}`
}

/* This is an origin request function */
exports.handler = (event, context, callback) => {
  const request = event.Records[0].cf.request
  const headers = request.headers

  /*
   * Serve different versions of an object based on the device type.
   * NOTE: 1. You must configure your distribution to cache based on the
   *      CloudFront-Is-*-Viewer headers. For more information, see
   *      the following documentation:
   *      http://docs.aws.amazon.com/console/cloudfront/cache-on-selected-headers
   *      http://docs.aws.amazon.com/console/cloudfront/cache-on-device-type
   *     2. CloudFront adds the CloudFront-Is-*-Viewer headers after the viewer
   *      request event. To use this example, you must create a trigger for the
   *      origin request event.
   */

  const originalUri = request.uri

  request.uri = appendsRootFile(request.uri)

  const rootFileUri = request.uri

  const desktopPath = '/desktop'
  const mobilePath = '/mobile'
  const tabletPath = '/desktop'
  const smarttvPath = '/desktop'

  if (headers['cloudfront-is-desktop-viewer'] && headers['cloudfront-is-desktop-viewer'][0].value === 'true')
    request.uri = desktopPath + request.uri
  else if (headers['cloudfront-is-mobile-viewer'] && headers['cloudfront-is-mobile-viewer'][0].value === 'true')
    request.uri = mobilePath + request.uri
  else if (headers['cloudfront-is-tablet-viewer'] && headers['cloudfront-is-tablet-viewer'][0].value === 'true')
    request.uri = tabletPath + request.uri
  else if (headers['cloudfront-is-smarttv-viewer'] && headers['cloudfront-is-smarttv-viewer'][0].value === 'true')
    request.uri = smarttvPath + request.uri
  else
    request.uri = desktopPath + request.uri

  const finalUri = request.uri

  console.log({
    isMobile: headers['cloudfront-is-mobile-viewer'],
    originalUri,
    rootFileUri,
    finalUri
  })

  callback(null, request)
}

Then zip this file into a index.zip in the root folder.

Provision a new Lambda in us-east-1

Lambda#Edge are not explicitly deployed. In reality, a standard Lambda is provisioned in us-east-1 (this is important). When an association is created between that Lambda and a CloudFront distribution, AWS uses copies that Lambda and deploys it in each region close to the edge locations (we'll do this in the Lambda association between the CloudFront distribution and the Lambda@Edge step).

locals {
  lambda_pkg = "index.zip" 
}

# Configures the CloudWatch log
resource "aws_iam_policy" "lambda_logging" {
  name        = "lambda_logging"
  path        = "/"
  description = "IAM policy for logging from a lambda"
  policy = <<-EOF
  {
    "Version": "2012-10-17",
    "Statement": [
      {
        "Action": [
          "logs:CreateLogGroup",
          "logs:CreateLogStream",
          "logs:PutLogEvents"
        ],
        "Resource": "arn:aws:logs:*:*:*",
        "Effect": "Allow"
      }
    ]
  }
  EOF
}

# Configures the IAM role for the Lambda@Edge
resource "aws_iam_role" "iam_for_lambda" {
  name = "iam_for_lambda"
  assume_role_policy = <<-EOF
  {
    "Version": "2012-10-17",
    "Statement": [
      {
        "Action": "sts:AssumeRole",
        "Principal": {
          "Service": [
            "lambda.amazonaws.com",
            "edgelambda.amazonaws.com"
          ]
        },
        "Effect": "Allow",
        "Sid": ""
      }
    ]
  }
  EOF
}

# Attches the CloudWatch policy to the Lambda role
resource "aws_iam_role_policy_attachment" "lambda_logs" {
  role       = aws_iam_role.iam_for_lambda.name
  policy_arn = aws_iam_policy.lambda_logging.arn
}

# Creates the Lambda@Edge
resource "aws_lambda_function" "useragent_routing" {
  provider         = aws.acm # IMPORTANT! - Use the AWS provider for us-east-1. That's the only region that supports Lambda@Edge
  filename         = local.lambda_pkg
  function_name    = "useragent_routing"
  role             = aws_iam_role.iam_for_lambda.arn
  handler          = "index.handler"
  source_code_hash = filebase64sha256(local.lambda_pkg)
  runtime          = "nodejs14.x"
  publish          = true
  depends_on       = [aws_iam_role_policy_attachment.lambda_logs]
}

NOTES:

Header configuration on the cache behavior

In this section, we configure how to use specific HTTP headers to build the cache-key. The following configuration will:

  • Include the CloudFront-Is-Mobile-Viewer(1) custom header in the cache-key (more about those custom device type headers here).
  • Expose that value in the Lambda@Edge (otherwise, its value is undefined).

Update the cache behavior from:

resource "aws_cloudfront_cache_policy" "brotli" {
  ...
  parameters_in_cache_key_and_forwarded_to_origin {
    ...
    headers_config {
      header_behavior = "none"
    }
    ...
  }
}

to

resource "aws_cloudfront_cache_policy" "brotli" {
  ...
  parameters_in_cache_key_and_forwarded_to_origin {
    ...
    headers_config {
      header_behavior = "whitelist"
      headers {
        items         = ["CloudFront-Is-Mobile-Viewer"] # Include all the headers you wish should be part of the cache-key (2)
      }
    }
    ...
  }
}

(1) A tablet is also considered a mobile type. If you need to diffrentiate between mobile and table, include the CloudFront-Is-Tablet-Viewer custom header. (2) Beware that more headers results in a lower cache hit ratio. This means your origin will receive more traffic.

Lambda association between the CloudFront distribution and the Lambda@Edge

Creating an association between the Lambda and CloudFront is what converts the standard Lambda into a Lambda@Edge. The following configuration uses an origin-request event type. To learn more about the other three event types, please refer to the Edge compute document.

resource "aws_cloudfront_distribution" "s3_distribution" {
  ...
  default_cache_behavior {
    ...
    lambda_function_association {
      event_type   = "origin-request"
      lambda_arn   = aws_lambda_function.useragent_routing.qualified_arn
      include_body = false
    }
  }
  ...
  depends_on = [
    aws_acm_certificate.cert,
    aws_lambda_function.useragent_routing
  ]
}

Troubleshooting

AWS CloudFront is not compressing my content

Check that:

InvalidLambdaFunctionAssociation: The function ARN must reference a specific function version

This happens when a resource (e.g., CloudFront) must be associated with a Lambda via its arn. For example, adding a Lambda@edge to CloudFront:

resource "aws_cloudfront_distribution" "s3_distribution" {
  ...
  default_cache_behavior {
    ...
    lambda_function_association {
      event_type   = "viewer-request"
      lambda_arn   = aws_lambda_function.useragent_routing.qualified_arn
      include_body = false
    }
  }
  ...
}

This error occurs when aws_lambda_function.useragent_routing.qualified_arn return something similar to: arn:aws:lambda:us-east-1:196799624576:function:useragent_routing:$LATEST.

The $LATEST is causing the issue. What would fix this issue is have an explicit version number instead of $LATEST. To achieve this, redeploy the Lambda (in our case, aws_lambda_function.useragent_routing) with the publish property set to true. Example:

resource "aws_lambda_function" "useragent_routing" {
  ...
  publish = true
}

Annexes

AWS Route 53

Alias on the apex domain does not cover all scenarios

Please refer to this document: https://gist.github.com/nicolasdao/e8face1a5483111a2a29d17aaed79199#alias-on-the-apex-domain-does-not-cover-all-scenarios

Configuring custom domain on any CloudFront distribution

Please refer to this document: https://gist.github.com/nicolasdao/e8face1a5483111a2a29d17aaed79199#how-to-configure-a-custom-domain-on-any-cloudfront-distribution

GoDaddy

Forwarding root domain to www

There is no way to point an apex record to a subdomain with GoDaddy. However, GoDaddy supports forwarding (i.e., 301 or 302 redirect). This options is under the DNS records table.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment