Single Page Application Infrastructure on AWS (Hands-on)

Creating a cloud infrastructure to support a Single Page Application (SPA) using the AWS Cloud Development Kit (CDK)


Static website infra

Introduction

Welcome to the second part of my article series on designing a cloud infrastructure for a single page application. If you missed the first part, I recommend you check it out here before diving in. It provides a solid overview of what we're about to build and sets the context for the services we'll be using.

This part of the series will be mostly hands-on, but I'll make sure to add some explanations where it's needed.

Pre-requisites

To continue make sure you have the following tools ready and configured:

  1. Install Node.js and NPM
  2. Install and configure the AWS CLI
  3. Install CDK CLI
  4. Bootstrap the CDK CLI
  5. Have your static website assets built and ready
  6. Register a domain name with Route 53
  7. Request a public certificate with Amazon Certificate Manager

AWS Cloud Development Kit (CDK)

The AWS CDK is a tool from AWS that lets developers write code to describe, create, and manage their cloud infrastructure easily using popular programming languages like TypeScript, python and Go.

Instead of setting up your cloud resources manually through a web interface, you can write a script with the AWS CDK to do it automatically, saving time and reducing errors.

Nested Stacks

We'll be taking advantage of a CloudFormation featured called nested stacks. Nested stacks are a feature in of CloudFormation where individual stacks, representing different parts of a cloud infrastructure, are encapsulated within a parent stack.

They allow for modularization, where complex architectures are broken down into manageable, reusable components. By using nested stacks, we can maintain and update individual components without affecting the entire infrastructure. This approach provides better organization, easier debugging, and scalable management of resources.


Creating The Root Stack

Delete everything in the lib directory, create a new file called root-stack.ts.

touch root-stack.ts

Add the following code:

// lib/root-stack.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
 
interface RootStackProps extends cdk.StackProps {
  domainName: string;
  subdomain?: string;
  hostedZoneId: string;
  certificateArn: string;
}
 
export class RootStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: RootStackProps) {
    super(scope, id, props);
 
    if (!props?.domainName) {
      throw new Error("A valid domain name must be provided");
    }
 
    if (!props?.hostedZoneId) {
      throw new Error("A valid route 53 hosted zone id must be provided");
    }
 
    if (!props?.certificateArn) {
      throw new Error("An ACM Certificate ARN must be provided");
    }
 
    // The children nested stacks will go here
  }
}

The code defined in the lib/root-stack.ts file contains a class called RootStack which will serve as the central point (our root) to orchestrate the deployment of several nested stacks. This modular approach will allow us to separate the logic and resources for handling different aspects of the infrastructure such as object storage (S3), CDN (CloudFront), SSL certificates, and DNS into their respective files/classes, promoting reusability and maintainability.

Passing Required Arguments

Change into the bin directory and add the following snippet in the generated file:

#!/usr/bin/env node
import 'source-map-support/register';
import * as dotenv from 'dotenv';
dotenv.config();
 
import * as cdk from 'aws-cdk-lib';
import { RootStack } from '../lib/root-stack';
 
const app = new cdk.App();
new RootStack(app, 'SPAStack', {
    domainName: process.env.DOMAIN_NAME as string,
    hostedZoneId: process.env.ROUTE_53_HOSTED_ZONE_ID as string,
    subdomain: process.env.SUBDOMAIN as string,
    certificateArn: process.env.CERTIFICATE_ARN as string,
    env: {
        account: process.env.CDK_DEFAULT_ACCOUNT, 
        region: process.env.CDK_DEFAULT_REGION,
    }
});

Initializing Environment Variables

Create a new .env file and add the environment variables we'll need to deploy the infrastructure:

DOMAIN_NAME='example.com'
SUBDOMAIN='www'
ROUTE_53_HOSTED_ZONE_ID='example.com hosted zone id'
CERTIFICATE_ARN='ACM certificate ARN for example.com'
CDK_DEFAULT_ACCOUNT='your aws account id'
CDK_DEFAULT_REGION='us-east-1'

The Object Storage Stack (S3)

Now that we have the initial setup out of the way. Let's create our first CDK stack.

