Highway Three Solutions

Hosting a static site with AWS CDK

#AWS#DevOps

Amazon Web Services Cloud Development Kit (AWS CDK) is an abstraction layer working to generate Cloud Formation templates. It is a programmatic interface that can be used in tandem with command line tools to compile (javascript, typescript, python, and others) into cloud formation templates. This is incredibly powerful. You can code a Front End application (react, vue, angular) and also your ENTIRE AWS stack within the same language, same repository, creating and deploy it using the same cicd tools. This reduces code silos, as a Front End developer you no longer have a mysterious AWS environment, and as the person or team managing the Front End Stack you can standardize and implement your organization's best practices. This is a big shift left for developers so let's go through an example.

Static Website - S3 + CloudFront + Route 53

One common pattern we at H3 put in place is the hosting of a static website in an S3 bucket, content served through Cloud Front (CF), and DNS managed by Route 53. The diagram below shows a rough architectural diagram.

Following an http requests path, it first hits the DNS provider, Route 53 in our case. Traffic is directed from route 53 to the CF via an A record within a hosted zone (e.g., www.example.com/index.html would be directed to 5kj22l3ksjas.cloudfront.net/index.html). Cloud Front will perform some magic here and the request will reach into the s3 bucket to retrieve the html file and return it. This is a very high level overview and we didn't touch on a few important features,

  • CF has a caching layer. So sometimes the s3 object wont be retrieved.

  • If the s3 bucket is a static website, then pages like www.example.com/blog would result in a 404 error. We implement a simple Lambda Edge functions to check if request paths without the .html extension are objects in our s3 bucket (i.e., /blog is the object /blog.html in out S3 bucket). If so then we return that object rather than the file blog. Then finally if that object doesn’t exist, return the default error code.

Each of these constructs, Route 53, Cloud Front, S3, Lambda can be defined within the CDK. We will be writing our example using TypeScript. We will also assume you have some basic knowledge about the CDK, it’s setup and how to use it so we can get right into creating our stack library. If you don't have that background knowledge see the AWS docs.

import * as cdk from '@aws-cdk/core';
import * as s3 from '@aws-cdk/aws-s3';
import * as cloudfront from '@aws-cdk/aws-cloudfront';
import * as acm from '@aws-cdk/aws-certificatemanager';
import * as lambda from '@aws-cdk/aws-lambda';
import * as path from 'path';
const H3_CERTIFICATE_ARN = 'arn-for-your-certification'
export class StaticSite extends cdk.Stack {
constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
/**
* Force stack to be created in us-east-1 (lambda and cf restriction)
*/
super(scope, id,
{
...props,
env: {
...props?.env,
region: 'us-east-1'
}
});
// ... stack definition
}
}

Above is the basic stack definition. We will be using the s3, cloudfront, acm, and lambda constructs. Since we have already created a wildcard certification we don't have to create one for this stack. It seems like the best way to handle certification may be to create them before hand. Since you may not have Route 53 handle your dns records and creating a cert takes some time. If you are not using amazon to manage your DNS you will have to certify your domain with AWS Certification Manager. Then after the initial deployment add an A record to route traffic from your domain to the Cloud Front instance.

First let's just create an s3 bucket with default error.html and index.html document references, and some general configuration objects.

import * as cdk from '@aws-cdk/core';
import * as s3 from '@aws-cdk/aws-s3';
import * as cloudfront from '@aws-cdk/aws-cloudfront';
import * as acm from '@aws-cdk/aws-certificatemanager';
import * as lambda from '@aws-cdk/aws-lambda';
import * as path from 'path';
const H3_CERTIFICATE_ARN = 'arn-for-your-certification'
export class StaticSite extends cdk.Stack {
constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
/**
* Force stack to be created in us-east-1 (lambda and cf restriction)
*/
super(scope, id,
{
...props,
env: {
...props?.env,
region: 'us-east-1'
}
});
const URL = 'cdktest.example.com'
const bucket = new s3.Bucket(this, URL, {
websiteErrorDocument: '404.html',
websiteIndexDocument: 'index.html',
removalPolicy: cdk.RemovalPolicy.DESTROY,
publicReadAccess: true,
cors: [{
allowedOrigins: ['*'],
allowedMethods: [
s3.HttpMethods.GET,
s3.HttpMethods.POST,
s3.HttpMethods.PUT,
s3.HttpMethods.DELETE,
s3.HttpMethods.HEAD
]
}],
})
}
}

We can deploy this stack by running the command cdk deploy or npx cdk deploy (depending on your environment). If we then log into our aws console and find the Cloud Formation stacks we'll see an s3 bucket has been created. YAY! 🎊 . Now let us update the stack so it connects this S3 bucket to a CF distribution.

