Deploy a Flutter web app and API to AWS CloudFront and S3 using Terraform

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: deployment

Sequences of events:

  1. 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.
  2. The browser sends a request to load your SPA - e.g., at /.
  3. The CF edge node receives your request and calls the CF Function to rewrite the URL path from / to /index.html.
  4. 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.
  5. After loading /index.html, the browser will probably start to load a bunch of physical assets such as JS and images.
  6. Your Flutter web app (SPA) starts to run in the browser and requests something from the API at path /api/foo.
  7. The CF edge node receives the request, but because it is an /api/ request, the CF Function doesn’t rewrite the URL.
  8. 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.
  9. 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!


See also

comments powered by Disqus