top of page

Hosting a static Single Page Application on AWS using the CDK

9 jan 2022

8 min. leestijd

1

10

A complete guide on hosting a SPA on AWS including automatic CI/CD



Two years ago I wrote a blog post on how you could use the AWS CDK to build scheduled Lambda functions. At that time the CDK was only 1 year old and had just become generally available for .NET and Java, next to the existing versions for Typescript and Python.


In the past 2 years, the AWS CDK gained more and more traction, and it has established itself in the IaC space. About a month ago, during re:Invent 2021, AWS announced the general availability of the CDK v2, including a preview for Go.

For me, the biggest value of the CDK is its concise method for representing infrastructure that could be easily abstracted into constructs (building blocks) together with the ability to use it with the programming language of my choice. The success of the construct programming model shows in the fact that besides the classic CDK, there are now also projects that apply the CDK concepts to define Kubernetes applications (CDK8s) and even Terraform based infrastructure (CDKtf by HashiCorp). Useful constructs for all three CDK versions can be found in the recently launched Construct Hub, which is an online catalog for construct libraries created by the community, AWS and cloud technology providers.

In the meantime, we also adopted the CDK for many use cases at HATCH. One of them is hosting Single Page Applications we create for our clients.

In the next steps I will explain how to host an existing SPA by adding some CDK magic.



Prerequisites


  • Install Node.js

  • Install AWS CLI and configure it

  • Install AWS CDK v2 and bootstrap it

  • Have an existing SPA (Angular, React, Vue, …)

  • Have a domain + matching Hosted Zone on AWS



The start


We start off with an existing SPA. The one I am using is a very minimalistic Angular sample app that only has one page, the home page.



Let’s run the app to see what it looks like.

npm start


If we want to host this application, we first need to create a distributable package. In angular you do this by running the command below.

npm run build

After running this command, you’ll see the distributable files in the ‘/dist‘ folder on the root level.



Adding in the CDK


In order to add the CDK into this application we need a folder in which we will put all infrastructure code. We’ll create it in the root of the application.

mkdir infrastructure

Next up is initialising this folder with the CDK. As my Angular application is written in Typescript, I’m also using Typescript for my CDK code.

cd infrastructure/  
cdk init app --language typescript

Doing this results in the following structure:




Hosting


Now we can get started writing the actual code. First, we’ll focus on the hosting part.



Building the stack


We will need a CDK stack that defines all infrastructure needed to host our application as a static website. Therefore we will delete the ‘infrastructure-stack.ts’ file and create a new ‘static-site.stack.ts’ file.

Within this stack, we’re going to create the following infrastructure:

  • An S3 bucket to store the static files of the application (content of the ‘dist’ folder)

  • A CloudFront distribution to serve the application files over HTTPS

  • An Origin Access Identity to restrict the S3 file access to the CloudFront distribution only

  • An SSL certificate for the domain we are going to use

  • Route53 TXT records to validate the ownership of the domain in order to request the SSL certificate

  • Route53 A records to point the domain to the CloudFront distribution

  • A one time S3 deployment to put the static files in the S3 bucket

After adding all necessary code, our file looks like this:


websocket-handler.ts

export const handleRequests: APIGatewayProxyHandler = async (event, _context) => {
    const dynamodb = new DynamoDB.DocumentClient();
    const lambda = new Lambda();
    const connectionTable = process.env.WEBSOCKET_CONNECTIONS_TABLE_NAME;
    const {body, requestContext: {connectionId, routeKey}} = event;

    switch (routeKey) {
        case '$connect':
            await dynamodb.put({
                TableName: connectionTable,
                Item: {
                    connectionId,
                    // Forget this connection after 1 hour
                    ttl: (new Date().getTime() / 1000) + 3600
                }
            }).promise();
            break;

        case '$disconnect':
            await dynamodb.delete({
                TableName: connectionTable,
                Key: {connectionId}
            }).promise();
            break;

        case 'generateReport':
            const reportDefinition = JSON.parse(body).data as ReportDefinition;
            const event: GenerateReportEvent = {connectionId: connectionId, reportDefinition: reportDefinition};
            await lambda.invoke({
                FunctionName: process.env.WEBSOCKET_REPORT_GENERATION_LAMBDA_NAME as string,
                InvocationType: 'Event',
                Payload: JSON.stringify(event),
            }).promise();
            break;

        case '$default':
        default:
            const apiGatewayManagementApi = new ApiGatewayManagementApi({
                endpoint: process.env.APIG_ENDPOINT
            });
            
            await apiGatewayManagementApi.postToConnection({
                ConnectionId: connectionId,
                Data: `Action ${routeKey} is not supported`
            }).promise();
    }

    return Response.success({});
}

