Back to home

Building A CDN With S3, Cloudfront And The AWS CDK

Published: Aug 8, 2021

Last updated: Aug 8, 2021

This post is #3 of a multi-part series on the AWS CDK with TypeScript written during Melbourne lockdown #6.

This post will use the AWS TypeScript CDK to deploy an S3 bucket to hold media assets, a CloudFront distribution for a content delivery network for those assets and setup a Aaaa record for that CDN through Route53.

The code will build off the work done in the first two articles of the "Working with the TypeScript AWS CDK" series.

Source code can be found here.

Prerequisites

  1. Basic familiarity with AWS CDK for TypeScript.
  2. Read the first two posts in the "Working with the TypeScript AWS CDK" series.
  3. If you want to create the Route53 record, you will need to get the ACM ARN for your certificate and have a hosted zone ID ready.

Getting started

We will clone the working repo from the previous blog post as a starting point:

$ git clone https://github.com/okeeffed/using-the-aws-cdk-with-localstack-and-aws-cdk-local building-a-cdn-with-s3-cloudfront-and-the-aws-cdk $ cd building-a-cdn-with-s3-cloudfront-and-the-aws-cdk $ npm i

At this stage, we will have the app in a basic working state.

In order to create the CDN stack, we will require the following AWS CDK libraries:

@aws-cdk/core @aws-cdk/aws-cloudfront @aws-cdk/aws-cloudfront-origins @aws-cdk/aws-s3 @aws-cdk/aws-certificatemanager @aws-cdk/aws-route53 @aws-cdk/aws-route53-targets

We can install these prior to doing any work:

npm i @aws-cdk/core @aws-cdk/aws-cloudfront @aws-cdk/aws-cloudfront-origins @aws-cdk/aws-s3 @aws-cdk/aws-certificatemanager @aws-cdk/aws-route53 @aws-cdk/aws-route53-targets

The repository that we cloned already has a upgrade script supplied. We can use this to ensure our CDK packages are at parity:

npm run upgrade

We are now at a stage to write our CDN stack.

Plan for the CDK stack

There are a few steps we need to take to create our CDN distribution.

The order that we want to go when creating our stack:

  1. We want to create an S3 bucket to host the assets.
  2. We want to ensure we get a reference to the ACM certificate from the ARN.
  3. We want to instantiate a new CloudFront distruction with a custom domain and that certificate.
  4. We need to grab the hosted zone from the attributes of the custom domain tha we created.
  5. We need to assign an ARecord and AaaaRecord for the zone targeting the CloudFront distribution.

With that outline, we are now ready to create our new stack.

Adding the CDN stack code

Create a new file at lib/cdn-stack.ts.

$ touch lib/cdn-stack.ts

Afterwards, we can add the following code:

import "dotenv/config"; import * as cdk from "@aws-cdk/core"; import * as cloudfront from "@aws-cdk/aws-cloudfront"; import * as origins from "@aws-cdk/aws-cloudfront-origins"; import * as s3 from "@aws-cdk/aws-s3"; import * as acm from "@aws-cdk/aws-certificatemanager"; import * as route53 from "@aws-cdk/aws-route53"; import * as targets from "@aws-cdk/aws-route53-targets"; export class CdnStack extends cdk.Stack { constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); // ... ready to add our code here } }

We are now ready to work through each of the steps.

Create an S3 bucket

We will create a bucket demo-cdk-assets-bucket to host the assets with a DESTROY policy for when we tear down the assets.

import "dotenv/config"; import * as cdk from "@aws-cdk/core"; import * as cloudfront from "@aws-cdk/aws-cloudfront"; import * as origins from "@aws-cdk/aws-cloudfront-origins"; import * as s3 from "@aws-cdk/aws-s3"; import * as acm from "@aws-cdk/aws-certificatemanager"; import * as route53 from "@aws-cdk/aws-route53"; import * as targets from "@aws-cdk/aws-route53-targets"; export class CdnStack extends cdk.Stack { constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); // Create the S3 bucket to host assets const assetsBucket = new s3.Bucket(this, "demo-cdk-assets-bucket", { bucketName: "demo-cdk-assets-bucket", removalPolicy: cdk.RemovalPolicy.DESTROY, }); } }