First, create a new directory called nested-stacks where all the children stacks will live, and change into it.

mkdir nested-stacks
cdk nested-stacks

Create a new file called object-storage.ts:

touch lib/nested-stacks/object-storage.ts

Add the following code:

// lib/nested-stacks/object-storage.ts
import * as cdk from 'aws-cdk-lib';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';
import { Construct } from 'constructs';
 
interface ObjectStorageNestedStackProps extends cdk.NestedStackProps {
    buildAssetsPath: string;
}
 
export default class ObjectStorageNestedStack extends cdk.NestedStack {
 
    public originBucket: s3.IBucket;
 
    constructor(scope: Construct, id: string, props: ObjectStorageNestedStackProps) {
        super(scope, id, props);
 
        this.originBucket = this.createOriginBucket();
        this.createDeployment(props.buildAssetsPath);
    }
 
    createOriginBucket(): s3.IBucket {
        return new s3.Bucket(this, 'OriginBucket', {
            autoDeleteObjects: true,
            removalPolicy: cdk.RemovalPolicy.DESTROY,
            publicReadAccess: false,
            blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
        });
    }
 
    createDeployment(buildAssetsPath: string): s3deploy.BucketDeployment  {
        return new s3deploy.BucketDeployment(this, 'OriginDeployment', {
            sources: [s3deploy.Source.asset(buildAssetsPath)],
            destinationBucket: this.originBucket,
            retainOnDelete: false,
        });
    }
}

We'll use this S3 bucket as a CloudFront origin bucket. In other words, this bucket will be use to serve the static assets for the single page application.

Let me explin briefly, what's happening in this class.

First, the createOriginBucket method sets up a new storage box (an S3 bucket) in AWS, where all our files that need to be shared or shown on our website will be stored. Think of this bucket like a big folder in the cloud where we keep stuff. However, this "folder" is private - meaning it won’t allow the general public to just come and grab files. Also, the autoDeleteObjects and removalPolicy settings are like putting rules on the folder that say "automatically throw away all the files" and "delete the whole folder" when we don’t need them anymore, like when we remove our setup with the cdk destroy command.

Moving on, createDeployment is a method that helps us to move or 'deploy' our files into the aforementioned bucket (or cloud folder). Behind the scenes, but still viewable via AWS’s CloudFormation service, it’s like having a robot (a Lambda function) with a special access card (an IAM Policy) and a toolset (a Lambda Layer) that helps it efficiently place our content into our bucket.

The ObjectStorageNestedStack dependency, buildAssetsPath, tells our code where our website files (for our Single Page Application or SPA) are on our computer before they get sent to the cloud.

Next let's add the ObjectStorageNestedStack class into the RootStack:

// lib/root-stack.ts
// ... other imports
import ObjectStorageNestedStack from './nested-stacks/object-storage';
 
export class RootStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: RootStackProps) {
    super(scope, id, props);
 
    // ... omitted for brevity
 
    const s3Stack = new ObjectStorageNestedStack(this, 'ObjectStorageStack', {
        buildAssetsPath: path.join(__dirname, '..', 'assets', 'ui', 'out'),
    }); 
  }
}

Great, now let's make sure we don't have errors and synthesize our current CDK project.

cdk synth

Next, let's deploy!

cdk deploy

The SSL/TLS Certificate Stack (ACM)

Now that we have taken care of the origin bucket, let's continue by importing the SSL/TLS ACM certificate we created earlier.

Create a new file called certificate.ts, and open it with your text editor.

touch certificate.ts

Now, paste the following code in there:

import * as cdk from 'aws-cdk-lib';
import * as acm from 'aws-cdk-lib/aws-certificatemanager';
import { Construct } from 'constructs';
 
interface CertificateNestedStackProps extends cdk.NestedStackProps {
    certificateArn: string;
}
 
export default class CertificateNestedStack extends cdk.NestedStack {
 
    public certificate: acm.ICertificate;
    constructor(scope: Construct, id: string, props: CertificateNestedStackProps) {
        super(scope, id, props);
 
        if (!props.certificateArn) {
            throw new Error("The certificate ARN must be provided");
        }
 
        this.certificate = this.importExistingCertificate(props.certificateArn);
    }
 
