jonelantha: Blog


How-To: Host serverless AWS Api Gateway and static files on the same domain

9th September 2020

This post contains an example of how to setup Serverless Framework to deploy static front-end files and a serverless api behind the same domain name. Sounds simple but there's a little bit of extra config involved...

Most of the config in this article is a CloudFormation template; so it should also be useful for SAM based projects.

Serverless Simplicity...

  • Serve the front-end static files from https://www.example.com
  • Serve the lambda function powered API Gateway from https://www.example.com/api

With only one host, cross-origin issues should be minimised, no need to configure CORS headers or tweak Cookie settings.

But wait - isn't it best practice to use as many domains as possible?

A few years ago it was considered a good idea to use Domain Sharding (having the site's resources split over multiple domains). Browsers limit themselves to 6 concurrent HTTP/1.1 connections (per host) and so using more hosts meant that limit could be increased.

However, modern browsers and HTTP/2 on the server make this practice obselete.

Still, it's important to note that the single domain approach outlined here isn't for all projects, particularly larger projects or projects which share resources with other sites.

The Stack

Here's what the example stack provides:

  • CDN

    • Site served using CloudFront
    • Supports HTTP/2
    • Secured with the latest version of the SSL protocol (all insecure http requests will be redirected to https)
    • CloudFront cache will be invalidated after each deployment
  • Static Files

    • Static files stored in an S3 Bucket, served via CloudFront (the best practice way to serve static content with the lowest costs)
    • Bucket content is private (unless accessed via the allocated domain name), i.e. no S3 website hosting
    • Static content served with default CloudFront cache settings
    • The S3 bucket is created as part of the stack
  • API

    • API supplied by HTTP API Gateway (also known as API Gateway V2)
    • Hosted using the same domain as the static content
    • No caching by default but caching can be configured per end-point by sending appropriate headers from the lambda functions
  • Domain Name

    • Domain Name allocated as part of the stack, so it should be straightforward to have different domains for different stages (or different developers)

This example assumes the following:

The settings in the custom section will need to be tweaked:

  • hostedZoneName: The Route53 hosted zone of the domain name, don't forget the . at the end!
  • domainName: The sub-domain to create. Hard-coded here for simplicity but could be adapted to be dependent on the stage
  • certificateARN: The ARN of an ACM certificate which will cover the domain name (usually a wildcard certificate). This must be in the us-east-1 region regardless of the stack's region

TL;DR

So here's the serverless.yml file, it's for Serverless Framework but could be adapted for Amazon's Serverless Application Model.

Some notes inline:

service: singleDomainProject

provider:
  name: aws
  runtime: nodejs12.x
  httpApi:
    payload: "2.0"

custom:
  # configure the following (see above for details)
  hostedZoneName: example.com.
  domainName: my-site.example.com
  certificateARN: "arn:aws:acm:us-east-1:############:certificate/########-####-####-####-############"
  # bucket name is based on the domain, tweak this as appropriate
  bucketName: ${self:custom.domainName}
  s3Sync:
    - bucketName: ${self:custom.bucketName}
      localDir: dist # where to copy the static files from
  cloudfrontInvalidate:
    distributionIdKey: CloudFrontId
    items: ["/*"]

plugins:
  - serverless-s3-sync
  - serverless-cloudfront-invalidate

package:
  exclude:
    - node_modules/**
    - dist/**
    - package.json
    - package-lock.json

# hello world api, code not included here
functions:
  hello:
    handler: handler.hello
    events:
      - httpApi:
          path: /api/hello
          method: get

resources:
  Resources:
    CloudFrontOriginAccessIdentity:
      Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
      Properties:
        CloudFrontOriginAccessIdentityConfig:
          Comment: !Join ["-", ["access-identity", !GetAtt S3Bucket.DomainName]]
    CloudFrontDistribution:
      Type: AWS::CloudFront::Distribution
      Properties:
        DistributionConfig:
          # https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/PriceClass.html
          PriceClass: PriceClass_100
          Aliases:
            - ${self:custom.domainName}
          Enabled: true
          HttpVersion: http2
          DefaultRootObject: index.html
          ViewerCertificate:
            AcmCertificateArn: ${self:custom.certificateARN}
            SslSupportMethod: sni-only
            MinimumProtocolVersion: TLSv1.2_2019
          Origins:
            - Id: api
              DomainName: !Join [".", [!Ref HttpApi, "execute-api", !Ref AWS::Region, "amazonaws.com"]]
              CustomOriginConfig:
                OriginProtocolPolicy: https-only
            - Id: static
              DomainName: !GetAtt S3Bucket.RegionalDomainName
              S3OriginConfig:
                OriginAccessIdentity: !Join ["", ["origin-access-identity/cloudfront/", !Ref CloudFrontOriginAccessIdentity]]
          CacheBehaviors:
            - PathPattern: api/*
              TargetOriginId: api
              # recently deprecated style of setting ForwardedValues
              # but currently there's no alternative if using CloudFormation
              ForwardedValues:
                QueryString: true
              ViewerProtocolPolicy: redirect-to-https
              AllowedMethods: [GET, HEAD, OPTIONS, PATCH, POST, PUT, DELETE]
              # No caching by default but caching can be set with appropriate headers in the api responses
              MinTTL: 0
              DefaultTTL: 0
          DefaultCacheBehavior:
            TargetOriginId: static
            # 'Managed-CachingOptimized'
            # https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-cache-policies.html
            CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6
            # 'Managed-CORS-S3Origin'
            # https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-origin-request-policies.html
            OriginRequestPolicyId: 88a5eaf4-2fd4-4709-b370-b4c650ea3fcf
            ViewerProtocolPolicy: redirect-to-https
    S3Bucket:
      Type: AWS::S3::Bucket
      Properties:
        BucketName: ${self:custom.bucketName}
    S3BucketPolicy:
      Type: AWS::S3::BucketPolicy
      Properties:
        Bucket: !Ref S3Bucket
        PolicyDocument:
          Statement:
            - Effect: Allow
              Action: "s3:GetObject"
              Resource: !Join ["", [!GetAtt S3Bucket.Arn, "/*"]]
              Principal:
                CanonicalUser: !GetAtt CloudFrontOriginAccessIdentity.S3CanonicalUserId
    DomainRecord:
      Type: AWS::Route53::RecordSetGroup
      Properties:
        HostedZoneName: ${self:custom.hostedZoneName}
        RecordSets:
        - Name: ${self:custom.domainName}
          Type: A
          AliasTarget:
            DNSName: !GetAtt CloudFrontDistribution.DomainName
            HostedZoneId: Z2FDTNDATAQYW2 # Hard-coded to the hosted zone of CloudFront
  Outputs:
    CloudFrontId:
      Description: CloudFront Id
      Value: !Ref CloudFrontDistribution
      Export:
        Name: CloudFrontId

Looks great, anything else I might like to look at?

Building the front-end

This example doesn't build the front-end, typically npm scripts would be setup to build the front-end and then deploy it using serverless deploy

Local dev

To develop locally, serverless-offline will serve the api gateway but won't serve the static files. Typically front-ends are developed using a local web server - this local server could be configured to proxy any requests for /api/* on to the local api server (or on to an api already deployed to AWS). Taking create-react-app as an example, here's some information on how to setup the proxy.

Caching

The above stack just uses the CloudFront caching defaults. Ideally the serverless-s3-sync plugin would be configured to set the appropriate cache headers for different front-end files:

  • files which always have the same name (index.html etc) should not be cached in the browser: CacheControl: 'no-cache'
  • files which are always deployed with a different name (js or css files with hashed names) can be cached for much longer in the browser: CacheControl: 'public, max-age=31536000'

But are there any alternatives to this approach?

It's also worth considering the following alternatives:

  • Just use seperate domains for the front-end and the api (and keep your config smaller) 🙂
  • Next.js - next generation React framework with serverless option
  • Serving static files from a lambda - a single Api Gateway also serves static content

Thanks for reading!


© 2003-2020 jonelantha