Hosting SPA with Route53, S3, and CloudFront by CDK for Terraform + TypeScript
Published: 2022-03-15 01:37:13

## Motivation
- For my new frontend project, it will work on CloudFront + S3. I'd like to know how it works.
- I'd like to know if CDK for Terraform is beneficial or not.
- I just want to define AWS resources by Terraform + TypeScript.

## What is AWS CDK for Terraform?
AWS CDK stands for AWS Cloud Development Kit.
It's an OSS framework to define cloud application resources by using your familiar programming languages including TypeScript, Python, Java, etc.

When we define our cloud resources, CDK will generate a CloudFormation template.
By using it, the actual resources are created.

From 2020, CDK has started supporting Terraform. So we can choose Terraform instead of CloudFormation when we use CDK.
It means we can define other cloud resources like GCP or Azure as well.

## How Route53, CloudFront, and S3 work together?
Before writing the code, let me describe how SPA work with Route53, CloudFront, and S3.

Let's say I have registered `example.com` domain in Route53.
And i'd like to host a SPA with `blog.example.com`.
To achieve it, the following settings are necessary.
- Creating an SSL certification by ACM
- Creating a bucket on S3 that will have the SPA related files like HTML, JavaScript, CSS, etc
- Creating a CloudFront distribution
- Associating the distribution with the certification and the bucket
- Associating `blog.example.com` with the distribution

Let's configure them with CDK for Terraform + TypeScript.

### Creating a project
First of all, `cdktf-cli` is necessary.
It can be installed via npm.
```
npm i -g cdktf-cli@latest
```

After the installation, you can create a project of CDK for Terraform.
This time I'd like to use TypeScript. So the command to create a project is like this
```
mkdir my-cdktf-project
cd my-cdktf-project
cdktf init --template=typescript
```

After executing the above command, you are asked to determine the following settings.
- Whether you use Terraform Cloud as a remote state or not
- Project name
- Project description

```
Do you want to continue with Terraform Cloud remote state management? (Y/n) → n Since I use S3 as a remote state this time
Project Name (my-cdktf-project) → empty
Project Description (A simple getting started project for cdktf.) → empty
```

After that, the project is ready with the following structure.
```
my-cdktf-project/
├ .gitignore
├ .npmrc
├ __tests__/
├ cdktf.json
├ help/
├ jest.config.js
├ main.ts
├ node_modules/
├ package-lock.json
├ package.json
├ setup.js
└ tsconfig.json
```

`main.ts` is the place to define the resources. (I’ll define all the resources in the place this time)
The initial code in the file is the following.

```typescript
import { Construct } from "constructs";
import { App, TerraformStack } from "cdktf";

class MyStack extends TerraformStack {
  constructor(scope: Construct, name: string) {
    super(scope, name);

    // define resources here
  }
}

const app = new App();
new MyStack(app, "my-cdktf-project");
app.synth();

```

## Configure Provider and remote state
### Provider
The provider is AWS this time.
In order to configure that, `@cdktf/provider-aws` is necessary to be installed.
```
npm i @cdktf/provider-aws
```

Then you can use the provider.
```typescript
import { AwsProvider } from "@cdktf/provider-aws";

class MyStack extends TerraformStack {
  constructor(scope: Construct, name: string) {
    super(scope, name);

    // provider
    new AwsProvider(this, "default", {
      region: "ap-northeast-1",
    });
```

It's the same as using HCL

```hcl
provider "default" {
  region = "ap-northeast-1"
}
```

To create SSL certification via ACM that is defined later, it's necessary to specify us-east-1 region.
So let me define another provider for that.
```typescript
const awsProviderUsEast = new AwsProvider(this, "use_east", {
  region: "us-east-1",
  alias: "us-east-1"
});
```

### Remote state
I'd like to use S3 for the remote state.
It's defined like this
```typescript
import { App, S3Backend, TerraformStack } from "cdktf";
...
new S3Backend(this, {
  bucket: "hogehoge-tfstate",
  key: "website",
  region: "ap-northeast-1"
});
```

## Creating an SSL certification
With the following code, the certification for `blog.example.com` is created with DNS validation.

```typescript
import { AwsProvider, route53, acm } from "@cdktf/provider-aws";
...

const dataAwsRoute53Zone = new route53.DataAwsRoute53Zone(this, "zone", {
  name: "example.com"
});

const acmCertificate = new acm.AcmCertificate(this, "cert", {
  domainName: "blog.example.com",
  validationMethod: "DNS",
  provider: awsProviderUsEast,
  lifecycle: {
    createBeforeDestroy: true
  }
});
    
const record = new route53.Route53Record(this, "validation_record", {
  name: acmCertificate.domainValidationOptions("0").resourceRecordName,
  type: acmCertificate.domainValidationOptions("0").resourceRecordType,
  records: [acmCertificate.domainValidationOptions("0").resourceRecordValue],
  zoneId: dataAwsRoute53Zone.zoneId,
  ttl: 60
});

new acm.AcmCertificateValidation(this, "certification_validation", {
  certificateArn: acmCertificate.arn,
  validationRecordFqdns: [record.fqdn],
  provider: awsProviderUsEast
});
```

