Deploying a SPA using aws-cdk (TypeScript)
Tags: aws, cdk, spa, development
Published: 2020-04-04 09:42:23

#### I recently read a post on getting started with [aws-cdk](https://docs.aws.amazon.com/cdk/latest/guide/home.html) and it has intrigued me since then.

`aws-cdk` is Infrastructure as Code. It's like Cloudformation or Terraform but it has an added advantage. You can create your infrastructure in the programming language of your choice. This also means that you are not bound to the constructs of `.yaml` or have to learn the unknown syntax of Terraform (`.tf`). It's all in a language that you understand.

**_Note_**: Under the hood, the cdk uses Cloudformation itself.

Currently aws-cdk supports **4** languages, namely TypeScript, Python, C# and Java. In this post we shall explore how to create a Codebuild deployment of a SPA on S3 and served with Cloudfront.

TLDR; all the code is in this [repo](https://github.com/ryands17/spa-deploy-cdk) if you want to start hacking right away!

**_Note_**: This post assumes that you have aws-cli installed and configured an AWS profile with an access and secret key via `aws configure`.

We will be using the TypeScript CDK and creating 3 services.

1. An S3 bucket for hosting our static website.

2. A Cloudfront Distribution that will act as a CDN.

3. A Codebuild project that will trigger whenever your code is pushed or a PR is merged.

Let's start with the S3 Bucket. The code will be as follows:

```ts
import * as S3 from '@aws-cdk/aws-s3';

const bucket = new S3.Bucket(this, cfg.BUCKET_NAME, {
  websiteIndexDocument: 'index.html',
  websiteErrorDocument: 'index.html',
  removalPolicy: cdk.RemovalPolicy.DESTROY,
});
```

First we import the `aws-s3` package and tell the cdk to create an S3 bucket with a bucket name specified in our configuration.

**_Note_**: This configuration comes from the `.env` file that you will have to setup in the repository. Instructions are provided in the `README.md`.

We specifically add the index and error documents to be `index.html` stating that we are deploying a SPA and all redirects are to be done via the `index.html` file.

Our next step is setting up the Cloudfront distribution that will act as a CDN for our bucket. Here's the code for that.

```ts
import {
  CloudFrontWebDistribution,
  CloudFrontWebDistributionProps,
  OriginAccessIdentity,
} from '@aws-cdk/aws-cloudfront';

const cloudFrontOAI = new OriginAccessIdentity(this, 'OAI', {
  comment: `OAI for ${cfg.WEBSITE_NAME} website.`,
});

const cloudFrontDistProps: CloudFrontWebDistributionProps = {
  originConfigs: [
    {
      s3OriginSource: {
        s3BucketSource: bucket,
        originAccessIdentity: cloudFrontOAI,
      },
      behaviors: [{ isDefaultBehavior: true }],
    },
  ],
};

const cloudfrontDist = new CloudFrontWebDistribution(
  this,
  `${cfg.WEBSITE_NAME}-cfd`,
  cloudFrontDistProps
);
```

First we import our necessary modules from the `aws-cloudfront` package.

Then we create an **Origin Access Identity (OAI)**, for our Cloudfront distribution. This will allow only Cloudfront to access our static website from S3 and if someone tries to access it otherwise, it will be denied. So everyone would be able to view the website only via the Cloudfront URL.

Lastly, we create the Cloudfront distribution, providing it the props that it requires, the main one being the S3 bucket. 

See how we pass the `bucket` variable initialized while creating the S3 Bucket. This ensures that the order of resources will matter; S3 bucket first, then the cloudformation distribution.

We also pass our OAI created above in the `originAccessIdentity` prop.


The next part would be adding a policy for our S3 bucket to only accept requests from Cloudfront. And to add policies, the one service that comes to mind is IAM!

So let's create a policy to restrict the S3 bucket access to Cloudfront only.

```ts
const cloudfrontS3Access = new IAM.PolicyStatement();
cloudfrontS3Access.addActions('s3:GetBucket*');
cloudfrontS3Access.addActions('s3:GetObject*');
cloudfrontS3Access.addActions('s3:List*');
cloudfrontS3Access.addResources(bucket.bucketArn);
cloudfrontS3Access.addResources(`${bucket.bucketArn}/*`);
cloudfrontS3Access.addCanonicalUserPrincipal(
  cloudFrontOAI.cloudFrontOriginAccessIdentityS3CanonicalUserId
);
```

In the above code, we create a new IAM Policy named `cloudfrontS3Access` and we add some specific actions to it. The actions being getting all the objects and the bucket as well.

In the `addResources` method, we specify the resources. Now we not only want the bucket itself but also the entire contents inside the bucket. Which is why we have provided two resources.

```ts
cloudfrontS3Access.addResources(bucket.bucketArn);
cloudfrontS3Access.addResources(`${bucket.bucketArn}/*`);
```

The first one is for the bucket and the second for the contents inside the bucket.

Lastly we add as the **Principal**, the origin access identity that we created above. Only what's in the `Principal` can access the specified resource. So here, only our Cloudfront distribution can access this resource.

Now, we have created this policy but not added it anywhere. Where could it likely be added? You guessed it right! In our S3 Bucket policy. So we have to tell the bucket in some way that this policy should be attached and the way we do it is below

```ts
bucket.addToResourcePolicy(cloudfrontS3Access);
```

We are telling our S3 Bucket to add the policy `cloudfrontS3Access`.


Now we are moving on to the last step of our development, i.e. creating a Codebuild project.

First, we create a GitHub repository source that Codebuild can use.

**_Note_**: You can create a Bitbucket repo in the same manner as well.

```ts
const repo = Codebuild.Source.gitHub({
  owner: cfg.REPO_OWNER,
  repo: cfg.REPO_NAME,
  webhook: true,
  webhookFilters: webhooks,
  reportBuildStatus: true,
});
```

The above code will create our repository that will act as a source to our Codebuid Project. We have passed the owner of the repo and the repository name as well.

You must have noticed that we have passed something called `webhooks` set to `true` and also `webhookFilters`. What are those?

Webhook filters allow you to run the build on any branch based on the conditions and action on the branch.

We have added a webhook in the following manner

```ts
const webhooks: Codebuild.FilterGroup[] = [
  Codebuild.FilterGroup.inEventOf(
    Codebuild.EventAction.PUSH,
    Codebuild.EventAction.PULL_REQUEST_MERGED
  ).andHeadRefIs(cfg.BUILD_BRANCH),
];
```

This webhook states that on PUSH and PULL REQUEST MERGED on the branch specified in our config, initiate the build runner in Codebuild. As an example, we will be using the `master` branch. So any push or any PR merge will trigger the build.

Lastly, we shall combine all this in creating our Codebuild pipeline as shown below.

```ts
import * as cdk from '@aws-cdk/core';
import * as Codebuild from '@aws-cdk/aws-codebuild';

const project = new Codebuild.Project(this, `${cfg.WEBSITE_NAME}-build`, {
  buildSpec: Codebuild.BuildSpec.fromSourceFilename('buildspec.yml'),
  projectName: `${cfg.WEBSITE_NAME}-build`,
  environment: {
    buildImage: Codebuild.LinuxBuildImage.STANDARD_3_0,
    computeType: Codebuild.ComputeType.SMALL,
  },
  source: repo,
  timeout: cdk.Duration.minutes(20),
});
```

Here we are telling Codebuild to create a project for the source that we have added above (via GitHub) and we specify parameters related to the build images and timeout.

One last thing left right now. Our Codebuild setup needs access to S3 and Cloudfront to push the build and to create an invalidation respectively.

So let's add a couple of policies to our codebuild project.

```ts
// iam policy to push your build to S3
project.addToRolePolicy(
  new IAM.PolicyStatement({
    effect: IAM.Effect.ALLOW,
    resources: [bucket.bucketArn, `${bucket.bucketArn}/*`],
    actions: [
      's3:GetBucket*',
      's3:List*',
      's3:GetObject*',
      's3:DeleteObject',
      's3:PutObject',
    ],
  })
);

// iam policy to invalidate cloudfront dsitribution's cache
project.addToRolePolicy(
  new IAM.PolicyStatement({
    effect: IAM.Effect.ALLOW,
    resources: ['*'],
    actions: [
      'cloudfront:CreateInvalidation',
      'cloudfront:GetDistribution*',
      'cloudfront:GetInvalidation',
      'cloudfront:ListInvalidations',
      'cloudfront:ListDistributions',
    ],
  })
);
```

This will give you access to perform the necessary operations on the S3 bucket and cloudfront distibution.

So we're done and the only thing required now for us to test is to create a repository with any SPA using either [create-react-app](https://create-react-app.dev/docs/getting-started/) or [vue-cli](https://cli.vuejs.org/).

Then replace all the config variables with the one's related to the repository and then run `npm run deploy -- --profile <profileName>` where `profileName` is the one you configured with the `aws-cli`.

I have added a sample `buildspec.yml` below that you can tweak and add in your repository.

```yml
version: 0.2
phases:
  install:
    runtime-versions:
      nodejs: 10
  pre_build:
    commands:
      - yarn
  build:
    commands:
      - echo Build started on `date`
      - yarn build
      - cd build
      - aws s3 sync . s3://${S3_BUCKET} --exclude "*.js.map" --delete
      - aws cloudfront create-invalidation --distribution-id ${CLOUDFRONT_ID} --paths "/index.html"
    finally:
      - echo Build completed on `date`
```

Here, I have used Codebuild's environment variables to refer to the S3 bucket and Cloudfront distribution. You can add those in the Codebuild build project from the console or directly add it in the file above as configuration (I have done that in the repo!).

Thanks for reading! I shall add more posts on `aws-cdk` in this series :)