    private importExistingCertificate(certificateArn: string): acm.ICertificate {
        return acm.Certificate.fromCertificateArn(this, 'ImportedCertificate', certificateArn);
    }
}

The CertificatedNestedStack class contains two properties, the certificate public property and the importExistingCertificate() private method which we use to import the ACM certificate by its Amazon Resource Number (ARN).

By the way, this is the recommended way of using certificates according to the AWS CDK documentation. First we create the certificate using Click-Ops or through the AWS CLI and then we import it.

After requesting a certificate, you will need to prove that you own the domain in question before the certificate will be granted. The CloudFormation deployment will wait until this verification process has been completed. Because of this wait time, when using manual validation methods, it's better to provision your certificates either in a separate stack from your main service, or provision them manually and import them into your CDK application.

The process of registering the SSL/TLS Certificate takes around 30 minutes, this is why you had to do this before we got started.

Add the Certificate stack to the root stack:

// lib/root-stack.ts
// ...other imports
import CertificateNestedStack from './nested-stacks/certificate';
 
export class RootStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: RootStackProps) {
    super(scope, id, props);
 
    // ... other nested stacks
 
    const certificateStack = new CertificateNestedStack(this, 'CertificateStack', {
      certificateArn: props.certificateArn
    });
  }
}

CDN Stack (CloudFront)

Next, we'll work on adding the code to provision our CloudFront CDN.

Let's create a new file in the nested-stacks directory called cloudfront.ts and add the following code in there:

import { Construct } from 'constructs';
import * as cdk from 'aws-cdk-lib'; 
import * as s3 from 'aws-cdk-lib/aws-s3'; 
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront'; 
import * as cloudfrontOrigins from 'aws-cdk-lib/aws-cloudfront-origins'; 
import * as iam from 'aws-cdk-lib/aws-iam'; 
import * as acm from 'aws-cdk-lib/aws-certificatemanager'; 
import * as fs from 'fs';
 
interface CloudFrontNestedStackProps extends cdk.NestedStackProps {
    originBucketArn: string;
    certificate: acm.ICertificate;
    cloudFrontFunctionPath: string;
    domainName: string;
}
 
export default class CloudFrontNestedStack extends cdk.NestedStack {
    public distribution: cloudfront.Distribution;
    constructor(scope: Construct, id: string, props: CloudFrontNestedStackProps) {
        super(scope, id, props);
 
        if (!props?.originBucketArn) {
            throw new Error("The origin bucket ARN must be provided");
        }
 
        if (!props?.certificate) {
            throw new Error("An ACM Certificate instance must be provided");
        }
        
        if (!fs.existsSync(props.cloudFrontFunctionPath)) {
            throw new Error("A path to the CloudFront function must be provided")
        }
 
        const originBucket = this.importOriginBucket(props.originBucketArn);
 
        const oai = this.createOriginAccessIdentity();
        const oaiPrincipal = this.createOriginAccessIdentityPrincipal(oai);
        this.grantPermissionsToOriginAccessIdentityPrincipal(originBucket, oaiPrincipal);
 
 
        const urlMapperCloudFrontFunction = this.createCloudFrontFunction(props.cloudFrontFunctionPath);
 
        this.distribution = this.createDistribution(
            originBucket,
            oai,
            urlMapperCloudFrontFunction,
            props.certificate,
            [`${props.domainName}`, `www.${props.domainName}`]
        );
 
        this.createOutputs();
    }
 
    private importOriginBucket(bucketArn: string): s3.IBucket {
        return s3.Bucket.fromBucketArn(this, 'ImportedBucket', bucketArn)
    }
 
    private createOriginAccessIdentity(): cloudfront.OriginAccessIdentity {
        return new cloudfront.OriginAccessIdentity(this, 'OriginAccessIdentity');
    }
 
    private createOriginAccessIdentityPrincipal(
        originAccessIdentity: cloudfront.OriginAccessIdentity
    ): iam.CanonicalUserPrincipal {
        return new iam.CanonicalUserPrincipal(
            originAccessIdentity.cloudFrontOriginAccessIdentityS3CanonicalUserId
        );
    }
 
