DEV Community

Cover image for Deploy a static site to AWS S3 and CloudFront using AWS CDK
Erik Petrinec
Erik Petrinec

Posted on • Edited on

Deploy a static site to AWS S3 and CloudFront using AWS CDK

Amazon Web Services (AWS) is the most comprehensive and widely adopted cloud platform in the world. Using the AWS Cloud Development Kit (CDK), we can easily define our cloud infrastructure as code (using TypeScript) and host our site on S3, serving it with Amazon's high-performance CloudFront CDN.

Full Source Code available on GitHub

GitHub logo htmnk / aws-cdk-static-site-starter

An AWS CDK, S3 & CloudFront static site deploy starter template (blog post repo)

Static Site Deploy AWS CDK template

πŸŽ‰ Tutorial here.

This is a starter template for deploying a static site with AWS CDK. Make sure to add your own .env file to /cdk

CDK_REGION=us-east-1
CDK_ACCOUNT=2383838383
DOMAIN_NAME=e53nec.com
Enter fullscreen mode Exit fullscreen mode



Prerequisites

  • β˜‘Β Node.js (LTS recommended), Git and TypeScript (globally via npm) installed
  • β˜‘Β Have an AWS account and access to the AWS Management Console

Todo

build a site to a ready-to-deploy folder, I will be using Hugo today, but any framework of your choice will suffice

install everything AWS CDK related, configure AWS credentials and set up a new CDK TypeScript project

deep dive into the CDK app, take a look at stacks and define all the necessary CDK constructs for a static site deploy (s3.bucket, cloudfront.Distribution, etc.)

run our first deployment with the CDK Toolkit commands, view the deployed stack and resources inside the AWS Management Console

scan our site with Mozilla Observatory and improve our grade by registering a domain name, enabling HTTPS, adding a certificate and setting security headers

Create a demo static site

⚠️ If you already have a static site ready, you can skip this todo and proceed to Setup a new AWS CDK project.