import * as cdk from '@aws-cdk/core';
import * as s3 from '@aws-cdk/aws-s3';
import * as cloudfront from '@aws-cdk/aws-cloudfront';
import * as acm from '@aws-cdk/aws-certificatemanager';
import * as lambda from '@aws-cdk/aws-lambda';
import * as path from 'path';
const H3_CERTIFICATE_ARN = 'arn-for-your-certification'
export class StaticSite extends cdk.Stack {
constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
/**
* Force stack to be created in us-east-1 (lambda and cf restriction)
*/
super(scope, id,
{
...props,
env: {
...props?.env,
region: 'us-east-1'
}
});
const URL = 'cdktest.example.com'
const bucket = new s3.Bucket(this, URL, {
websiteErrorDocument: '404.html',
websiteIndexDocument: 'index.html',
removalPolicy: cdk.RemovalPolicy.DESTROY,
publicReadAccess: true,
cors: [{
allowedOrigins: ['*'],
allowedMethods: [
s3.HttpMethods.GET,
s3.HttpMethods.POST,
s3.HttpMethods.PUT,
s3.HttpMethods.DELETE,
s3.HttpMethods.HEAD
]
}],
})
const cf = new cloudfront.CloudFrontWebDistribution(this, `cf-${URL}`, {
defaultRootObject: 'index.html',
originConfigs: [{
s3OriginSource: { s3BucketSource: bucket },
behaviors: [{
cachedMethods: cloudfront.CloudFrontAllowedCachedMethods.GET_HEAD_OPTIONS,
isDefaultBehavior: true,
allowedMethods: cloudfront.CloudFrontAllowedMethods.GET_HEAD_OPTIONS,
}],
}]
});
}
}

This is a very simple CF distribution. Let's fill it out with some custom Behaviors for error pages (line 58 ish), and setup the certification (line 47 and line 91 ish).

import * as cdk from '@aws-cdk/core';
import * as s3 from '@aws-cdk/aws-s3';
import * as cloudfront from '@aws-cdk/aws-cloudfront';
import * as acm from '@aws-cdk/aws-certificatemanager';
import * as lambda from '@aws-cdk/aws-lambda';
import * as path from 'path';
const H3_CERTIFICATE_ARN = 'arn-for-your-certification'
export class StaticSite extends cdk.Stack {
constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
/**
* Force stack to be created in us-east-1 (lambda and cf restriction)
*/
super(scope, id,
{
...props,
env: {
...props?.env,
region: 'us-east-1'
}
});
const URL = 'cdktest.highwaythreesolutions.com'
const bucket = new s3.Bucket(this, URL, {
websiteErrorDocument: '404.html',
websiteIndexDocument: 'index.html',
removalPolicy: cdk.RemovalPolicy.DESTROY,
publicReadAccess: true,
cors: [{
allowedOrigins: ['*'],
allowedMethods: [
s3.HttpMethods.GET,
s3.HttpMethods.POST,
s3.HttpMethods.PUT,
s3.HttpMethods.DELETE,
s3.HttpMethods.HEAD
]
}],
})
/**
* Load Site Certification object for association with Cloud Front instance
*/
const siteCertificate = acm.Certificate.fromCertificateArn(
this,
'site-cert',
H3_CERTIFICATE_ARN
)
/**
* Define Cloud Front Instance
*/
const cf = new cloudfront.CloudFrontWebDistribution(this, `cf-${URL}`, {
defaultRootObject: 'index.html',
errorConfigurations: [{
errorCode: 404,
errorCachingMinTtl: 10,
responseCode: 404,
responsePagePath: '/404.html'
},
{
errorCode: 400,
errorCachingMinTtl: 10,
responseCode: 400,
responsePagePath: '/404.html'
},
{
errorCode: 403,
errorCachingMinTtl: 10,
responseCode: 403,
responsePagePath: '/404.html'
},
{
errorCode: 405,
errorCachingMinTtl: 10,
responseCode: 405,
responsePagePath: '/404.html'
},
{
errorCode: 500,
errorCachingMinTtl: 10,
responseCode: 500,
responsePagePath: '/404.html'
}],
originConfigs: [{
s3OriginSource: { s3BucketSource: bucket },
behaviors: [{
cachedMethods: cloudfront.CloudFrontAllowedCachedMethods.GET_HEAD_OPTIONS,
isDefaultBehavior: true,
allowedMethods: cloudfront.CloudFrontAllowedMethods.GET_HEAD_OPTIONS,
}],
}],
viewerCertificate: cloudfront.ViewerCertificate.fromAcmCertificate(
siteCertificate,
{
aliases: [URL],
securityPolicy: cloudfront.SecurityPolicyProtocol.TLS_V1,
sslMethod: cloudfront.SSLMethod.SNI
}
),
});
}
}

Okay great! Now if this stack is deployed we’ll have a secured http site, custom error messages, that serves a static site. In 100 ish lines of code! Now for some optional fun stuff. Since this is a static site, we should handle the request like a webserver would, directing traffic from /page to /page.html. To do this we’ll define a lambda function, and trigger it in the origin request.

