Terraform guide: https://gist.github.com/nicolasdao/53387b44bf74f91d379ebdb52cfdc125
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"
}
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]
}
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"
}
}
}
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:
- Create an SSL cert with Terraform and validate the domain manually.
- Add that SSL cert and the custom domain to the CloudFront distribution via Terraform.
- 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:
- Add a new AWS provider in the
us-east-1
region because SSL certs from ACM can only be associated to CloudFront from that region. - Add a new cert from ACM with a DNS challenge and output the DNS challenge details so we can verify the domain ownership.
- Update the authoritative DNS to verify the domain ownership and redirect the custom subdomain to the CloudFront distribution.
- Associate the SSL cert and custom domain to the CloudFront resource
# 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"
}
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).
- Use the DNS challenge details from the step above to create new DNS records in the authoritative DNS.
- Create a new
CNAME
record betweenwww
(or whatever subdomain set up in the step above) and the CloudFront distribution URL (e.g., 12345567.cloudfront.net). - 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.
# 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.
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:
# 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
}
}
- Delete the
forwarded_values
property in the previous default_cache_behavior as it cannot be set when thecache_policy_id
is set. - Add the
cache_policy_id
andcompress
properties as follow:
resource "aws_cloudfront_distribution" "s3_distribution" {
...
default_cache_behavior {
compress = true
cache_policy_id = aws_cloudfront_cache_policy.brotli.id
}
...
}
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
- Header configuration on the cache behavior
- Lambda association between the CloudFront distribution and the Lambda@Edge
There are 2 steps in this section:
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.
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:
- The
publish = true
is important to avoid the InvalidLambdaFunctionAssociation: The function ARN must reference a specific function version error.- To learn how to read the CloudWatch logs, please refer to the Debugging a Lambda@edge document.
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.
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
]
}
Check that:
- The cache behavior for your content contains a cache policy as described in CloudFront with compression.
- The cache behavior contains a
compress
set totrue
as described in Attach the cache policy to the CloufFront distribution
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
}
Please refer to this document: https://gist.github.com/nicolasdao/e8face1a5483111a2a29d17aaed79199#alias-on-the-apex-domain-does-not-cover-all-scenarios
Please refer to this document: https://gist.github.com/nicolasdao/e8face1a5483111a2a29d17aaed79199#how-to-configure-a-custom-domain-on-any-cloudfront-distribution
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.