Photo by Tran Mau Tri Tam ✪ on Unsplash

Easily Deploy Serverless SPA Apps With Custom AWS CDK Constructs

Discover how to streamline the deployment of serverless SPA applications by harnessing custom AWS CDK constructs in TypeScript, simplifying the development process and improving efficiency.

Serverless Advocate
7 min readOct 7, 2023

--

Introduction

So, you have a SPA written in your favourite framework such as React or Vue that you need to host on AWS, but how do you do this using the AWS CDK and TypeScript? In this quick article we will see how you can do this easily through AWS CDK L3 Custom Constructs to use for all of your organisations needs.

👇 Before we go any further — please connect with me on LinkedIn for future blog posts and Serverless news https://www.linkedin.com/in/lee-james-gilmore/

Let’s walk through the code

Let’s now cover how we may want to do this with some basic AWS CDK and TypeScript code, so each time we perform a deployment of our stack it deploys the SPA build.

Firstly, we want to create a WebBucket custom CDK construct which will setup the hosting of the SPA in S3, whilst also configuring Origin Access Identity:

import * as cdk from 'aws-cdk-lib';
import * as cloudFront from 'aws-cdk-lib/aws-cloudfront';
import * as s3 from 'aws-cdk-lib/aws-s3';

import { Construct } from 'constructs';

interface WebBucketProps
extends Pick<s3.BucketProps, 'removalPolicy' | 'bucketName'> {
/**
* The stage name
*/
stageName: string;
/**
* The removal policy for the table
*/
removalPolicy: cdk.RemovalPolicy;
/**
* The bucket name
*/
bucketName: string;
}

type FixedWebBucketProps = Omit<s3.BucketProps, 'removalPolicy' | 'bucketName'>;

export class WebBucket extends Construct {
public readonly bucket: s3.Bucket;
public readonly originAccessIdentity: cloudFront.OriginAccessIdentity;

constructor(scope: Construct, id: string, props: WebBucketProps) {
super(scope, id);

const fixedProps: FixedWebBucketProps = {
websiteIndexDocument: 'index.html',
websiteErrorDocument: 'index.html',
enforceSSL: true,
publicReadAccess: false,
autoDeleteObjects: true,
serverAccessLogsPrefix: 'access-logs/',
serverAccessLogsBucket: new s3.Bucket(this, 'WebBucketAccessLogs' + id, {
autoDeleteObjects: true,
removalPolicy: props.removalPolicy,
bucketName: `${props.bucketName}-access-logs`,
}),
};

this.bucket = new s3.Bucket(this, id, {
// fixed props
...fixedProps,
// custom props
...props,
});

this.originAccessIdentity = new cloudFront.OriginAccessIdentity(
this,
id + 'OAI',
{
comment: `Origin Access Identity for ${id} web bucket`,
}
);

this.bucket.grantRead(this.originAccessIdentity);
this.bucket.applyRemovalPolicy(cdk.RemovalPolicy.DESTROY);
}
}

We can see from the code above that this creates an S3 bucket with:

  • Origin Access Identity configured.
  • Adds the relevant access logging configuration.
  • Sets the index and error document properties to ‘index.html’.
  • Auto deletes objects when the bucket itself is deleted (used mainly for ephemeral environments).

We can then create an instance of the WebBucket construct as shown below in our Client stack:

const { originAccessIdentity, bucket } = new WebBucket(
this,
'ClientBucket',
{
bucketName,
removalPolicy: RemovalPolicy.DESTROY,
stageName: stageName,
}
);
this.bucket = bucket;

Now that we have our S3 bucket setup correctly for web hosting our SPA build, we now need to create our CloudFront Distribution using a custom WebCloudFrontDistribution CDK construct, as shown below:

import * as cdk from 'aws-cdk-lib';
import * as certificateManager from 'aws-cdk-lib/aws-certificatemanager';
import * as cloudFront from 'aws-cdk-lib/aws-cloudfront';
import * as s3 from 'aws-cdk-lib/aws-s3';

import { Construct } from 'constructs';
import { Stage } from 'infra-common';