Get reference to the ACM certificate

import "dotenv/config"; import * as cdk from "@aws-cdk/core"; import * as cloudfront from "@aws-cdk/aws-cloudfront"; import * as origins from "@aws-cdk/aws-cloudfront-origins"; import * as s3 from "@aws-cdk/aws-s3"; import * as acm from "@aws-cdk/aws-certificatemanager"; import * as route53 from "@aws-cdk/aws-route53"; import * as targets from "@aws-cdk/aws-route53-targets"; export class CdnStack extends cdk.Stack { constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); const assetsBucket = new s3.Bucket(this, "demo-cdk-assets-bucket", { bucketName: "demo-cdk-assets-bucket", removalPolicy: cdk.RemovalPolicy.DESTROY, }); // Get the certificate const certificate = acm.Certificate.fromCertificateArn( this, "Certificate", // found using aws acm list-certificates --region us-east-1 "arn:aws:acm:your-zone:your-id" ); } }

Instantiate a new CloudFront distribution

import "dotenv/config"; import * as cdk from "@aws-cdk/core"; import * as cloudfront from "@aws-cdk/aws-cloudfront"; import * as origins from "@aws-cdk/aws-cloudfront-origins"; import * as s3 from "@aws-cdk/aws-s3"; import * as acm from "@aws-cdk/aws-certificatemanager"; import * as route53 from "@aws-cdk/aws-route53"; import * as targets from "@aws-cdk/aws-route53-targets"; export class CdnStack extends cdk.Stack { constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); const assetsBucket = new s3.Bucket(this, "demo-cdk-assets-bucket", { bucketName: "demo-cdk-assets-bucket", removalPolicy: cdk.RemovalPolicy.DESTROY, }); const certificate = acm.Certificate.fromCertificateArn( this, "Certificate", "arn:aws:acm:your-zone:your-id" ); // Create new CloudFront Distribution const cf = new cloudfront.Distribution(this, "cdnDistribution", { defaultBehavior: { origin: new origins.S3Origin(assetsBucket) }, domainNames: ["example-cdn.your-domain.com"], certificate, }); } }

Get the hosted zone

import "dotenv/config"; import * as cdk from "@aws-cdk/core"; import * as cloudfront from "@aws-cdk/aws-cloudfront"; import * as origins from "@aws-cdk/aws-cloudfront-origins"; import * as s3 from "@aws-cdk/aws-s3"; import * as acm from "@aws-cdk/aws-certificatemanager"; import * as route53 from "@aws-cdk/aws-route53"; import * as targets from "@aws-cdk/aws-route53-targets"; export class CdnStack extends cdk.Stack { constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); const assetsBucket = new s3.Bucket(this, "demo-cdk-assets-bucket", { bucketName: "demo-cdk-assets-bucket", removalPolicy: cdk.RemovalPolicy.DESTROY, }); const certificate = acm.Certificate.fromCertificateArn( this, "Certificate", "arn:aws:acm:your-zone:your-id" ); const cf = new cloudfront.Distribution(this, "cdnDistribution", { defaultBehavior: { origin: new origins.S3Origin(assetsBucket) }, domainNames: ["example-cdn.your-domain.com"], certificate, }); // Get the zone const zone = route53.HostedZone.fromHostedZoneAttributes( this, "dennisokeeffe-zone", { zoneName: "example-cdn.your-domain.com", hostedZoneId: "your-zone-id", } ); } }

Assign ARecord and AaaaRecord to CloudFront distribution