    private grantPermissionsToOriginAccessIdentityPrincipal(
        originBucket: s3.IBucket,
        originAccessIdentityPrincipal: iam.CanonicalUserPrincipal
    ) {
        // Must explicitly create and attach policy because bucket is being imported
        const policyStatement = new iam.PolicyStatement({
            sid: 's3BucketPublicRead',
            effect: iam.Effect.ALLOW,
            actions: ['s3:GetObject'],
            principals: [originAccessIdentityPrincipal],
            resources: [`${originBucket.bucketArn}/*`]
        }); 
 
        if(!originBucket.policy) {
            const bucketPolicy = new s3.BucketPolicy(this, 'Policy', { 
                bucket: originBucket 
            });
 
            bucketPolicy.document.addStatements(policyStatement);
        } else {
            originBucket.policy.document.addStatements(policyStatement);
        }
  
        originBucket.addToResourcePolicy(policyStatement);
    }
 
    createCloudFrontFunction(cloudFrontFunctionPath: string) {
        return new cloudfront.Function(this, 'CloudFrontFunction', {
            code: cloudfront.FunctionCode.fromFile({ filePath: cloudFrontFunctionPath })
        });
    }
 
    private createDistribution(
        originBucket: s3.IBucket,
        originAccessIdentity: cloudfront.IOriginAccessIdentity,
        cloudFrontFunction: cloudfront.IFunction,
        certificate: acm.ICertificate,
        domainNames: string[]
    ) {
        return new cloudfront.Distribution(this, 'CloudFrontDistribution', {
            comment: "Single page application CloudFront distribution", 
            defaultRootObject: 'index.html',
            minimumProtocolVersion: cloudfront.SecurityPolicyProtocol.TLS_V1_2_2021,
            defaultBehavior: {
                origin: new cloudfrontOrigins.S3Origin(originBucket, {
                    originAccessIdentity: originAccessIdentity
                }),
                functionAssociations: [
                    {
                        eventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
                        function: cloudFrontFunction
                    }
                ],
                compress: true,
                allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
                viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
            },
            errorResponses: [
                {
                    ttl: cdk.Duration.minutes(30),
                    httpStatus: 403,
                    responseHttpStatus: 200,
                    responsePagePath: '/404.html'
                }
            ],
            certificate: certificate,
            domainNames: domainNames
        });
 
    }
 
    createOutputs() {
        new cdk.CfnOutput(this, 'CloudFrontDistributionURL', {
            description: "CloudFront distribution URL",
            value: `https://${this.distribution.distributionDomainName}`,
        })
    }
}

First, we import the origin bucket by passing the originBucketArn the stack expects to receive as an argument.

Second, we create an origin access identity which will allow the CloudFront distribution to access the S3 origin bucket.

Finally, we proceed to create the cloudfront distribution by passing all the dependencies it needs, including the origin bucket, origin access identity, URL mapper CloudFront function, a reference to the ACM certificate and the domain names we want associating with the distribution.

Next, add the CloudFront stack to the root stack:

// ... other imports
import CertificateNestedStack from './nested-stacks/certificate';
 
export class RootStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: RootStackProps) {
    super(scope, id, props);
 
    const s3Stack = new ObjectStorageNestedStack(this, 'ObjectStorageStack', {
      buildAssetsPath: path.join(__dirname, '..', 'assets', 'ui', 'out'),
    }); 
 
    const certificateStack = new CertificateNestedStack(this, 'CertificateStack', {
      certificateArn: props.certificateArn
    });
 
    const cdnStack = new CloudFrontNestedStack(this, 'CDNStack', {
      originBucketArn: s3Stack.originBucket.bucketArn,
      certificate: certificateStack.certificate,
      cloudFrontFunctionPath: path.join(__dirname, 'nested-stacks', 'url-mapper.js'),
      domainName: props.domainName,
    });
 
    cdnStack.addDependency(s3Stack);
    cdnStack.addDependency(certificateStack);
  }
}

The only difference this time is that we add some dependencies for the s3Stack and the certificateStack. This tells CloudFront to make sure those two stacks are created before it provisions the CloudFront distribution.

URL Rewrites at the Edge