For the purposes of this tutorial, we will not be focusing on fine-tuning the site contents. Today, I will be deploying a simple static blog and have chosen to use Hugo as my framework (other popular alternatives for this purpose include 11ty, Astro, and Jekyll). I don't need any complex user interactions, so a static site generator is sufficient for my needs.
If you plan to use Hugo as well, make sure it is installed on your machine. Then, open up a terminal (on Windows, it's recommended to use an Unix command line terminal or Powershell; Git Bash is usually the easiest option),

and run

hugo version
# hugo v0.103.0+extended darwin/arm64 BuildDate=unknown
Enter fullscreen mode Exit fullscreen mode

to verify your installation. You should get a similar output to the one above.

Now that the hard part is done, we can generate our site by running

hugo new site my-blog
Enter fullscreen mode Exit fullscreen mode

Running the command above should generate a new my-blog folder within the current directory. Let's move inside that folder and pull in a theme from GitHub (you can see a full list of available themes here). I'll be using the Paper theme today

cd my-blog
git init
git submodule add https://github.com/nanxiaobei/hugo-paper themes/paper
Enter fullscreen mode Exit fullscreen mode

You should see a new subfolder under themes, in my case it is called themes/paper. Since we are using Git submodules to manage themes, you should also see a new .gitmodules file being generated in the root directory.

While we're at it, let's also add a .gitignore file for Hugo. Create a new file

touch .gitignore
Enter fullscreen mode Exit fullscreen mode

Similar to other frameworks, we do not want to include any build files or automatically generated files in our repository. In Hugo, the build folder is called /public by default, so let's add that to our .gitignore file to exclude it from version control

# Generated files by hugo
/public/
# Temporary lock file while building
/.hugo_build.lock
Enter fullscreen mode Exit fullscreen mode

Now that we've taken care of the Git-related stuff, let's apply the theme to our project. To do this, we just need to add a new line to the configuration file. Open up config.toml and add the following line

theme = "paper"
Enter fullscreen mode Exit fullscreen mode

View on GitHub

To add some content, use the new command to create a new Markdown file inside the content folder

hugo new posts/my-first-post.md
Enter fullscreen mode Exit fullscreen mode

Hugo has excellent Markdown support and will automatically convert the contents of the file into a new page at /posts/my-first-post/. Let's verify that by starting up Hugo's development server:

hugo server -D
# ...
# Web Server is available at http://localhost:1313/
# ...
Enter fullscreen mode Exit fullscreen mode

When you start the server, you should see some basic information output to the terminal. Look for the localhost URL. If you are not running anything on Hugo's default port (1313), the site should now be available at that URL. Open it up in a browser. With the Paper theme, the posts should also be visible on the home page by default. Visit /posts/my-first-post and edit the /content/posts/my-first-post.md file, then save your changes. Hugo should rebuild the site without stopping the server, and you should see your changes reflected on the site.

All right we are ready to ship this thing πŸš€, run

hugo -D
Enter fullscreen mode Exit fullscreen mode

By default, when you use hugo new to create a new post, Hugo treats it as a draft and sets the draft: true flag. To include content marked as a draft in the build, we need to specify the -D option. All of the build files will be located in the /public directory, and they should be ready to deploy.

Done.

  • β˜‘Β Create a demo static site

Setup a new AWS CDK project

To simplify interactions with AWS services, consider installing the AWS CLI. This will provide you with a set of command-line tools that allow you to easily manage your AWS resources from the terminal.

Access keys consist of an access key ID and secret access key, which are used to sign programmatic requests that you make to AWS. If you don't have access keys, you can create them from the AWS Management Console.

Before we can use the AWS CDK, we need to set up AWS credentials for our account. There are several ways to do this, but for our purposes, we will use the "Creating a key pair" method as described in the AWS documentation. Here's how to do it, sign in to the AWS Management Console and

  1. Open the IAM console.
  2. In the navigation pane, choose Users and click the Add Users button.
  3. Type in a User name for the key pair, I'm going with "my-blog-cdk". Check Access key - Programmatic access as the AWS credential type and hit Next: Permissions.
  4. Now, you could dive deeper with permissions in this step if you have to take security a bit more serious, I'm going to keep it simple, select Attach existing policies directly and check AdministratorAccess. Click Next: Tags.
  5. We can skip Tags and click Next: Review.
  6. Finally click Create user.
  7. Now you can either download the .csv file and use that for the configuration or simply copy-paste the access key ID and secret access key pair. ❗ Be careful with exposing the keys if you followed my steps and chose AdministratorAccess for permissions, since anyone could access your AWS resources with the key pair.

Now that we have our AWS credentials, we can configure them for use with the AWS CDK. The easiest way to do this is to run the AWS CLI configure command. On MacOS, the configuration file will be stored at ~/.aws/credentials, so you may need to use sudo to run this command (for more information on configuration and credential file settings, see this page). Alternatively, you can create the .aws files manually if you are not using the AWS CLI

sudo aws configure
# AWS Access Key ID [None]: AKIAIOSFODNN7EXAMPLE
# AWS Secret Access Key [None]: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
# Default region name [None]: us-east-1
# Default output format [None]: json
Enter fullscreen mode Exit fullscreen mode

When prompted, enter your access key ID and secret access key from the key pair that you created earlier. For the region name, enter us-east-1, and for the output format, enter json. If you run aws configure again or cat ~/.aws/credentials, you should see your configured credentials.

Alright we got the keys πŸ”‘, let's move on and install the AWS CDK Toolkit (globally) with npm

npm install -g aws-cdk
Enter fullscreen mode Exit fullscreen mode

If you were following the previous todo, our project folder structure currently looks something like this

my-blog
β”‚   ...
β”œβ”€β”€ public # Hugo build folder
β”‚   ...
└── .gitignore
Enter fullscreen mode Exit fullscreen mode

The name of the build folder (in this case, public) is not particularly important, but it's important that we specify the correct path to the folder when we configure our deployment later. Let's create a new cdk folder at the same level as the public folder and initialize a new empty CDK project using the cdk init command

mkdir cdk
cd cdk
sudo cdk init app --language typescript
Enter fullscreen mode Exit fullscreen mode

In addition to other generated files and folders, we should focus on the cdk.json file first. This is a configuration file for the CDK, and it tells the CDK Toolkit how to execute our app. It contains information such as the programming language being used, the app's entry point, and other runtime settings.

If one of cdk.json or ~/.cdk.json exists, options specified there will be used as defaults. Settings in cdk.json take precedence.

When we choose TypeScript as the programming language with cdk init, it sets the app option to "app": "npx ts-node --prefer-ts-exts bin/cdk.ts",. This specifies the command that will be used to execute the CDK application.

The command specified in the app option uses ts-node by default, which is an execution engine for Node.js that allows you to run TypeScript code directly. The --prefer-ts-exts flag prevents ts-node from prioritizing precompiled .js files and will always import the TypeScript source code instead, if it is available. This is useful if you are also using tsc (the TypeScript compiler) alongside the app option. The bin/cdk.ts file is the entry point for our CDK app, which defines the main function that will be executed when the app is run.

To make it easier to use certain values throughout our CDK app, let's define some environment variables. Create a new .env file inside the cdk folder and add the following lines (we will use these later)

CDK_REGION=us-east-1
# Add your AWS Account ID here,
# can be grabbed from AWS Management Console when,
# clicking on your profile in the top right dropdown
CDK_ACCOUNT=2383838383
# Optional, if you are planning to purchase or have a domain
# ready to use
DOMAIN_NAME=e53nec.com
Enter fullscreen mode Exit fullscreen mode

Even though our .env file does not contain any sensitive information, it is a good practice to exclude it from version control. To do this, open the cdk/.gitignore file and add the following line

# env files
.env*
Enter fullscreen mode Exit fullscreen mode

To make it easier to access the environment variables defined in the .env file, we will use the dotenv module. This module loads environment variables from a .env file into our CDK app. To install dotenv, run the following command

npm install dotenv --save
Enter fullscreen mode Exit fullscreen mode

To use dotenv in our CDK app, we need to import and configure it in the entry point file (bin/cdk.ts). At the top of this file, add the following lines

import * as dotenv from 'dotenv'
dotenv.config()
...
import * as cdk from 'aws-cdk-lib'
Enter fullscreen mode Exit fullscreen mode

View on GitHub

This will import the dotenv module and use the config function to load the environment variables from the .env file. We will be able to access these variables throughout our CDK app using process.env.VARIABLE_NAME.

To verify that the environment variables are being loaded correctly, we can log them to the console and execute the app with ts-node. Inside bin/cdk.ts, add the following line below the dotenv configuration

console.log(process.env.CDK_REGION) // Remove if it's working
...
const app = new cdk.App() // Initialize a CDK application
Enter fullscreen mode Exit fullscreen mode

and run

npx ts-node bin/cdk.ts
Enter fullscreen mode Exit fullscreen mode

You should see us-east-1 printed to the console. Now that we have set up our CDK project and configured the environment variables, we are ready to start building our CDK app by writing CDK constructs.

Done.

  • β˜‘Β Setup a new AWS CDK project

Define AWS resources for the deployment

At the top level of every CDK App we define one or multiple stacks. Stacks are units of deployments, they are used for organizing and grouping the AWS resources together to a single deployment. In a TypeScript CDK app, any instance of the cdk.Stack class represents a stack. You can see an example of this with the CdkStack class in the bin/cdk.ts file

...
import { CdkStack } from '../lib/cdk-stack';
...
new CdkStack(app, 'CdkStack', {
  // ...
})
Enter fullscreen mode Exit fullscreen mode

The CdkStack class extends the root cdk.Stack class, which is imported from the aws-cdk-lib module in the lib/cdk-stack.ts file. This allows us to define custom behavior for our stack in addition to the functionality provided by the base cdk.Stack class

export class CdkStack extends cdk.Stack
Enter fullscreen mode Exit fullscreen mode

Inside stacks, we define constructs, which are the basic building blocks of a CDK app. Constructs represent a cloud component (I'm going to use the terms construct and components interchangeably), such as an S3 Bucket or a CloudFront Distribution. These components are turned into AWS resources after the CDK app is deployed.

For those who prefer visual representations, the composition of an app with stacks and constructs can be summarized with the following diagram from the AWS docs

AWS CDK structure

We are now going to add some constructs to our stacks. The following structure is optional, but we will define a BaseStack which will contain all the components that can be reused across multiple site deployments and a BlogStack for components specific to our blog. This separation of reusable and specific components allows for greater flexibility and modularity in our app in case we plan on having multiple deployments in the future.

Let's rename the file lib/cdk-stack.ts to lib/base-stack.ts and the exported CdkStack class to BaseStack. We will remove all unnecessary comments and add our first construct to the BaseStack, which will be the storage for the site: an S3 Bucket πŸͺ£

import * as cdk from 'aws-cdk-lib'
import * as s3 from 'aws-cdk-lib/aws-s3'
import { Construct } from 'constructs'

export class BaseStack extends cdk.Stack {
  private _bucket: s3.Bucket

  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props)
    this._bucket = new s3.Bucket(this, 'BaseBucket')
  }
}
Enter fullscreen mode Exit fullscreen mode

