How to return a static file with a CloudFront function

Storing files on the edge

Author's image
Tamás Sallai
3 mins
Photo by Karolina Grabowska: https://www.pexels.com/photo/cardboard-box-on-dark-wooden-table-near-tape-and-scissors-4498152/

Environment information

Sometimes I need to make a small static file available via a web URL. For example, to communicate pieces of information about the environment to clients, such as the Cognito login URL they need to use, I make a /config.mjs or similar with that information. The client then knows where to find this information and can fetch it when it needs to.

To implement this, I usually opt for an S3 origin with a single file deployed with Terraform, as described in the linked article. While this setup works well, I started thinking whether I could solve the same problem with fewer resources.

CloudFront supports running arbitrary code for client requests and they can also return a static response without going to the origin. This makes a suitable solution for this problem: since the function code is generated by Terraform it can include not just static data but also information about resources in the stack. This makes this solution a valid alternative to communicate environment data to clients.

Implementation

Let’s break down the different parts!

First, write the function:

resource "aws_cloudfront_function" "static_file" {
	name    = "static_file-${random_id.id.hex}"
	runtime = "cloudfront-js-1.0"
	code    = <<EOF
function handler(event) {
	return {
		statusCode: 200,
		statusDescription: "OK",
		body: {
			encoding: "base64",
			data: "${base64encode(trimspace(<<EOT
This is a response defined in Terraform.
It can even include dynamic values: ${aws_lambda_function.tester.arn}
EOT
			))}"
		}
	}
}
EOF
}

The function returns a status code (200), a description (“OK”), and a body. The body then is encoded in Base64 to avoid any escaping issues and the data uses Terraform’s base64encode function with the HEREDOC syntax.

The value defined here is returned to the clients as-is and Terraform can insert data that is available during the deployment. In the above example, it includes the ARN of a Lambda function, but it can equally be a Cognito Client ID, an invoke URL, or anything else the clients might need.

While there is no origin that CloudFront will contact, it is still a required element. To make sure there is an error in case it tries to contact it, the special .invalid domain can be used:

origin {
	domain_name              = "invalid.invalid"
	origin_id                = "invalid"
	custom_origin_config {
		http_port = 80
		https_port = 443
		origin_protocol_policy = "http-only"
		origin_ssl_protocols = ["TLSv1.2"]
	}
}

The cache behavior then defines the path pattern for the file and configures the function:

ordered_cache_behavior {
	path_pattern     = "/static_file"
	target_origin_id = "invalid"

	# ...

	function_association {
		event_type   = "viewer-request"
		function_arn = aws_cloudfront_function.static_file.arn
	}
}

Sending a request to /static_file then returns the contents defined in the function.

Disadvantages

While this is a more optimized solution compared to the S3 file-based one there are a few drawbacks.

First, it is easier to end up with a deployment cycle as the CloudFront distribution depends on the function. For example, if a Cognito Client ID is included in the returned data while the Cognito Client defines the CloudFront domain as an allowed callback URL then Terraform can’t deploy the stack. To deploy the function, it needs the Client ID. But to create the Cognito Client, it needs the CloudFront distribution. But the distribution needs the function before it can be created.

This is not a problem with the S3-based solution as the file can be added last when all the other resources are already created.

Next, it works only for small file. CloudFront functions have a limit of 10KB of function code, so anything that it returns must fit in that.

And finally, changing the function code is problematic. Terraform tries to destroy the old one first before creating the new one. This looks like a problem with the Terraform provider, so probably it will be fixed in the future.

17 October 2023
In this article