Using CloudFront Functions to Serve Multiple Static React Websites
Created by: Karl Edward Y. Bayron 5min read
Jul 25, 2023
Our team had many simple static sites in React, and we wanted to serve them using a single CloudFront and S3 Bucket. We wanted each application to be separated by subdirectory (I.e., /app1, /app2, etc.) and we were able to implement it using CloudFront Functions.
Amazon made it easy for users to deploy their static website on the cloud. AWS (Amazon Web Services) S3 provides a built-in feature for you to host your website by just creating a bucket in S3 and then enabling the Static Website Hosting feature. However, for this to work, you’ll need to make your objects publicly accessible which will bring in a lot of security implications, especially if you’re deploying it to production. You also need to think about the pricing. S3 charges you based on how much you access the objects stored in the buckets.
Another problem with this solution is that you can only have 1 static website per bucket. You might be thinking that you can just create more buckets per website you’ll deploy however AWS S3 has a soft limit of 100 buckets per account. This would become a problem eventually once you deploy a lot of websites (or if other teams would use S3 for another purpose).
The Solution
Rather than serving the website directly from S3, we can use CloudFront, a CDN, to serve the files for us from S3. This would enable us to serve our website and assets from a private S3 Bucket. This solution will also help reduce the cost of accessing the objects in S3 since CloudFront will cache them, thus also increasing the performance of your website.
The Setup
To create a CloudFront that serves the website assets from our S3 bucket do the following:
- Put the S3 Bucket URL as the Origin Domain.
- Leave this blank as it’ll default into a wildcard (*).
- Choose “Origin Access Control”
- Create a control setting (default values should be good enough)
- Create your CloudFront Distribution
After the distribution has been created, we need to copy the policy statement from the Origin to our S3 bucket policy.
- Go to the Origins tab.
- Choose your S3 origin
- Click on the edit button
- Click “Copy Policy”
- Click “Go to S3 bucket permissions”
Once you’re in your S3 Bucket’s Permission Tab, go to the “Bucket policy” section and press the Edit button.
Then, paste the policy you just copied in the text editor. It should look similar to this. Then press “Save Changes”
Now if you try accessing your website through CloudFront, you should be able to see your site if everything has been deployed and done correctly.
However, you’ll notice that the URL on the last picture explicitly stated the index.html file. Having this behavior in effect, will not let you properly browse Single Page Application like React Apps. This is where our last component comes into play.
CloudFront Functions
With CloudFront Functions, we’ll be able to serve the index.html file wihout explicitly requesting it in the URL. So rather than requesting /demo1/index.html, we’ll be able to load the website by just requesting /demo1.
CloudFront Functions are short lived functions that allows you to modify the request or response sent or received by the user so for our use case, we’ll be able to redirect the request to serve the index.html file without having the user to explicitly state it in the request URL.
To create a CloudFront Function, go to the sidebar of CloudFront and look for “Functions”
Next. Create a function that contains this code snippet:
function handler(event) {
var request = event.request;
if(!request.uri.includes(".")) {
request.uri = request.uri.replace(/^\/([^\/]_)(\/|\\b)._/,"/$1/index.html");
}
return request;
}
The code above checks if the user is trying to fetch a specific file in S3, hence, we look at the URI if it includes a “.” (i.e., /demo1/maya_logo.png) and if not, we assume that the user wants to load the website, which means we’ll need to serve index.html.
The regex above just gets the first subdirectory and then inserts it into the string “<subdirectory>/index.html”
So, for example, we have our index.html and assets in a folder in S3 called “maya-app”. To access this website, we’ll just need to set the URL to <CloudFront-domain-origin-url>/maya-app for it to serve the index.html inside the maya-app folder in S3.
Now that we know what the function does, publish the CloudFront function and then we need to associate it to our S3 origin so that it runs when somebody tries to access our websites stored in S3.
Go back to our CloudFront Distribution and go to the Behaviors Tab. Click on your S3 Origin and then the Edit Button.
And then at the bottom, we need to associate the function we just published to the Viewer Request as shown below.
After that, let’s wait until CloudFront has finished deploying the changes.
Now when you try to access the CloudFront Distribution Domain Name directly, it should serve you the index.html without explicitly stating it in the URL.
Lastly, a great enhancement to your CloudFront Distribution is to set up default Error Pages to redirect to so you can avoid displaying S3 specific error messages.
However, this will require an index.html on the root of S3.
So, with the above setup, you’ll be able to deploy multiple static websites in a single private bucket in S3. The application only needs to be separated per folder within the S3 bucket similar to this:
So, to access the different apps, we just need to access <CloudFront-distribution-origin-url>/demo or <CloudFront-distribution-origin-url>/demo2.
Bonus:
For an easier setup, here are the terraform scripts that we use that you can copy to get the same setup:
resource "aws_cloudfront_distribution" "demo_apps" {
enabled = true
is_ipv6_enabled = true
default_root_object = "index.html"
restrictions {
geo_restriction {
restriction_type = "none"
}
}
viewer_certificate {
cloudfront_default_certificate = true
}
custom_error_response {
error_code = 404
response_code = 200
response_page_path = "/index.html"
}
custom_error_response {
error_code = 403
response_code = 200
response_page_path = "/index.html"
}
origin {
domain_name = aws_s3_bucket.demo_apps_bucket.bucket_regional_domain_name
origin_id = aws_s3_bucket.demo_apps_bucket.id
origin_access_control_id = aws_cloudfront_origin_access_control.assets_oac.id
connection_attempts = 3
connection_timeout = 10
}
default_cache_behavior {
target_origin_id = aws_s3_bucket.demo_apps_bucket.id
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD"]
viewer_protocol_policy = "redirect-to-https"
response_headers_policy_id = aws_cloudfront_response_headers_policy.dashboard_cloudfront_headers_policy.id
forwarded_values {
headers = ["Origin"]
query_string = false
query_string_cache_keys = []
cookies {
forward = "none"
whitelisted_names = []
}
}
function_association {
event_type = "viewer-request"
function_arn = aws_cloudfront_function.demo_apps_cloudfront_function.arn
}
min_ttl = 0
default_ttl = 360
max_ttl = 86400
}
}
resource "aws_cloudfront_origin_access_control" "assets_oac" {
name = "demo-apps-oac"
description = "demo-apps Origin Access Control"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
resource "aws_cloudfront_response_headers_policy" "dashboard_cloudfront_headers_policy" {
name = "demo-apps-cf-headers-policy"
security_headers_config {
strict_transport_security {
access_control_max_age_sec = 631138519
override = true
}
content_type_options {
override = true
}
}
}
resource "aws_cloudfront_function" "demo_apps_cloudfront_function" {
name = "demo-app-cf-redirect"
comment = "Serve index.html on GET request to appropriate URLs"
runtime = "cloudfront-js-1.0"
publish = true
code = file("${path.module}/demo-app-cf-redirect.js")
}
resource "aws_s3_bucket" "demo_apps_bucket" {
bucket = "demo-apps"
}
resource "aws_s3_bucket_acl" "demo_apps_bucket_acl" {
bucket = aws_s3_bucket.demo_apps_bucket.id
acl = "private"
}
resource "aws_s3_bucket_policy" "demo_apps_bucket_policy" {
bucket = aws_s3_bucket.demo_apps_bucket.id
policy = data.aws_iam_policy_document.demo_apps_bucket_policy.json
}
data "aws_iam_policy_document" "demo_apps_bucket_policy" {
statement {
sid = "AllowReadFromCloudFront"
actions = [
"s3:GetObject",
]
resources = [
"arn:aws:s3:::${aws_s3_bucket.demo_apps_bucket.id}/*",
]
principals {
type = "Service"
identifiers = ["cloudfront.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "AWS:SourceArn"
values = [aws_cloudfront_distribution.demo_apps.arn]
}
}
}