View on GitHub

Constructs are implemented in classes that extend the Construct base class. You define a construct by instantiating the class.

We have instantiated the s3.Bucket class, thus defining a new Bucket construct. Inside our BaseStack, we will also define private properties for the constructs. These will be exposed and passed down to the child BlogStack later. All constructs take three parameters when they are initialized: the scope, the id, and the props

new s3.Bucket(this, 'BaseBucket', {})
Enter fullscreen mode Exit fullscreen mode
  • scope - The first parameter is the scope, which is the construct's parent or owner. This can be either a stack or another construct (in our case, the BaseStack will be the owner of the Bucket construct). In JavaScript (TypeScript), we use the this keyword to represent the current object for the scope.
  • id - The second parameter is the id, which is a unique identifier within the current scope.
  • props - The third parameter is the props, which is a set of properties that define the construct's configuration. In most cases, constructs provide sensible defaults, and if all props elements are optional, you can omit the props parameter completely (which is what we did with the bucket above).

The first two parameters will be repetitive across the whole CDK app. To keep the construct ids unique, I will use this simple StackNameConstructName pattern.

When deploying a static site to the S3 bucket, we could either

  1. Enable static website hosting by adding the websiteIndexDocument property to our Bucket construct. This would require changing the defaults and some additional configuration when serving the site with CloudFront.

    If the bucket is configured for website hosting, the CloudFront origin will be configured to use the bucket as an HTTP server origin and will use the bucket's configured website redirects and error handling. Otherwise, the origin is created as a bucket origin and will use CloudFront's redirect and error handling.

  2. Keep the s3.bucket as a bucket origin for CloudFront. βœ…

We are going to stick with the defaults and continue with the 2nd (bucket origin) option. We could pretty much achieve the same final result with the first one (see using a website endpoint as the origin). There are also some caveats to both options, we will go through the latter ones.

