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 tohttps
) - 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:
- There's a domain name managed by Route53
- The front-end static files are built into a local
./dist
folder - The serverless plugins serverless-s3-sync and serverless-cloudfront-invalidate are installed
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 stagecertificateARN
: The ARN of an ACM certificate which will cover the domain name (usually a wildcard certificate). This must be in theus-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 separate 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!