interface WebCloudFrontDistributionProps
extends Pick<cloudFront.CloudFrontWebDistributionProps, 'enabled'> {
/**
* The stage name which the distribution is being used with
*/
stageName: string;
/**
* The removal policy for the distribution
*/
removalPolicy: cdk.RemovalPolicy;
/**
* Whether or not the distribution is enabled
*/
enabled: boolean;
/**
* The domain certificate arn
*/
domainCertArn: string;
/**
* The origin access identity
*/
originAccessIdentity: cloudFront.OriginAccessIdentity;
/**
* The s3 bucket which the distribution will target
*/
bucket: s3.Bucket;
/**
* The sub domain for the distribution
*/
subDomain: string;
/**
* An optional edge function for the distribution
*/
edgeFunction?: cloudFront.experimental.EdgeFunction;
}

type FixedWebCloudFrontDistributionProps = Omit<
cloudFront.CloudFrontWebDistributionProps,
'enabled'
>;

export class WebCloudFrontDistribution extends Construct {
public readonly distribution: cloudFront.CloudFrontWebDistribution;

constructor(
scope: Construct,
id: string,
props: WebCloudFrontDistributionProps
) {
super(scope, id);

const lambdaFunctionAssociations: cloudFront.LambdaFunctionAssociation[] =
props.edgeFunction
? [
{
lambdaFunction: props.edgeFunction.currentVersion,
eventType: cloudFront.LambdaEdgeEventType.VIEWER_REQUEST,
},
]
: [];

const fixedProps: FixedWebCloudFrontDistributionProps = {
originConfigs: [
{
s3OriginSource: {
s3BucketSource: props.bucket,
originAccessIdentity: props.originAccessIdentity,
},
behaviors: [{ isDefaultBehavior: true, lambdaFunctionAssociations }],
},
],
viewerProtocolPolicy: cloudFront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
comment: `${props.stageName} client web distribution`,
defaultRootObject: 'index.html',
priceClass: cloudFront.PriceClass.PRICE_CLASS_100,
loggingConfig: {
bucket: new s3.Bucket(this, 'DistAccessLogs' + id, {
bucketName:
`${props.stageName}-distribution-access-logs`.toLowerCase(),
objectOwnership: s3.ObjectOwnership.OBJECT_WRITER,
autoDeleteObjects: true,
removalPolicy: cdk.RemovalPolicy.DESTROY,
serverAccessLogsPrefix: 'access-logs/',
enforceSSL: true,
serverAccessLogsBucket: new s3.Bucket(
this,
'DistBucketAccessLogs' + id,
{
removalPolicy: props.removalPolicy,
autoDeleteObjects: true,
bucketName: `${props.stageName}-web-access-logs`.toLowerCase(),
}
),
}),
},
// we need to pull in the certificate you have already created for your own domain
viewerCertificate: cloudFront.ViewerCertificate.fromAcmCertificate(
certificateManager.Certificate.fromCertificateArn(
this,
'Certificate',
props.domainCertArn
),
{
securityPolicy: cloudFront.SecurityPolicyProtocol.TLS_V1_2_2021,
sslMethod: cloudFront.SSLMethod.SNI,
aliases: [
...(props.stageName === Stage.prod
? [
props.subDomain,
`www.${props.subDomain}`,
`*.${props.subDomain}`,
]
: [props.subDomain]),
],
}
),
httpVersion: cloudFront.HttpVersion.HTTP3,
};

this.distribution = new cloudFront.CloudFrontWebDistribution(this, id, {
// fixed props
...fixedProps,
// custom props
...props,
});

this.distribution.applyRemovalPolicy(props.removalPolicy);

// add security headers using an override as the prop is not available yet
// https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-response-headers-policies.html#managed-response-headers-policies-security
const cfnDistribution = this.distribution.node
.defaultChild as cloudFront.CfnDistribution;

cfnDistribution.addPropertyOverride(
'DistributionConfig.DefaultCacheBehavior.ResponseHeadersPolicyId',
cloudFront.ResponseHeadersPolicy.SECURITY_HEADERS.responseHeadersPolicyId
);
}
}