The default props for the Bucket should then just work fine. The only interesting ones to us are the blockPublicAccess and the removalPolicy properties. If you are only playing around and plan on destroying the bucket later make sure to modify the removalPolicy and the autoDeleteObjects properties based on your needs

// This will also destroy the bucket when running cdk destroy later
this._bucket = new s3.Bucket(this, 'BaseBucket', {
  removalPolicy: RemovalPolicy.DESTROY,
  autoDeleteObjects: true,
})
Enter fullscreen mode Exit fullscreen mode

To allow users to access the site's content through CloudFront, we can add another construct called an OriginAccessIdentity. This is a special CloudFront user that can be associated with an S3 bucket origin. Since the default for blockPublicAccess is s3.BlockPublicAccess.BLOCK_ALL, this is necessary.

import { aws_cloudfront as cf } from 'aws-cdk-lib'
...
export class BaseStack extends cdk.Stack {
  ...
  private _originAccessIdentity: cf.OriginAccessIdentity
  ...
  this._originAccessIdentity = new cf.OriginAccessIdentity(this, 'BaseCfOriginAccessIdentity')
}

Enter fullscreen mode Exit fullscreen mode

View on GitHub

Next, we need to grant our Origin Access Identity (OAI) bucket read permissions. We can do this by adding the user to the bucket resource policy with the addToResourcePolicy() method, passing in a new IAM statement and attaching our OAI to the policy statement principals.

import * as iam from 'aws-cdk-lib/aws-iam'
...
this._bucket.addToResourcePolicy(
  new iam.PolicyStatement({
    actions: ['s3:GetObject'],
    resources: [this._bucket.arnForObjects('*')],
    principals: [
      new iam.CanonicalUserPrincipal(
        this._originAccessIdentity.cloudFrontOriginAccessIdentityS3CanonicalUserId
      ),
    ],
  })
)
Enter fullscreen mode Exit fullscreen mode

View on GitHub

That should be all the permission stuff done. Now we have only have one little problem left that we can solve within our BaseStack.

If you have not been following from the beginning, I used Hugo to generate the site. I have this build folder structure (which is common when using static site generators) for my pages

public
β”‚   ...
β”œβ”€β”€ posts
β”‚   └── my-first-post
β”‚       └── index.html
β”‚   ...
└── index.html
Enter fullscreen mode Exit fullscreen mode

Since we chose the bucket origin option, we must request the file at /posts/my-first-post/index.html directly, and our page routing will not work (this should be handled automatically if the static website hosting option is enabled). However, if we want to access the posts using the /posts/my-first-post/ URL, we can use a CloudFront Function to make this happen.