The lambda function is really simple, it just checks if the request URI for the absence of a .html extension. If its true, the request is updated:

'use strict';
exports.handler = async (event) => {
const request = event.Records[0].cf.request;
if (/^(/+([dws+_-!@=#$%&*]+))+$/g.test(request.uri)) {
request.uri = request.uri + '.html';
return request;
}
return request;
};

and the Stack definition will now look like the following (changes on line 52-56 and line 99-102)

import * as cdk from '@aws-cdk/core';
import * as s3 from '@aws-cdk/aws-s3';
import * as cloudfront from '@aws-cdk/aws-cloudfront';
import * as acm from '@aws-cdk/aws-certificatemanager';
import * as lambda from '@aws-cdk/aws-lambda';
import * as path from 'path';
const H3_CERTIFICATE_ARN = 'arn-for-your-certification'
export class StaticSite extends cdk.Stack {
constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
/**
* Force stack to be created in us-east-1 (lambda and cf restriction)
*/
super(scope, id,
{
...props,
env: {
...props?.env,
region: 'us-east-1'
}
});
const URL = 'cdktest.highwaythreesolutions.com'
const bucket = new s3.Bucket(this, URL, {
websiteErrorDocument: '404.html',
websiteIndexDocument: 'index.html',
removalPolicy: cdk.RemovalPolicy.DESTROY,
publicReadAccess: true,
cors: [{
allowedOrigins: ['*'],
allowedMethods: [
s3.HttpMethods.GET,
s3.HttpMethods.POST,
s3.HttpMethods.PUT,
s3.HttpMethods.DELETE,
s3.HttpMethods.HEAD
]
}],
})
/**
* Load Site Certification object for association with Cloud Front instance
*/
const siteCertificate = acm.Certificate.fromCertificateArn(
this,
'site-cert',
H3_CERTIFICATE_ARN
)
const nonHtmlRequestFunction = new lambda.Function( this, 'LambdaEdgeRedirect', {
runtime: lambda.Runtime.NODEJS_12_X,
handler: 'index.handler',
code: lambda.Code.fromAsset(path.join(__dirname, 'lambdas', 'static-web-hosting'))
})
/**
* Define Cloud Front Instance
*/
const cf = new cloudfront.CloudFrontWebDistribution(this, `cf-${URL}`, {
defaultRootObject: 'index.html',
errorConfigurations: [{
errorCode: 404,
errorCachingMinTtl: 10,
responseCode: 400,
responsePagePath: '/404.html'
},
{
errorCode: 400,
errorCachingMinTtl: 10,
responseCode: 400,
responsePagePath: '/404.html'
},
{
errorCode: 403,
errorCachingMinTtl: 10,
responseCode: 400,
responsePagePath: '/404.html'
},
{
errorCode: 405,
errorCachingMinTtl: 10,
responseCode: 400,
responsePagePath: '/404.html'
},
{
errorCode: 500,
errorCachingMinTtl: 10,
responseCode: 400,
responsePagePath: '/404.html'
}],
originConfigs: [{
s3OriginSource: { s3BucketSource: bucket },
behaviors: [{
cachedMethods: cloudfront.CloudFrontAllowedCachedMethods.GET_HEAD_OPTIONS,
isDefaultBehavior: true,
allowedMethods: cloudfront.CloudFrontAllowedMethods.GET_HEAD_OPTIONS,
lambdaFunctionAssociations:[{
eventType: cloudfront.LambdaEdgeEventType.ORIGIN_REQUEST,
lambdaFunction: nonHtmlRequestFunction.currentVersion
}]
}],
}],
viewerCertificate: cloudfront.ViewerCertificate.fromAcmCertificate(
siteCertificate,
{
aliases: [URL],
securityPolicy: cloudfront.SecurityPolicyProtocol.TLS_V1,
sslMethod: cloudfront.SSLMethod.SNI
}
),
});
}
}

Adding A Record to Route 53

If your DNS provider is AWS (Route 53), then we can create the A record with the following lines of code.

const zone = r53.HostedZone.fromLookup(this, 'H3-SiteZones', {
domainName: DOMAIN_NAME
})
new r53.ARecord(this, 'DistributionRecord', {
recordName: URL,
zone: zone,
target: r53.RecordTarget.fromAlias({
bind: () => ({
hostedZoneId: r53T.CloudFrontTarget.getHostedZoneId(cf),
dnsName: cf.distributionDomainName
})
})
})

where r53 is defined from the import import * as r53 from '@aws-cdk/aws-route53'.

Deployment options:

By adding the following code snippet you can deploy a folder as your static site.

new s3deploy.BucketDeployment(this, 'DeployWebsite', {
sources: [s3deploy.Source.asset(path.join(__dirname, '..', 'website-dist'))],
destinationBucket: bucket,
retainOnDelete: false
});

The example stack has been updated with some quality of life/reusability improvements to in the Github Repo here. I encourage you to check out the updates.

Contact us for more information on anything DevOps