As you can see from our custom construct above, we create the CloudFront distribution with:

  • An optional Lambda@Edge function passed into the props (see link below).
  • We pass in the props for the S3 bucket to set it as the origin, and also configure Origin Access Identity.
  • We setup the relevant logging.
  • We setup the relevant aliases for the domain names on the certificate (both main domain name and Subject Alternative names).

💡 Note: For more information on using Lambd@Edge see the following article:

We can then create an instance of this custom WebCloudFrontDistribution construct as shown below:

const cloudFrontDistribution = new WebCloudFrontDistribution(
this,
'Distribution',
{
enabled: true,
stageName: stageName,
subDomain,
removalPolicy: RemovalPolicy.DESTROY,
originAccessIdentity,
bucket: this.bucket,
domainCertArn: domainCertificateArn,
edgeFunction: privacyEdgeFunction,
}
).distribution;

We now have the S3 bucket configured for hosting our SPA build and a CloudFront Distribution sitting in front of it.

Finally, we can utilise the s3Deploy CDK construct (‘aws-cdk-lib/aws-s3-deployment’) to actually deploy our SPA build to our S3 bucket as shown below:

new s3deploy.BucketDeployment(this, 'ClientBucketDeployment', {
sources: [
s3deploy.Source.asset(path.join(__dirname, '../../web/build')),
s3deploy.Source.jsonData('config.json', {
stage,
domainName,
subDomain,
api: generateApiSubDomain(stage, domainName),
}), // runtime config for client
],
destinationBucket: this.bucket,
metadata: {
stageName: stage,
},
distribution: cloudFrontDistribution,
distributionPaths: ['/*'],
});

We can see from the code above that we deploy the build of our client which is found in ‘../../web/build’, and we also generate and deploy a config file in the root of the directory too which contains:

  • The stage i.e. Prod, Staging, Dev, pr-123 etc
  • The domain name i.e. your-domain.com.
  • The sub domain name i.e. staging.your-domain.com or pr-123.your-domain.com.
  • The relevant REST API URL that the client application needs using a function called generateApiSubDomain which results in something like api-staging.your-domain.com.

The generateApiSubDomain function looks like this below:

import { Stage } from '../stage/stage';

export function generateApiSubDomain(
stageName: string,
domainName: string
): string {
switch (stageName) {
case Stage.prod:
return `api.${domainName}`.toLowerCase();
case Stage.featureDev:
return `api-dev.${domainName}`.toLowerCase();
case Stage.staging:
return `api-staging.${domainName}`.toLowerCase();
default:
return `api-${stageName}.${domainName}`.toLowerCase();
}
}

This means that the client application can easily read this static deployed JSON configuration file at the root directory to easily display the stage in the client app, or too easily determine which underlying API to call.

An example of this config file could be:

{
"stage" : "pr-123",
"domainName" : "your-domain.com",
"subDomain" : "pr-123.your-domain.com",
"api" : "api-pr-123.your-domain.com"
}

It is also important to ensure your npm scripts or pipelines are setup in a way that when you perform a CDK Deploy of your stacks you build the client application first i.e we build the client application so we always deploy a fresh version of it.

Wrapping up

I hope you enjoyed this quick article, and if you did then please feel free to share and feedback!

Please go and subscribe on my YouTube channel for similar content!

I would love to connect with you also on any of the following:

https://www.linkedin.com/in/lee-james-gilmore/
https://twitter.com/LeeJamesGilmore

If you enjoyed the posts please follow my profile Lee James Gilmore for further posts/series, and don’t forget to connect and say Hi 👋

Please also use the ‘clap’ feature at the bottom of the post if you enjoyed it! (You can clap more than once!!)

About me

Hi, I’m Lee, an AWS Community Builder, Blogger, AWS certified cloud architect and Global Head of Technology & Architecture based in the UK; currently working for City Electrical Factors (UK) & City Electric Supply (US), having worked primarily in full-stack JavaScript on AWS for the past 6 years.

I consider myself a serverless advocate with a love of all things AWS, innovation, software architecture and technology.

*** The information provided are my own personal views and I accept no responsibility on the use of the information. ***

You may also be interested in the following:

--

--

Global Head of Technology & Architecture | Serverless Advocate | Mentor | Blogger | AWS x 7 Certified 🚀