To begin, create a new folder called cdk/functions and add a new file to it called cdk/functions/reqRewrite.js. By default, a new CDK project includes a *.js line in the .gitignore file, which ignores all JavaScript files. In order to ensure that our function code is tracked and committed to the repository, we can add a new rule !/functions/*, which grabs back all of the .js files inside the functions directory.

In this file, we will rewrite all page requests by checking if the request does not contain a dot (e.g. .css, .js). This will help us determine if we are requesting a file or not. If the request does not contain a dot, it means that we are not requesting a file, and we can proceed with the rewrite and append .index.html to the requested URI

function rewritePageUri(uri) {
  var isDir = uri.endsWith('/')
  // Handle both "/posts/my-post/" and "/posts/my-post"
  return uri + (isDir ? '' : '/') + 'index.html'
}

function handler(event) {
  var request = event.request

  // Requesting a page ("/" or "/posts/my-post")
  if (!request.uri.includes('.')) {
    // Append index.html to the requested uri
    request.uri = rewritePageUri(request.uri)
  }

  return request
}
Enter fullscreen mode Exit fullscreen mode

View on GitHub

All the page requests will now serve the respective .index.html files.

⚠️ The code provided above is designed to work with a simple build folder structure, such as my-page/index.html or similar. If you have a different build folder structure, you will need to modify the rewritePageUri() function so that it appends the correct .html file to the requested URI. This will ensure that the correct page is displayed when a user navigates to your site.

Let's go ahead and attach our function to a CloudFront function associations list (we will drop this list into our CloudFront construct later). To do this, create a new _functionAssociations property and add a new construct as follows

...
export class BaseStack extends cdk.Stack {
  private _functionAssociations: cf.FunctionAssociation[]
  ...
  this._functionAssociations = [
    {
      eventType: cf.FunctionEventType.VIEWER_REQUEST,
      function: new cf.Function(this, 'BaseRequestRewriteFunction', {
        code: cf.FunctionCode.fromFile({ filePath: 'functions/reqRewrite.js' }),
      }),
    },
  ]
}
Enter fullscreen mode Exit fullscreen mode

View on GitHub

It is important to note that we need to create a VIEWER_REQUEST type function in order to modify the request before it reaches the origin(see Introducing CloudFront Functions). To create a VIEWER_REQUEST type function, we can use the Function construct, which allows us to specify the source code of the function from a file (such as the reqRewrite.js file we created earlier). We can also pass the code in as an inline string if we prefer.

In order to make our base stack resources accessible to other CDK stacks, let's create and export a BaseStackInterface

...
export interface BaseStackResources {
  bucket: s3.Bucket
  originAccessIdentity: cf.OriginAccessIdentity
  functionAssociations: cf.FunctionAssociation[]
}

export class BaseStack extends cdk.Stack {
...
Enter fullscreen mode Exit fullscreen mode

View on GitHub

and add a public resources() method to the BaseStack

...
private _functionAssociations: cf.FunctionAssociation[]

resources(): BaseStackResources {
  return {
    bucket: this._bucket,
    originAccessIdentity: this._originAccessIdentity,
    functionAssociations: this._functionAssociations,
  }
}

constructor(scope: Construct, id: string, props?: cdk.StackProps) {
...
Enter fullscreen mode Exit fullscreen mode

View on GitHub

By doing this, we can ensure that our child stacks are able to leverage the resources created in the BaseStack, making our cdk project more modular and reusable.

Great, it looks like we have all the base resources we need. Now, let's create our stack. To do this, open the bin/cdk.ts file and instantiate the BaseStack class as follows (make sure to use unique stack IDs if you are planning on deploying multiple CDK applications to the same AWS account)

import { BaseStack } from '../lib/base-stack'
...
const app = new cdk.App()
const baseStack = new BaseStack(app, 'AwsStaticSiteStarterBaseStack')
Enter fullscreen mode Exit fullscreen mode

View on GitHub

It is a good practice to ensure that our stacks are deployed to the desired account and region every time. To accomplish this, we can use the cdk.StackProps.env and retrieve the values from our .env file, and pass them as properties to our stacks. This approach is recommended for production environments, as it ensures consistency and eliminates potential issues that may arise when working with other team members who may have different accounts configured

...
const sharedProps: cdk.StackProps = {
  // Set the region/account fields of env to either a concrete
  // value to select the indicated environment (recommended for production stacks)
  env: { account: process.env.CDK_ACCOUNT, region: process.env.CDK_REGION },
}
const baseStack = new BaseStack(app, 'AwsStaticSiteStarterBaseStack', { ...sharedProps })
...
Enter fullscreen mode Exit fullscreen mode

View on GitHub

Now that we have finished preparing resources for the CloudFront construct, we can move on to creating our BlogStack.

To get started, create a new file called lib/blog-stack.ts and define a new class called BlogStack. The stack props should contain the additional BaseStackResources props. We can then destructure the props to get the individual resource instances as follows

import type { Construct } from 'constructs'
import type { BaseStackResources } from './base-stack'
import * as cdk from 'aws-cdk-lib'

export class BlogStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: cdk.StackProps & BaseStackResources) {
    super(scope, id, props)
    const { bucket, originAccessIdentity, functionAssociations } = props
  }
}
Enter fullscreen mode Exit fullscreen mode

View on GitHub

Now that we have defined our BlogStack class, we can create an instance of it inside bin/cdk.ts and pass in the resources as props

...
import { BlogStack } from '../lib/blog-stack'

...
new BlogStack(app, 'AwsStaticSiteStarterBlogStack', { ...sharedProps, ...baseStack.resources() })
Enter fullscreen mode Exit fullscreen mode

View on GitHub

To finish setting up the BlogStack, let's go back to lib/blog-stack.ts and create a CloudFront distribution. We are going to specify a few props here

  • defaultRootObject - The root index.html object we want to request from the S3 origin when a viewer requests the root URL for our distribution.
  • defaultBehavior
    • functionAssociations - The request rewrite function that we created earlier.
    • origin - A new S3Origin construct with our bucket and the originAccessIdentity from our base stack.
...
import * as origins from 'aws-cdk-lib/aws-cloudfront-origins'
import { aws_cloudfront as cf } from 'aws-cdk-lib'
...
const cfDistribution = new cf.Distribution(this, 'BlogCfDistribution', {
  defaultRootObject: 'index.html',
  defaultBehavior: {
    functionAssociations: [...functionAssociations],
    origin: new origins.S3Origin(bucket, {
      originAccessIdentity,
      originPath: '/blog',
    }),
  },
})
Enter fullscreen mode Exit fullscreen mode

View on GitHub

Finally, we will also need a BucketDeployment construct to deploy our site to our S3 bucket (populate the S3 bucket with our public folder). As for the props, the destinationBucket and the distribution are the obvious ones. The ones that need a little bit more attention would be

  • distributionPaths - The file paths to invalidate in the CloudFront distribution. This is important to specify because if the cache is not invalidated, users may continue to see the old content, which can lead to a variety of issues, such as incorrect display of content, broken links, JavaScript errors, etc.
  • destinationKeyPrefix - This is to organize our objects in the bucket by placing them in a specific subdirectory (blog). In case we would want to deploy multiple apps to the same bucket, we could specify different prefixes for different apps.
  • sources - The sources property allows us to specify the files or directories that we want to deploy to the bucket. An array of ISource objects is expected, so we can use the s3deploy.Source.asset() with the node.js path.join() to build the path to our public directory.
...
import { join } from 'path'
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment'
...
new s3deploy.BucketDeployment(this, 'BlogBucketDeployment', {
  destinationBucket: bucket,
  distribution: cfDistribution,
  distributionPaths: ['/*'],
  destinationKeyPrefix: 'blog',
  sources: [s3deploy.Source.asset(join(__dirname, '..', '..', 'public'))],
})
Enter fullscreen mode Exit fullscreen mode

View on GitHub

The BaseStack and BlogStack structure that I chose should now be clearer. If we decide to deploy another site, we can easily reuse resources from the BaseStack and add an additional child stack (similar to or the same as the BlogStack) as needed. We can then modify the paths and prefixes as needed.


Deploying to CloudFront CDN results in a significant improvement over other alternatives due to Amazon's edge cache. To further improve our site performance, we must set Cache-Control headers that control caching of our assets inside browsers. You may want to consider the snippet I provided above, which extends the BucketDeployment (drop-in SiteBucketDeployment construct) with several additional properties that enable you to set different Cache-Control headers according to the specified file extensions.


Well that was easy, let's ship this thing for real now.

Done.

  • β˜‘Β Define AWS resources for the deployment

Deploy the site

If this is your first time deploying a CDK app on your AWS account, you may also need to run the cdk bootstrap command. This command creates the necessary resources in your account to support CDK deployments, such as an S3 bucket for storing templates and assets. A simple

cdk bootstrap
Enter fullscreen mode Exit fullscreen mode

should work if your credentials are configured. You should also get a warning when trying to deploy without bootstrapping first.

There are also some useful commands that we can run to ensure that the cdk app is in good shape and we can catch any potential issues early on:

  • cdk diff - This command compares the current state of the application with the deployed state and shows you the differences between them. This can help you understand what changes will be made to your resources during the next deployment.
  • cdk synth - Generates the CloudFormation template for your stack, which can be useful for reviewing the template, or even debugging and troubleshooting issues related to your stack deployment.

In addition to the built-in CDK commands, we can also include a custom build command to catch any errors or issues with the application's code before deployment. Since it is not part of the CDK toolchain, and we are using ts-node, we can replace the build script in the package.json file with the command we used in the previous section

...
"scripts": {
  "build": "npx ts-node bin/cdk.ts",
},
...
Enter fullscreen mode Exit fullscreen mode

View on GitHub

and run

npm run build
Enter fullscreen mode Exit fullscreen mode

Alright it's finally time to deploy. As we have multiple stacks within the app, it is necessary to use the cdk deploy --all option when deploying our app

# sudo cdk deploy --all
cdk deploy --all
Enter fullscreen mode Exit fullscreen mode

When deploying the CDK application for the first time, it may take a while as all the resources defined in the stacks will be created. During the deployment process, you may be prompted with confirmation messages, asking you to confirm the creation of certain resources.

If the deployment process is successful, you should see a green checkmark with the stack id in the console

...

βœ… AwsStaticSiteStarterBlogStack

✨ Deployment time: 326.15s

...
Enter fullscreen mode Exit fullscreen mode

Let's explore the deployed resources in the AWS Management Console. We can first visit the AWS CloudFormation service that displays a list of all the stacks in our AWS account, along with the resources that are associated with each stack

  1. Open the AWS Management Console
  2. In the top navigation bar, click on the Services menu and search/select CloudFormation.
  3. In the CloudFormation console, you will see a list of all the stacks in your AWS account.
  4. To view the resources associated with a stack, click on the stack name.

We can also view the properties and status of each resource in our stack, as well as perform various actions such as updating or deleting the stack. This is particularly useful when integrating CDK into a continuous integration and deployment (CI/CD) workflow. If a deployment gets stuck, we can use the AWS Management Console to take manual action to fix the issue.

To access our deployed blog, we will need to use the URL provided by CloudFront. To find the HTTP URL of our CloudFront distribution

  1. Select/Search CloudFront from the services menu.
  2. In the CloudFront console, you will see a list of all the CloudFront distributions in your AWS account.
  3. Click on the distribution that was created by your CDK app.
  4. In the distribution details page, you will see the Domain Name of the distribution, and it will take the form of xxxxxxx.cloudfront.net. We can use this URL to access our blog using HTTP. Go ahead and paste the url inside your browser.

If you are not using Hugo as we did in the first section, your site may already be working as intended. With Hugo it should be completely broken at this stage, we have to go back to our config.toml and update the baseURL with our generated URL

baseURL = 'http://xxxxxxx.cloudfront.net'
...
Enter fullscreen mode Exit fullscreen mode

View on GitHub

Let's rebuild and redeploy the site again (make sure you are inside the root directory)

hugo -D
cd cdk
cdk deploy --all
Enter fullscreen mode Exit fullscreen mode

The redeploy should be noticeably quicker this time because we are only updating the contents of the site, not the constructs.

πŸŽ‰ That's it!

⚠️ If you encounter issues when attempting to redeploy and the cdk diff command shows changes to the AWS::Cloudfront::Function .zip, one solution is to make a small modification to the function code, such as adding a comment. Another option is to use the inline version of the function code when creating the CloudFront function. This may resolve the issue and allow for successful redeployment.

For those of you that are only playing around and don't want to keep the deployed stacks, you can use the cdk destroy command to delete all the resources that were created by the deployment. You might have to edit and redeploy the s3.bucket construct (the bucket needs to be empty) so that it gets properly destroyed

this._bucket = new s3.Bucket(this, 'BaseBucket', {
  removalPolicy: RemovalPolicy.DESTROY,
  autoDeleteObjects: true,
})
Enter fullscreen mode Exit fullscreen mode

and run

cdk destroy --all
Enter fullscreen mode Exit fullscreen mode

Alternatively you can also delete the contents of the bucket inside the AWS Management console and destroy the stack manually with CloudFormation.

If you have purchased a domain through AWS and want to take your site to production by enabling HTTPS, this final section below is for you. Even if you are not planning to purchase a domain at this time, you may still want to read along to learn about how to set up HTTPS and other security related features using the CDK.

Done.

  • β˜‘Β Deploy the site

Security and Domain Name

Let's evaluate the security of our site by using Mozilla Observatory to obtain a security grade. Simply paste your CloudFront http://xxxxxxx.cloudfront.net/ URL to the input. At this stage, it is likely that we will receive an "F" rating, similar to a failing grade in college. However, unlike academic studies, it does not require extensive effort to improve our score. The most important and immediate step is to implement HTTPS, which is now a standard even for websites that do not transmit sensitive data. Failure to do so can result in Google Chrome flagging the website as insecure, which is not an acceptable outcome for a publicly accessible site.

Before diving deep into certificates and HTTPS, we can perform a security checklist by following the suggestions of Mozilla Observatory. One way to do this is by using the ResponseHeadersPolicy construct to add several security headers to our CloudFront distribution

These headers include (checkout the Web Security Cheat Sheet for more details, the importance and implementation difficulty of each one):

  • Content-Security-Policy - used to restrict the resources that a browser can load for a given page
  • Content-Type-Options - prevents the browser from interpreting files as something other than declared by the content type
  • Frame-Options - prevents clickjacking by ensuring that the content is not embedded into other sites
  • Referrer-Policy - allows us to control the value of the referrer header
  • Strict-Transport-Security - used to enforce secure connections to the server
  • X-XSS-Protection - helps prevent cross-site scripting attacks

Go back to our cdk/lib/base-stack.ts and create the ResponseHeadersPolicy construct (we will skip CSP in this article)

export interface BaseStackResources {
  ...
  responseHeadersPolicy: cf.ResponseHeadersPolicy
}

export class BaseStack extends cdk.Stack {
  ...
  private _responseHeadersPolicy: cf.ResponseHeadersPolicy

  resources(): BaseStackResources {
    return {
      ...
      responseHeadersPolicy: this._responseHeadersPolicy,
    }
  }

  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    ...
    this._responseHeadersPolicy = new cf.ResponseHeadersPolicy(this, 'BaseResponseHeadersPolicy', {
      responseHeadersPolicyName: 'CustomBaseResponseHeadersPolicy',
      securityHeadersBehavior: {
        /**
         * @todo ResponseHeadersPolicy CSP (for you, in case you want to have * a perfect grade)
         *
         * When using Hugo, implementing a proper Content Security Policy (CSP) can be
         * challenging when there is limited control over the scripts included on the site.
         */
        // contentSecurityPolicy: { contentSecurityPolicy: 'default-src https:;', override: true },
        contentTypeOptions: { override: true },
        frameOptions: { frameOption: cf.HeadersFrameOption.DENY, override: true },
        referrerPolicy: {
          referrerPolicy: cf.HeadersReferrerPolicy.NO_REFERRER,
          override: true,
        },
        strictTransportSecurity: {
          override: true,
          accessControlMaxAge: Duration.days(365 * 2),
          includeSubdomains: true,
          preload: true,
        },
        xssProtection: { override: true, protection: true, modeBlock: true },
      },
    })
    ...