As you can see, we are creating CloudFormation output values for the S3 bucket name and the CloudFront distribution ID. We’ll need to use these outputs in the CI/CD stack that we’ll build later on.

Another thing worth mentioning is the SSL certificate being requested in the ‘us-east-1’ region. This is done because this is the only region currently supported by CloudFront.



Deploying it


We are about to deploy this infrastructure, but before we can do this, we will need to add the code that creates our stack to the ‘infrastructure.ts’ file.



We are creating the stack in a specific account and region. For the domain name, I’m using the subdomain ‘static-cdk.hatch.be’.

To start the actual deployment we execute the command below inside the ‘infrastructure’ folder:

cdk deploy cdk-web-static-stack

After a few seconds, the CDK will provide you with an overview of all changes that will be done in order to deploy the infrastructure. It will also ask you to confirm the deployment.



When you confirm the deployment, you’ll see all the resources being created one by one.



If you want, you can also follow up in more detail in the AWS CloudFormation console.



When the deployment is done, you’ll see that all the infrastructure is present.

The S3 bucket:



The CloudFront distribution:



The DNS records in Route53:



The SSL certificate:



When we now browse to our domain, we can see the content of our application being served over HTTPS.


As a final step, we push this code to a GitHub repository.



CI/CD


The initial version of the application is now being hosted, but we also want it to be updated automatically in case there are future changes in the application code. We do this by creating a CI/CD pipeline.



Building the stack


We create a new file for the CDK stack that defines all infrastructure needed for our CI/CD pipeline. Let’s call this file ‘ci-cd.stack.ts’. Within this stack we’re going to create following infrastructure:

  • A CodePipeline with a ‘Source’ and ‘Build’ stage

  • A GitHub source action that will download the code from our GitHub repository

  • A CodeBuild project that will

  • create the application-distributable package

  • push it to the S3 bucket

  • create a CloudFront invalidation

  • An SNS topic + subscription to get notifications in case we have build failures

After adding all the code, our file looks like this:


ci-cd.stack.ts

import {SecretValue, Stack, StackProps} from 'aws-cdk-lib';
import {Construct} from 'constructs';
import {Artifact, Pipeline} from 'aws-cdk-lib/aws-codepipeline';
import {CodeBuildAction, GitHubSourceAction} from 'aws-cdk-lib/aws-codepipeline-actions';
import {BuildSpec, LinuxBuildImage, PipelineProject} from 'aws-cdk-lib/aws-codebuild';
import {Effect, PolicyStatement} from 'aws-cdk-lib/aws-iam';
import {Subscription, SubscriptionProtocol, Topic} from 'aws-cdk-lib/aws-sns';
import {SnsTopic} from 'aws-cdk-lib/aws-events-targets';
import {identifyResource} from './config-util';

export interface CiCdProps extends StackProps {
  readonly resourcePrefix: string;
  readonly distributionId: string;
  readonly bucket: string;
  readonly repo: string;
  readonly repoOwner: string;
  readonly repoBranch: string;
  readonly githubTokenSecretId: string;
  readonly buildAlertEmail: string;
}

/**
 * Infrastructure that creates a CI/CD pipeline to deploy a static site to an S3 bucket.
 * The pipeline checks out the source code from a GitHub repository, builds it, deploys it to the S3 bucket and invalidates the CloudFront distribution.
 */
export class CiCdStack extends Stack {
  constructor(parent: Construct, id: string, props: CiCdProps) {
    super(parent, id, props);

    // Create the source action
    const github_token = SecretValue.secretsManager(props.githubTokenSecretId, {jsonField: 'github-token'});
    const sourceOutput = new Artifact('SourceOutput');
    const sourceAction = new GitHubSourceAction({
      actionName: 'SOURCE',
      owner: props.repoOwner,
      repo: props.repo,
      branch: props.repoBranch,
      oauthToken: github_token,
      output: sourceOutput
    });

    // Create the build action
    const webBuildProject = this.createBuildProject(props.resourcePrefix, props.distributionId, props.bucket, props.buildAlertEmail, props.env!.account!);
    const buildAction = new CodeBuildAction({
      actionName: 'BUILD_DEPLOY',
      project: webBuildProject,
      input: sourceOutput,
    });

    // Create the pipeline
    const pipelineName = identifyResource(props.resourcePrefix, 'pipeline');
    new Pipeline(this, pipelineName, {
      pipelineName: pipelineName,
      stages: [
        {
          stageName: 'Source',
          actions: [sourceAction],
        },
        {
          stageName: 'Build',
          actions: [buildAction],
        }
      ]
    });
  }