function handler (event) {
  var request = event.request;
  var uri = request.uri;
 
  if (uri.endsWith('/')) {
    request.uri += 'index.html';
  } else if (!uri.includes('.')) {
    request.uri += '.html';
  }
 
  return request;
}

The CloudFront function is triggered just before a viewer's request reaches the origin S3 bucket. If a viewer asks for the root path, it sends back the index.html. For other paths, it adds a .html extension. So, if a viewer requests /posts/spa-infra-on-aws-hands-on resource, the CloudFront function steps in, adjusts the requesting file to /posts/spa-infra-on-aws-hands-on.html, and then sends it to the S3 bucket. This ensures that the S3 bucket locates and sends back the right file.

DNS Stack (Route 53)

Let's create a new file called lib/nested-stacks/dns.ts and add the following code:

import * as cdk from 'aws-cdk-lib';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
import * as route53 from 'aws-cdk-lib/aws-route53';
import * as route53targets from 'aws-cdk-lib/aws-route53-targets';
import { Construct } from 'constructs';
 
interface DnsNestedStackProps extends cdk.NestedStackProps {
    distribution: cloudfront.Distribution;
    domainName: string;
    hostedZoneId: string;
}
 
export default class DnsNestedStack extends cdk.NestedStack {
 
    constructor(scope: Construct, id: string, props: DnsNestedStackProps) {
        super(scope, id, props);
 
        if (!props.distribution) {
            throw new Error("A cloudfront distribution instance must be provided");
        }
 
        if (!props.domainName) {
            throw new Error("The domain name must be provided");
        }
 
        if (!props.hostedZoneId) {
            throw new Error("The hosted zone id must be provided");
        }
 
        const hostedZone = this.importHostedZone(props.hostedZoneId, props.domainName);
 
        this.createAliasRecord(hostedZone, props.distribution, props.domainName);
        this.createAliasRecord(hostedZone, props.distribution, `www.${props.domainName}`);
    }
 
    private importHostedZone(hostedZoneId: string, domainName: string) {
        return route53.HostedZone.fromHostedZoneAttributes(this, 'ImportedHostedZone', {
            hostedZoneId: hostedZoneId,
            zoneName: domainName,
        });
    }
 
    private createAliasRecord(
        hostedZone: route53.IHostedZone, 
        distribution: cloudfront.IDistribution,
        domainName: string,
    ): route53.ARecord {
        return new route53.ARecord(this, `SPAAliasRecord-${domainName}`, {
            zone: hostedZone,
            target: route53.RecordTarget.fromAlias(
                new route53targets.CloudFrontTarget(distribution)
            ),
            recordName: domainName,
        });
    }
}

In this section, we're working with the hosted zone associated with our domain name and creating two A records, utilizing Amazon Route 53. An A record in DNS typically maps a domain to an IP address. However, when working with AWS services like CloudFront, we can utilize Alias A records, which allow us to map our domain names directly to our CloudFront distributions without the need to manage IP addresses. Specifically, we create one A record for the apex domain name (e.g., example.com) and another for the 'www' subdomain (e.g., www.example.com), ensuring users are directed to our distribution whether they use the apex domain or the www prefix in their URL

Let's add our final stack to the root stack:

// ... other imports
import DnsNestedStack from './nested-stacks/dns';
 
export class RootStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: RootStackProps) {
    super(scope, id, props);
 
    // omitted for brevity
 
    const dnsStack = new DnsNestedStack(this, 'DNSNestedStack', {
      distribution: cdnStack.distribution,
      domainName: props.domainName,
      hostedZoneId: props.hostedZoneId,
    })
 
    dnsStack.addDependency(cdnStack);
  }
}

We make sure to set up the cdnStack first because we need our CloudFront distribution to be ready and available before we link our domain names to it using DNS. So, we add a dependency to ensure things are created in the right order and everything is set up smoothly.

Let's deploy our infrastructure to AWS!

cdk deploy

Conclusion

And that wraps it up, everyone! I hope this journey provided you with a deeper understanding of the behind-the-scenes infrastructure that powers single page applications. Don't forget to explore the final result by visiting the repository right here.

Resources