## Creating a bucket on S3
HTML, JavaScript, CSS files will be in this bucket.
```typescript
import { AwsProvider, s3, route53, acm } from "@cdktf/provider-aws";
...

const s3Bucket = new s3.S3Bucket(this, "blog_example_com", {
  bucket: "blog.example.com"
});
```

## Creating a CloudFront distribution
Next, will create OAI first.
OAI(origin access identity) is a special CloudFront user.
By associating it with a Cloudfront distribution, CloudFront can access an S3 bucket that doesn't allow public access.
```typescript
import { AwsProvider, s3, route53, acm, cloudfront } from "@cdktf/provider-aws";
...

const oai = new cloudfront.CloudfrontOriginAccessIdentity(this, "oai", {
  comment: "blog.example.com"
});
```

And the S3 bucket is supposed to be accessed by the OAI.
So I defined the following bucket policy.
```typescript
import { AwsProvider, s3, route53, acm, cloudfront, iam } from "@cdktf/provider-aws";
...

const policyDocument = new iam.DataAwsIamPolicyDocument(this, "policy_document", {
  statement: [{
    actions: ["s3:GetObject"],
    resources: [`${s3Bucket.arn}/*`],
    principals: [{
      type: "AWS",
      identifiers: [oai.iamArn]
    }]
  }]
});

new s3.S3BucketPolicy(this, "connect_bucket_and_policy", {
  bucket: s3Bucket.id,
  policy: policyDocument.json
});
```

And the following code is the distribution definition.
In the code, the S3 bucket is defined as the origin and the certification is attached.

```typescript
const distribution = new cloudfront.CloudfrontDistribution(this, "distribution", {
  enabled: true,
  defaultRootObject: "index.html",
  aliases: ["blog.example.com"],
  customErrorResponse: [
    {
      errorCode: 403,
      responseCode: 200,
      responsePagePath: "/"
    }
  ],
  origin: [
    {
      originId: s3Bucket.id,
      domainName: s3Bucket.bucketRegionalDomainName,
      s3OriginConfig: {
        originAccessIdentity: oai.cloudfrontAccessIdentityPath
      }
    }
  ],
  defaultCacheBehavior: {
    allowedMethods: ["GET", "HEAD"],
    cachedMethods: ["GET", "HEAD"],
    targetOriginId: s3Bucket.id,
    forwardedValues: {
      queryString: false,
        cookies: {
          forward: "none"
      }
    },
    viewerProtocolPolicy: "redirect-to-https",
      minTtl: 0,
      defaultTtl: 0,
      maxTtl: 0
    },
    restrictions: {
      geoRestriction: {
        restrictionType: "none"
      }
    },
    viewerCertificate: {
      acmCertificateArn: acmCertificate.arn,
      sslSupportMethod: "sni-only"
    }
  });
```
You might already know, SPA doesn’t have an actual HTML file for all the paths.
So with the following configuration, the root `index.html` is returned in that case.
```typescript
customErrorResponse: [
    {
      errorCode: 403,
      responseCode: 200,
      responsePagePath: "/"
    }
  ],
```

## Associating `blog.example.com` with the distribution
Alias record can use for some AWS endpoints like CloudFront distribution, S3 bucket, ELB, and the endpoint returns its IP address.
I'd like to associate `blog.example.com` with the IP address of the distribution.
So the code is the following
```typescript
new route53.Route53Record(this, "website", {
  zoneId: dataAwsRoute53Zone.id,
  name: "blog.example.com",
  type: "A",
  alias: [{
    name: distribution.domainName,
    zoneId: distribution.hostedZoneId,
    evaluateTargetHealth: false
  }]
});
```

## Create resources by cdktf
The code is ready to be applied, so to confirm the change by above code, you can run the following command
```
cdktf plan
```

To apply them
```
cdktf apply
```
After a while, all the resources defined with the above code will be created. Then you can see your SPA. Of course you need to upload your HTML, JS, CSS, etc files to the S3 bucket.

## Is CDK for Terraform beneficial?
### Type checking
Type checking is a benefit.
TypeScript informs us when the type of a value doesn't have the expected type.

### Needs to translate the Terraform syntax to CDK's
When you define a data source, you can write it by HCL like
```hcl
data "aws_iam_policy_document" "example" {
```
But with CDK, it becomes
```typescript
new iam.DataAwsIamPolicyDocument(this, "example", {
```
You need to translate the Terraform syntax to CDK's every time. it's a bother a bit.

### Other
- With CDK, need to instantiate a class every time we define a resource or a data source. HCL is simpler and more concise.
- CDK for Terraform is still beta. It's not recommended to use it in production.

## Conclusion
If you are already familiar with Terraform, you can't find reasone to use CDK for Terraform i think.
But if you are newbie of IaC and want to start with your familiar programing language, it might be comfortable.