  private createBuildProject(resourcePrefix: string, distributionId: string, staticWebsiteBucket: string, buildAlertEmail: string, account: string) {
    const buildProject = new PipelineProject(this, identifyResource(resourcePrefix, 'build'), {
      buildSpec: BuildSpec.fromObject({
        version: '0.2',
        phases: {
          install: {
            'runtime-versions': {
              nodejs: 'latest'
            },
            commands: [
              'npm install',
            ],
          },
          build: {
            commands: [
              'npm run build',
            ],
          },
          post_build: {
            commands: [
              `aws s3 sync "dist" "s3://${staticWebsiteBucket}" --delete`,
              `aws cloudfront create-invalidation --distribution-id ${distributionId} --paths "/*"`
            ]
          }
        }
      }),
      environment: {
        buildImage: LinuxBuildImage.STANDARD_5_0,
      },
    });

    const codeBuildS3ListObjectsPolicy = new PolicyStatement({
      effect: Effect.ALLOW,
      actions: ['s3:GetObject','s3:GetBucketLocation','s3:ListBucket','s3:PutObject','s3:DeleteObject','s3:PutObjectAcl'],
      resources: [`arn:aws:s3:::${staticWebsiteBucket}`,`arn:aws:s3:::${staticWebsiteBucket}/*`],
    });
    buildProject.role?.addToPrincipalPolicy(codeBuildS3ListObjectsPolicy);
    const codeBuildCreateInvalidationPolicy = new PolicyStatement({
      effect: Effect.ALLOW,
      actions: ['cloudfront:CreateInvalidation'],
      resources: [`arn:aws:cloudfront::${account}:distribution/${distributionId}`],
    });
    buildProject.role?.addToPrincipalPolicy(codeBuildCreateInvalidationPolicy);

    // Add alert notifications on build failure
    const alertsTopic = new Topic(this, identifyResource(resourcePrefix, 'notifications'), {
      topicName: identifyResource(resourcePrefix, 'notifications'),
      displayName: `${resourcePrefix} pipeline failures`,
    });

    // Subscribe to these alerts using email
    new Subscription(this, identifyResource(resourcePrefix, 'notifications-subscription'), {
      protocol: SubscriptionProtocol.EMAIL,
      endpoint: buildAlertEmail,
      topic: alertsTopic
    });

    buildProject.onBuildFailed(identifyResource(resourcePrefix, 'build-failed'), {target: new SnsTopic(alertsTopic)});

    return buildProject;
  }
}

The source action needs the correct permissions to download the source code from the GitHub repository. We will provide these permissions by specifying a newly created personal access token and storing this one in the AWS SecretsManager.

aws secretsmanager create-secret --name /static-cdk/cicd/github_token --secret-string '{"github-token":"<<YOUR GITHUB TOKEN>>" }'

The role used by the CodeBuild project also requires permissions to sync the distributable files with the S3 bucket and invalidate the CloudFront distribution. These permissions are granted to the role by adding them explicitly to the principal policy of the Role.



Deploying it


Next up is deploying the CI/CD stack, so let’s create an instance of the stack in the ‘infrastructure.ts’ file.



In order to deploy the new distributable files to the correct bucket and invalidate the correct distribution, we import the S3 bucket name and CloudFront distribution ID we exported earlier inside our static site stack. We do this by using the same output ID’s. The imported values are passed in to the CI/CD stack.

We also provide information on the GitHub repository where the source code resides (repo, repoOwner, repoBranch) and the mail address where we want to receive notifications in case of pipeline failures.

Now, let’s deploy.

cdk deploy cdk-web-cicd-stack

Again, you’ll see all resources being created in the CloudFormation console.



During the creation of the stack, we get an email to confirm the subscription to the CI/CD pipeline failure notifications.



Once the stack is created, the first run of the CI/CD pipeline is automatically triggered. However, we won’t see any differences with the already deployed application because we did not make any changes to the application source code yet.



Now, let’s see what happens if we do so by changing the content of our home page.



When we commit and push this change to the repository, a new run of our CI/CD pipeline will be triggered automatically.



When this run completes, we can see our content changes in the hosted application.



Clean up


If you no longer need your hosted application and CI/CD pipeline, or you need to completely remove it for any other reason, you can delete all created resources with the following command.

cdk destroy --all

If you only want to remove one of the stacks, you can respectively use one of the commands below.

cdk destroy cdk-web-static-stack  
cdk destroy cdk-web-cicd-stack


Final code


The final code of this project can be found on GitHub. If you want to use it, don’t forget to specify the correct AWS account, region, repository information and any other parameter placeholders.

Thanks for reading!



9 jan 2022

8 min. leestijd

1

10

bottom of page