import "dotenv/config"; import * as cdk from "@aws-cdk/core"; import * as cloudfront from "@aws-cdk/aws-cloudfront"; import * as origins from "@aws-cdk/aws-cloudfront-origins"; import * as s3 from "@aws-cdk/aws-s3"; import * as acm from "@aws-cdk/aws-certificatemanager"; import * as route53 from "@aws-cdk/aws-route53"; import * as targets from "@aws-cdk/aws-route53-targets"; export class CdnStack extends cdk.Stack { constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); const assetsBucket = new s3.Bucket(this, "demo-cdk-assets-bucket", { bucketName: "demo-cdk-assets-bucket", removalPolicy: cdk.RemovalPolicy.DESTROY, }); const certificate = acm.Certificate.fromCertificateArn( this, "Certificate", "arn:aws:acm:your-zone:your-id" ); const cf = new cloudfront.Distribution(this, "cdnDistribution", { defaultBehavior: { origin: new origins.S3Origin(assetsBucket) }, domainNames: ["example-cdn.your-domain.com"], certificate, }); const zone = route53.HostedZone.fromHostedZoneAttributes( this, "dennisokeeffe-zone", { zoneName: "example-cdn.your-domain.com", hostedZoneId: "your-zone-id", } ); // Adding out A Record code new route53.ARecord(this, "CDNARecord", { zone, target: route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(cf)), }); new route53.AaaaRecord(this, "AliasRecord", { zone, target: route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(cf)), }); } }

Adding the stack to the CDK app

We are now ready to add the stack to our app!

Inside of bin/aws-cdk-with-typescript-foundations.ts, adjust the code to be the following:

#!/usr/bin/env node import "source-map-support/register"; import * as cdk from "@aws-cdk/core"; import { CdnStack } from "../lib/cdn-stack"; const app = new cdk.App(); new CdnStack(app, "CdnStack", { /* If you don't specify 'env', this stack will be environment-agnostic. * Account/Region-dependent features and context lookups will not work, * but a single synthesized template can be deployed anywhere. */ /* Uncomment the next line to specialize this stack for the AWS Account * and Region that are implied by the current CLI configuration. */ // env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION }, /* Uncomment the next line if you know exactly what Account and Region you * want to deploy the stack to. */ // env: { account: '123456789012', region: 'us-east-1' }, /* For more information, see https://docs.aws.amazon.com/cdk/latest/guide/environments.html */ });

Synthesizing and deploying the stack

We can run through our usual lifecycle to syntheize and deploy the stack:

$ npm run cdk synth npm run cdk synth > aws-cdk-with-typescript-foundations@0.1.0 cdk > cdk "synth" Resources: democdkassetsbucketA19A67A4: Type: AWS::S3::Bucket Properties: BucketName: demo-cdk-assets-bucket UpdateReplacePolicy: Delete DeletionPolicy: Delete Metadata: aws:cdk:path: CdnStack/demo-cdk-assets-bucket/Resource democdkassetsbucketPolicy674BC594: Type: AWS::S3::BucketPolicy Properties: Bucket: Ref: democdkassetsbucketA19A67A4 PolicyDocument: Statement: - Action: s3:GetObject Effect: Allow Principal: CanonicalUser: Fn::GetAtt: - cdnDistributionOrigin1S3Origin52472F64 - S3CanonicalUserId Resource: Fn::Join: - "" - - Fn::GetAtt: - democdkassetsbucketA19A67A4 - Arn - /* Version: "2012-10-17" Metadata: aws:cdk:path: CdnStack/demo-cdk-assets-bucket/Policy/Resource cdnDistributionOrigin1S3Origin52472F64: Type: AWS::CloudFront::CloudFrontOriginAccessIdentity Properties: CloudFrontOriginAccessIdentityConfig: Comment: Identity for CdnStackcdnDistributionOrigin123067AEF Metadata: aws:cdk:path: CdnStack/cdnDistribution/Origin1/S3Origin/Resource cdnDistribution5DCBB4A4: Type: AWS::CloudFront::Distribution Properties: DistributionConfig: Aliases: - example-cdn.your-domain.com DefaultCacheBehavior: CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6 Compress: true TargetOriginId: CdnStackcdnDistributionOrigin123067AEF ViewerProtocolPolicy: allow-all Enabled: true HttpVersion: http2 IPV6Enabled: true Origins: - DomainName: Fn::GetAtt: - democdkassetsbucketA19A67A4 - RegionalDomainName Id: CdnStackcdnDistributionOrigin123067AEF S3OriginConfig: OriginAccessIdentity: Fn::Join: - "" - - origin-access-identity/cloudfront/ - Ref: cdnDistributionOrigin1S3Origin52472F64 ViewerCertificate: AcmCertificateArn: arn:aws:acm:[redacted] MinimumProtocolVersion: TLSv1.2_2019 SslSupportMethod: sni-only Metadata: aws:cdk:path: CdnStack/cdnDistribution/Resource CDNARecord9F02271B: Type: AWS::Route53::RecordSet Properties: Name: example-cdn.your-domain.com. Type: A AliasTarget: DNSName: Fn::GetAtt: - cdnDistribution5DCBB4A4 - DomainName HostedZoneId: Fn::FindInMap: - AWSCloudFrontPartitionHostedZoneIdMap - Ref: AWS::Partition - zoneId HostedZoneId: [redacted] Metadata: aws:cdk:path: CdnStack/CDNARecord/Resource AliasRecord851000D2: Type: AWS::Route53::RecordSet Properties: Name: example-cdn.your-domain.com. Type: AAAA AliasTarget: DNSName: Fn::GetAtt: - cdnDistribution5DCBB4A4 - DomainName HostedZoneId: Fn::FindInMap: - AWSCloudFrontPartitionHostedZoneIdMap - Ref: AWS::Partition - zoneId HostedZoneId: [redacted] Metadata: aws:cdk:path: CdnStack/AliasRecord/Resource CDKMetadata: Type: AWS::CDK::Metadata Properties: Analytics: v2:deflate64:H4sIAAAAAAAAE3VQy24CMQz8Fu4hsKoQPXZZVKmHqtXyBaljkFkao8QpqqL8OwmpVC6cPB57xo9Od91aL2cv5hLmYKdFAvao004MTGpgF8RHEDVi4OgB1bB37+Z8Jneo8J4uzZaE2GVVzVJ40mkTYUKp1T/UwiefCH7/6ZZnBSeOdu/ZiU5bKqPpK1ZH9eHpQK4HwBDeLDohucmHKnitgkcd9zZZeY6Cq7JYP2I51LYbKtqV5XpjTMtyzsqxRX0Mi59upZ/Li46BaO5jcf5GPbZ4Bbh/xoU/AQAA Metadata: aws:cdk:path: CdnStack/CDKMetadata/Default Condition: CDKMetadataAvailable Mappings: AWSCloudFrontPartitionHostedZoneIdMap: aws: zoneId: [redacted] aws-cn: zoneId: [redacted] Conditions: CDKMetadataAvailable: Fn::Or: - Fn::Or: - Fn::Equals: - Ref: AWS::Region - af-south-1 - Fn::Equals: - Ref: AWS::Region - ap-east-1 - Fn::Equals: - Ref: AWS::Region - ap-northeast-1 - Fn::Equals: - Ref: AWS::Region - ap-northeast-2 - Fn::Equals: - Ref: AWS::Region - ap-south-1 - Fn::Equals: - Ref: AWS::Region - ap-southeast-1 - Fn::Equals: - Ref: AWS::Region - ap-southeast-2 - Fn::Equals: - Ref: AWS::Region - ca-central-1 - Fn::Equals: - Ref: AWS::Region - cn-north-1 - Fn::Equals: - Ref: AWS::Region - cn-northwest-1 - Fn::Or: - Fn::Equals: - Ref: AWS::Region - eu-central-1 - Fn::Equals: - Ref: AWS::Region - eu-north-1 - Fn::Equals: - Ref: AWS::Region - eu-south-1 - Fn::Equals: - Ref: AWS::Region - eu-west-1 - Fn::Equals: - Ref: AWS::Region - eu-west-2 - Fn::Equals: - Ref: AWS::Region - eu-west-3 - Fn::Equals: - Ref: AWS::Region - me-south-1 - Fn::Equals: - Ref: AWS::Region - sa-east-1 - Fn::Equals: - Ref: AWS::Region - us-east-1 - Fn::Equals: - Ref: AWS::Region - us-east-2 - Fn::Or: - Fn::Equals: - Ref: AWS::Region - us-west-1 - Fn::Equals: - Ref: AWS::Region - us-west-2 $ npm run cdk deploy > aws-cdk-with-typescript-foundations@0.1.0 cdk > cdk "deploy" This deployment will make potentially sensitive changes according to your current security approval level (--require-approval broadening). Please confirm you intend to make the following modifications: IAM Statement Changes ┌───┬────────────────────────────┬────────┬──────────────┬────────────────────────────┬───────────┐ │ │ Resource │ Effect │ Action │ Principal │ Condition │ ├───┼────────────────────────────┼────────┼──────────────┼────────────────────────────┼───────────┤ │ + │ ${demo-cdk-assets-bucket.A │ Allow │ s3:GetObject │ CanonicalUser:${cdnDistrib │ │ │ │ rn}/* │ │ │ ution/Origin1/S3Origin.S3C │ │ │ │ │ │ │ anonicalUserId} │ │ └───┴────────────────────────────┴────────┴──────────────┴────────────────────────────┴───────────┘ (NOTE: There may be security-related changes not in this list. See https://github.com/aws/aws-cdk/issues/1299) Do you wish to deploy these changes (y/n)? y CdnStack: deploying... CdnStack: creating CloudFormation changeset... ✅ CdnStack Stack ARN: arn:aws:cloudformation:us-east-1:[REDACTED]

Once successful, we are ready to test some assets.

Manually testing the stack

We can use the AWS CLI to test our CDN endpoint.

Add the following script to package.json under scripts:

"scripts": { "s3": "aws s3 sync ./assets s3://demo-cdk-assets-bucket" }

This script will sync assets in our assets folder to the root of our S3 bucket.

Create a new assets folder:

$ mkdir assets

Then add any assets in that you want. I have added the icon for my website workingoutloud.dev for the demonstration.

Once that is done, run the command npm run s3 and let the assets sync. If successful, you should get something similar to the following:

$ npm run s3 > aws-cdk-with-typescript-foundations@0.1.0 s3 > aws s3 sync ./assets s3://demo-cdk-assets-bucket upload: assets/wol-dev-300.png to s3://demo-cdk-assets-bucket/wol-dev-300.png

If successful, you can now go to https://example-cdn.your-domain.com/wol-dev-300.png and see your resulting image:

Example CDN success with networking information

Example CDN success with networking information

Writing a test for our stack

We can also add a test for our stack under test/cdk-stack.test.ts. This section is more for completion sake, but please note that I have adjusted some of the test to take environment variables for the sake of redacting critical information (albeit it is not that critical):

import { expect as expectCDK, matchTemplate, MatchStyle, } from "@aws-cdk/assert"; import * as cdk from "@aws-cdk/core"; import * as CdnStack from "../lib/cdn-stack"; test("CDN Stack", () => { const app = new cdk.App(); // WHEN const stack = new CdnStack.CdnStack(app, "MyTestStack"); // THEN expectCDK(stack).to( matchTemplate( { Resources: { democdkassetsbucketA19A67A4: { Type: "AWS::S3::Bucket", Properties: { BucketName: "demo-cdk-assets-bucket", }, UpdateReplacePolicy: "Delete", DeletionPolicy: "Delete", }, democdkassetsbucketPolicy674BC594: { Type: "AWS::S3::BucketPolicy", Properties: { Bucket: { Ref: "democdkassetsbucketA19A67A4", }, PolicyDocument: { Statement: [ { Action: "s3:GetObject", Effect: "Allow", Principal: { CanonicalUser: { "Fn::GetAtt": [ "cdnDistributionOrigin1S3Origin52472F64", "S3CanonicalUserId", ], }, }, Resource: { "Fn::Join": [ "", [ { "Fn::GetAtt": [ "democdkassetsbucketA19A67A4", "Arn", ], }, "/*", ], ], }, }, ], Version: "2012-10-17", }, }, }, cdnDistributionOrigin1S3Origin52472F64: { Type: "AWS::CloudFront::CloudFrontOriginAccessIdentity", Properties: { CloudFrontOriginAccessIdentityConfig: { Comment: "Identity for MyTestStackcdnDistributionOrigin1E3DABCBC", }, }, }, cdnDistribution5DCBB4A4: { Type: "AWS::CloudFront::Distribution", Properties: { DistributionConfig: { Aliases: [process.env.ZONE_NAME!], DefaultCacheBehavior: { CachePolicyId: "658327ea-f89d-4fab-a63d-7e88639e58f6", Compress: true, TargetOriginId: "MyTestStackcdnDistributionOrigin1E3DABCBC", ViewerProtocolPolicy: "allow-all", }, Enabled: true, HttpVersion: "http2", IPV6Enabled: true, Origins: [ { DomainName: { "Fn::GetAtt": [ "democdkassetsbucketA19A67A4", "RegionalDomainName", ], }, Id: "MyTestStackcdnDistributionOrigin1E3DABCBC", S3OriginConfig: { OriginAccessIdentity: { "Fn::Join": [ "", [ "origin-access-identity/cloudfront/", { Ref: "cdnDistributionOrigin1S3Origin52472F64", }, ], ], }, }, }, ], ViewerCertificate: { AcmCertificateArn: process.env.ACM_ARN!, MinimumProtocolVersion: "TLSv1.2_2019", SslSupportMethod: "sni-only", }, }, }, }, CDNARecord9F02271B: { Type: "AWS::Route53::RecordSet", Properties: { Name: `${process.env.ZONE_NAME!}.`, Type: "A", AliasTarget: { DNSName: { "Fn::GetAtt": ["cdnDistribution5DCBB4A4", "DomainName"], }, HostedZoneId: { "Fn::FindInMap": [ "AWSCloudFrontPartitionHostedZoneIdMap", { Ref: "AWS::Partition", }, "zoneId", ], }, }, HostedZoneId: "Z1Z1MST5IFCUVI", }, }, AliasRecord851000D2: { Type: "AWS::Route53::RecordSet", Properties: { Name: `${process.env.ZONE_NAME!}.`, Type: "AAAA", AliasTarget: { DNSName: { "Fn::GetAtt": ["cdnDistribution5DCBB4A4", "DomainName"], }, HostedZoneId: { "Fn::FindInMap": [ "AWSCloudFrontPartitionHostedZoneIdMap", { Ref: "AWS::Partition", }, "zoneId", ], }, }, HostedZoneId: process.env.HOSTED_ZONE_ID!, }, }, }, Mappings: { AWSCloudFrontPartitionHostedZoneIdMap: { aws: { zoneId: "Z2FDTNDATAQYW2", }, "aws-cn": { zoneId: "Z3RFFRIM2A3IF5", }, }, }, }, MatchStyle.EXACT ) ); });

Running npm t test/cdn-stack.test.ts or npm run test test/cdn-stack.test.ts would display our test succeeding:

$ npm t test/cdn-stack.test.ts > aws-cdk-with-typescript-foundations@0.1.0 test > jest "test/cdn-stack.test.ts" PASS test/cdn-stack.test.ts ✓ CDN Stack (144 ms) Test Suites: 1 passed, 1 total Tests: 1 passed, 1 total Snapshots: 0 total Time: 1.78 s, estimated 4 s Ran all test suites matching /test\/cdn-stack.test.ts/i.

Teardown

Always be sure to tear down your resources when you are done with them. We purposely made the bucket destroyable for this reason in this demo.

Note: your bucket will need to be empty to be destroyed. Ensure you login and empty the bucket first.

$ npm run cdk destroy > aws-cdk-with-typescript-foundations@0.1.0 cdk > cdk "destroy" Are you sure you want to delete: CdnStack (y/n)? y CdnStack: destroying... ✅ CdnStack: destroyed

Summary

Today's post demonstrated how to create a simple CDN with the AWS TypeScript CDK.

To improve upon from here, you could look to add in ways to resize assets on the fly or build a pipeline to process images and place them into the S3 bucket.

Resources and further reading

Photo credit: roadtripwithraj

Personal image

Dennis O'Keeffe

@dennisokeeffe92
  • Melbourne, Australia

Hi, I am a professional Software Engineer. Formerly of Culture Amp, UsabilityHub, Present Company and NightGuru.
I am currently working on Visibuild.

1,200+ PEOPLE ALREADY JOINED ❤️️

Get fresh posts + news direct to your inbox.

No spam. We only send you relevant content.

Building A CDN With S3, Cloudfront And The AWS CDK

Introduction

Share this post