Enter fullscreen mode Exit fullscreen mode

View on GitHub

Before proceeding further, ensure that your domain name is registered with Amazon Route 53. This process also creates a hosted zone with your domain name as part of the registration. You can follow the guide How to Register a Domain Name with Amazon Route 53) if you haven't done so already.

We can utilize the HostedZone.fromLookup() method to create a HostedZone construct, which is required to specify for our certificate. Simply pass in your domain name, which should also be stored in the .env file at this point if you have been following the guide from the beginning

...
import * as route53 from 'aws-cdk-lib/aws-route53'
...
export interface BaseStackResources {
  ...
  zone: route53.IHostedZone
}

export class BaseStack extends cdk.Stack {
  ...
  private _zone: route53.IHostedZone

  resources(): BaseStackResources {
    return {
      ...
      zone: this._zone,
    }
  }

  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
  ...
  this._zone = route53.HostedZone.fromLookup(this, 'BaseHostedZone', {
    domainName: process.env.DOMAIN_NAME ?? '',
  })
Enter fullscreen mode Exit fullscreen mode

View on GitHub

Next, we will once again proceed to the lib/blog-stack.ts file to bring everything together in our CloudFront distribution. We can retrieve the zone and responseHeadersPolicy constructs we created previously from the props. We can also grab the shared env prop for the region. Additionally, let's assign the value of process.env.DOMAIN_NAME to a variable, as we will be using it in multiple places within the BlogStack.

...
super(scope, id, props)
const { env, zone, responseHeadersPolicy, bucket, originAccessIdentity, functionAssociations } = props
const domainName = process.env.DOMAIN_NAME ?? ''
...
Enter fullscreen mode Exit fullscreen mode

View on GitHub

Alright, let's pass in the base stack constructs and specify some additional parameters for the CloudFront distribution. These include:

  • defaultBehavior.responseHeadersPolicy - this is where we will pass in our responseHeadersPolicy construct
  • defaultBehavior.viewerProtocolPolicy - set to cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS to redirect all HTTP requests to HTTPS
  • priceClass - set to cf.PriceClass.PRICE_CLASS_ALL for all CloudFront locations
  • domainNames - an array containing our site's domain name, passed in as [domainName]
  • certificate - it is necessary to specify the certificate that should be used for HTTPS connections. This is done by passing in an instance of the Certificate construct as a property of the Distribution construct. The certificate is then associated with the distribution, and CloudFront uses it to authenticate connections to our website. The certificate must be located in N. Virginia us-east-1.
  • errorResponses - CloudFront, with our configuration, will return a 403 error for any missing requests. This includes the standard 404 page responses. To properly handle these errors, we can use this behavior to remap the response and return a /404.html page. Keep in mind that this page must exist on your site.
...
import * as acm from 'aws-cdk-lib/aws-certificatemanager'

...

const cfDistribution = new cf.Distribution(this, 'BlogCfDistribution', {
  defaultRootObject: 'index.html',
  defaultBehavior: {
    functionAssociations: [...functionAssociations],
    origin: new origins.S3Origin(bucket, {
      originAccessIdentity,
      originPath: '/blog',
    }),
    responseHeadersPolicy,
    viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
  },
  priceClass: cf.PriceClass.PRICE_CLASS_ALL,
  domainNames: [domainName],
  certificate: new acm.DnsValidatedCertificate(this, 'BlogCertificate', {
    domainName,
    // Support www. + domain name
    subjectAlternativeNames: ['www.' + domainName],
    hostedZone: zone,
    region: env?.region,
  }),
  errorResponses: [{ httpStatus: 403, responseHttpStatus: 404, responsePagePath: '/404.html' }],
})
Enter fullscreen mode Exit fullscreen mode

View on GitHub

Next up: an ARecord. An alias record in Route 53 is a type of record that can be used to map a domain name to selected AWS resources, such as an Elastic Load Balancer, CloudFront distribution, or an S3 bucket. We can use this construct to map our domain name to a CloudFrontTarget

...
import * as route53 from 'aws-cdk-lib/aws-route53'
import * as route53targets from 'aws-cdk-lib/aws-route53-targets'

...
new route53.ARecord(this, 'BlogAliasRecord', {
  zone,
  recordName: domainName,
  target: route53.RecordTarget.fromAlias(new route53targets.CloudFrontTarget(cfDistribution)),
})
Enter fullscreen mode Exit fullscreen mode

View on GitHub

Not crucial, but we can also use the HttpsRedirect construct to redirect requests made to the www version of our domain to the non-www version. This is often done to ensure that users accessing our site are directed to a consistent URL, and to improve SEO. By using this construct, we can easily configure a redirect from www.domainName.com to domainName.com for all requests made to our site

...
import { aws_cloudfront as cf, aws_route53_patterns } from 'aws-cdk-lib'

...
new aws_route53_patterns.HttpsRedirect(this, 'BlogWwwToNonWww', {
  zone,
  recordNames: ['www.' + domainName],
  targetDomain: domainName,
})
Enter fullscreen mode Exit fullscreen mode

View on GitHub

The construct uses CloudFront and S3 behind the scenes, so an additional CloudFront distribution and S3 bucket will be created.

That should be everything we need for now. Let's try a redeploy with the command cdk deploy --all. If the deployment is successful, you should be able to access your site at your purchased domain over HTTPS.

After deploying our CDK app, we can view the created certificate in the AWS Certificate Manager and the created records inside the hosted zone in the AWS Route 53 service.

Let's also run a scan on the site using Mozilla Observatory again. We should see an improvement in the security grade, and should now receive a grade of "B" this time.

πŸ₯³ Here's the final result e53nec.com.

Done.

  • β˜‘Β Security and Domain Name

What's next

Top comments (2)

Collapse
 
sarahokolo profile image
sahra πŸ’«

Awesome write up, Love how you explained everything in very detailed and clear steps.πŸ‘πŸ‘

Collapse
 
htmnk profile image
Erik Petrinec

thanks a lot sahra ❀️