Over the years, I have used AWS Route 53, CloudFront, and S3 to deploy single-page web apps (SPA). Most of the time the backend API is delivered alongside the web app. This has a lot of advantages:
- Eliminates CORS issues
- Provides superfast delivery of the web app via CloudFront’s CDN
- CloudFront may provide faster access to your API than connecting from the browser/app to an AWS region. This is unintuitive, but if the CloudFront edge node is closer to your browser, there will be less latency. After that, you’ll take advantage of the larger pipes going from CloudFront to the AWS region - possibly with fewer hops. Without CloudFront, your browser API request must travel over the public internet to reach the AWS region where your API is hosted.
Older Approaches
In the earlier AWS days, you might have deployed a SPA directly to S3 as a website in a public S3 bucket. It is possible to point Route 53 or CloudFront directly to such a bucket. But now we have learned that buckets should be kept private. Also, if you point Route 53 directly to S3, you will need a different subdomain for your API.
You’ll also encounter other complications with a SPA, such as routing. You will need to load
physical assets such as html, css, js and image files from S3, but you want to allow routing for
other virtual paths such as https://example.com/contacts/edit/1
. With the S3 website approach, you
would normally need to set the root route for /
to point at index.html
, and then a 404 handler
to also load index.html
for virtual routes.
Later on, AWS introduced Lamda@Edge. For a while, I used Route 53 to CloudFront to a private S3
bucket. CloudFront would also front the backend API. I would write a Lambda@Edge function hooked
into CloudFront to perform URL rewriting for the virtual routes so that they would always
load index.html
. Lambda@Edge always seemed kind of heavy-weight because you were actually creating
a Lambda, but it ran on the CloudFront edge node. There were also unnecessary versioning
complications with Lambda@Edge.
New Approach
In May 2021, AWS introduced
CloudFront Functions. These functions are lighter-weight, look similar to Lambda@Edge, but are much simpler to
create, and they reside entirely in CloudFront. Unlike Lambda@Edge, these functions must only be
written in Javascript - and it isn’t running under Node.js. According to the
doc, the JS is “compliant with ECMAScript (ES) version 5.1 and also supports some features of ES
versions 6 through 9.”
You won’t find things like const
here. Fortunately, the amount of JS you’ll need to write is
minimal. You will only need to rewrite URL paths to load from index.html
for virtual routes.
In the end, here’s how it looks:
Sequences of events:
- The browser looks up your host name in Route 53, which returns a CloudFront (CF) hostname which will resolve to an IP address of a CF edge node near you.
- The browser sends a request to load your SPA - e.g., at
/
. - The CF edge node receives your request and calls the CF Function to rewrite the URL path from
/
to/index.html
. - The CF edge node passes the request to CF in the AWS region/AZ. Since the request does not start
with
/api/
, CloudFront requests/index.html
from the S3 bucket and sends it back up the line to the browser. - After loading
/index.html
, the browser will probably start to load a bunch of physical assets such as JS and images. - Your Flutter web app (SPA) starts to run in the browser and requests something from the API at path
/api/foo
. - The CF edge node receives the request, but because it is an
/api/
request, the CF Function doesn’t rewrite the URL. - The CF edge node passes the request to CF in the AWS region/AZ. Since the request start withs
/api/
, CF passes the request to your backend. Your backend could be running on Elastic Beanstalk (which runs on top of ECS), ELB/ECS, EC2, API Gateway, etc. - Your backend responds to the request, which is eventually passed back to the browser.
Terraform
The Terraform configuration below illustrates how to set up Route 53, CloudFront, the CF Function to rewrite the URL path, and S3. I’m deploying a Flutter web app in this example, but you could deploy any SPA.
Route 53
You don’t have to use Route 53 for DNS - you could use your favorite provider. However, the setup is less manual if you do everything on AWS.
The code below will also create a free certificate on AWS ACM, which will eventually be tied to CF.
Note that after the Route 53 zone is created, you will probably have to copy the generated NS records to your DNS provider.
var.domain_name
below is the domain name where you are hosting your app.
resource "aws_route53_zone" "primary" {
name = var.domain_name
}
resource "aws_route53_record" "root_alias" {
zone_id = aws_route53_zone.primary.zone_id
name = var.domain_name
type = "A"
alias {
name = module.cloudfront.cloudfront_distribution_domain_name
zone_id = module.cloudfront.cloudfront_distribution_hosted_zone_id
evaluate_target_health = true
}
}
resource "aws_route53_record" "www_cname" {
zone_id = aws_route53_zone.primary.zone_id
name = "www"
type = "CNAME"
ttl = 300
records = [module.cloudfront.cloudfront_distribution_domain_name]
}
resource "aws_route53_record" "acm_cert_validation_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 = aws_route53_zone.primary.zone_id
}
# AWS Certificate for app domain
resource "aws_acm_certificate" "cert" {
provider = aws.us_east_1
domain_name = var.domain_name
subject_alternative_names = ["*.${var.domain_name}"]
validation_method = "DNS"
lifecycle {
create_before_destroy = true
}
}
resource "aws_acm_certificate_validation" "cert" {
provider = aws.us_east_1
certificate_arn = aws_acm_certificate.cert.arn
validation_record_fqdns = [for record in aws_route53_record.acm_cert_validation_record : record.fqdn]
}
CloudFront
local.name_with_env
below refers to a prefix for your app and environment - for example myapp-prod
.
# Log bucket
resource "aws_s3_bucket" "cloudfront_log_bucket" {
bucket_prefix = "${local.name_with_env}-cf-logs-"
acl = "log-delivery-write"
}
# Web app bucket
module "web_app" {
source = "terraform-aws-modules/s3-bucket/aws"
version = "~> 2.0"
bucket_prefix = "${local.name_with_env}-web-app-"
force_destroy = true
}
# Origin Access Identities
data "aws_iam_policy_document" "s3_web_app_policy" {
statement {
actions = ["s3:GetObject"]
resources = ["${module.web_app.s3_bucket_arn}/*"]
principals {
type = "AWS"
identifiers = module.cloudfront.cloudfront_origin_access_identity_iam_arns
}
}
}
resource "aws_s3_bucket_policy" "bucket_policy" {
bucket = module.web_app.s3_bucket_id
policy = data.aws_iam_policy_document.s3_web_app_policy.json
}
# Request policy
resource "aws_cloudfront_origin_request_policy" "api_request_policy" {
name = "${local.name_with_env}-api-request-policy"
cookies_config {
cookie_behavior = "all"
}
headers_config {
header_behavior = "allViewer"
}
query_strings_config {
query_string_behavior = "all"
}
}
resource "aws_cloudfront_cache_policy" "api_cache_policy" {
name = "${local.name_with_env}-api-cache-policy"
default_ttl = 0
max_ttl = 0
min_ttl = 0
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"
}
}
}
module "cloudfront" {
source = "terraform-aws-modules/cloudfront/aws"
version = "2.9.2"
comment = "${local.name_with_env} distribution"
enabled = true
is_ipv6_enabled = true
price_class = "PriceClass_All"
retain_on_delete = false
wait_for_deployment = true
# These aliases can ONLY be on one CF distribution globally across all accounts.
aliases = [
var.domain_name,
"www.${var.domain_name}",
]
create_origin_access_identity = true
origin_access_identities = {
web_app = "My web app"
}
logging_config = {
bucket = aws_s3_bucket.cloudfront_log_bucket.bucket_domain_name
prefix = var.domain_name
}
origin = {
default = {
domain_name = module.web_app.s3_bucket_bucket_regional_domain_name
s3_origin_config = {
origin_access_identity = "web_app" # key in `origin_access_identities`
}
}
app_api = {
# I'm pointing to Elastic Beanstalk here, but it could be ELB, API Gateway, etc.
domain_name = module.elastic_beanstalk_environment.endpoint
custom_origin_config = {
http_port = var.eb_api_port
https_port = var.eb_api_port
origin_protocol_policy = "http-only"
origin_ssl_protocols = ["TLSv1.2"]
}
}
}
# Everything that doesn't match an ordered_cache_behavior goes here (i.e., your SPA requests).
default_cache_behavior = {
target_origin_id = "default"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD"]
compress = true
query_string = true
function_association = {
viewer-request = {
function_arn = aws_cloudfront_function.web_app_url_rewriter_cf_function.arn
}
}
}
ordered_cache_behavior = [
# REST or GraphQL API requests go here
{
path_pattern = "/api/*"
target_origin_id = "app_api"
viewer_protocol_policy = "redirect-to-https"
origin_request_policy_id = aws_cloudfront_origin_request_policy.api_request_policy.id
cache_policy_id = aws_cloudfront_cache_policy.api_cache_policy.id
use_forwarded_values = false
allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
cached_methods = ["GET", "HEAD"]
compress = true
min_ttl = 0
max_ttl = 0
default_ttl = 0
}
]
viewer_certificate = {
acm_certificate_arn = aws_acm_certificate.cert.arn
ssl_support_method = "sni-only"
}
}
# Cloudfront function to rewrite '/' -> '/index.html'
resource "aws_cloudfront_function" "web_app_url_rewriter_cf_function" {
name = "${local.name_with_env}-web-app-url-rewriter"
runtime = "cloudfront-js-1.0"
code = file("url_rewriter.js")
}
The last few lines above create the CF Function to rewrite the URL path. You need to place the
following file (url_rewriter.js
) alongside the terraform file above.
url_rewriter.js
:
function handler(event) {
var request = event.request;
var uri = request.uri;
// Any physical file that Flutter builds is allowed through. Anything else is presumed to be an
// app deep link, so we need to load index.html. Basically:
// /assets/**
// /canvaskit/**
// /icons/**
// /<any regular file in root directory with a dot in it>
if (!(uri.startsWith('/assets/') || uri.startsWith('/canvaskit/') || uri.startsWith('/icons/')
|| /^\/[^\/]*\.[^\/]*$/.test(uri))) {
request.uri = '/index.html';
}
return request;
}
Deploying the Flutter web app
Finally, you need to deploy the Flutter web app (SPA) to S3. Here’s part of the script I use:
rm -rf build/web
flutter pub get
flutter build web --release --web-renderer canvaskit
dist_bucket=<your-bucket-name>
# Clean out the bucket
aws s3 rm s3://${dist_bucket} --recursive
# Upload everything fresh
aws s3 cp build/web/ s3://${dist_bucket} --recursive
# Invalidate cloudfront to flush the edge node caches
DISTRIBUTION_ID=`aws cloudfront list-distributions |
jq -r '.DistributionList.Items[] | select(.Comment == "myapp-prod distribution") | .Id'`
aws cloudfront create-invalidation --distribution-id ${DISTRIBUTION_ID} --paths '/*'
This requires the AWS CLI and jq.
Enjoy!