AWS CDK Tutorial for Beginners - Step-by-Step Guide

avatar

Borislav Hadzhiev

Sat Apr 24 202113 min read

banner

Photo by redcharlie

Updated on Sat Apr 24 2021

A step-by-step guide to getting started with AWS CDK using TypeScript. We go through all the phases of a CDK project with real world scenarios.

Table of Contents #

  1. Introduction
  2. Creating a new CDK App
  3. File structure
  4. Constructs - introduction
  5. Creating Resources via Constructs
  6. Construct Parameters
  7. Printing diffs of Resources
  8. Listing CDK Stacks
  9. Generating CloudFormation templates with CDK Synth
  10. Deploying our CloudFormation Stack
  11. Updating a CDK Stack
  12. Identifiers in CDK
  13. Adding Outputs to a Stack
  14. Creating a second Stack in our CDK App
  15. Clean up
  16. Discussion
  17. Further Reading

Introduction #

In this article we're going to create a CDK app and go through a step-by-step explanation of the things we need to know to feel confident when using CDK to provision infrastructure.

The code for this article is available on GitHub

Creating a new CDK App #

Prerequisites - You have to have the CDK CLI installed and the AWS CLI installed and configured.

In order to create a new CDK App we have to use the cdk init command.

We can write our CDK code in many programming languages and init our CDK app from multiple starter templates. To list the available options we can append the --list flag to the command:

shell
npx cdk init --list

The output shows all the available programming languages we can use in our CDK app and all of the available starter templates:

available languages

There are 3 templates we can start from:

  • app - a basic starter template
  • lib - a template for writing a CDK construct library
  • sample-app - a starter with some constructs included

In this article we'll use the app template with the TypeScript language.

Note that cdk init cannot be run in a non-empty directory, so we first have to create one:

shell
mkdir cdk-app
cd cdk-app
npx cdk init app --language=typescript

The output from the command looks like:

cdk init output

File structure #

At this point we have an empty CDK project. Let's look at some of the more important files in the project.

In the root directory we have some configuration files, most of which are language specific:

  • package.json - manages our node packages and scripts
  • jest.config.js - configuration for testing
  • tsconfig.json - typescript configuration

We also have a helpful README.md file with the most commonly used CDK commands.

The first cdk specific file in the root directory is cdk.json and it looks something like:

cdk.json
{
  "app": "npx ts-node --prefer-ts-exts bin/cdk-app.ts",
  "context": {
    "@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": true,
    // ...rest
  }
}

The app key tells the CDK CLI how to execute our code. We're using TypeScript, so our code has to be compiled down to JavaScript and that's what the ts-node package does.

The command points to the location of our CDK App:

shell
npx ts-node --prefer-ts-exts bin/cdk-app.ts

The feature flags in the context object give us the option to enable or disable some breaking changes that have been made by the AWS CDK team outside of major version releases.

In short feature flags allow the AWS CDK team to push new features that cause breaking changes without having to wait for a major version release. They can just enable the new functionality for new projects, whereas old projects without the flags will continue to work.

Next, let's take a look at the entry point of our CDK app in the bin/cdk-app.ts file.

Every CDK App can consist of one or more Stacks. You can think of a stack as a unit of deployment. For instance we could have one stack for our dev environment and one for our prod environment, and both can be created in the scope of the same CDK App. If you're familiar with stacks in CloudFormation, it's the same thing.

The code for this article is available on GitHub

Update the CdkAppStack class instantiation in the bin/cdk-app.ts file to look like:

bin/cdk-app.ts
const app = new cdk.App();
new CdkAppStack(app, 'CdkAppStack', {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION,
  },
});

The env property allows us to specify the AWS environment (account and region) where our stack will be deployed.

The CDK_DEFAULT_ACCOUNT and CDK_DEFAULT_REGION environment variables are made available in our CDK code and by default resolve to the account and region of our default AWS CLI profile.

Constructs - introduction #

In order to provision resources using CDK we have to define Constructs within our CDK stack.

Constructs are cloud components. They provide us with:

  • sane defaults, so we don't have to dive deep into CloudFormation docs, unless we need to diverge from the default behavior
  • helper methods, that ease the service to service interactions, such as permission grants
  • a very concise and maintainable way to define infrastructure, using higher level abstractions (than CloudFormation) and leveraging the power of our IDE by writing in a programming language rather than a configuration language (yaml or json)

In order to create some resources we have to install the necessary libraries first.

At the time of writing it's very important to keep all of the aws-cdk packages the same version. If the versions of cdk packages in our package.json file are different we get nasty, hard to debug errors. If you want to learn more about version management in CDK, I've written an article - How to manage Package Versions in AWS CDK.

In short, we have to make sure all of the aws-cdk packages we have in our package.json file are the same version.

Let's install the CDK CLI and the S3 and Dynamodb construct libraries from npm:

shell
npm install --save-exact \
  @aws-cdk/aws-s3@latest \
  @aws-cdk/aws-dynamodb@latest \
  aws-cdk@latest

We used the --save-exact flag to lock down the version of the installed packages. To be on the safe side, open your package.json file and make sure the versions of all aws-cdk packages are the same.

Creating Resources via Constructs #

Next, let's instantiate our first constructs in the lib/cdk-app-stack.ts file. We'll create an S3 bucket and a Dynamodb table:

lib/cdk-app-stack.ts
import * as dynamodb from '@aws-cdk/aws-dynamodb';
import * as s3 from '@aws-cdk/aws-s3';
import * as cdk from '@aws-cdk/core';

export class CdkAppStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // ๐Ÿ‘‡ use the Bucket construct
    const bucket = new s3.Bucket(this, 'avatars-bucket', {
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });

    // ๐Ÿ‘‡ use the Table construct
    const table = new dynamodb.Table(this, 'todos-table', {
      partitionKey: {name: 'todoId', type: dynamodb.AttributeType.NUMBER},
      billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });
  }
}

In the code snippet we:

  1. Used the Bucket construct to define an S3 bucket resource. We've set the removalPolicy prop to RemovalPolicy.DESTROY, which means that if the bucket is empty at the time we delete our stack it will also get deleted.

  2. Used the Table construct to define a Dynamodb table resource. We've set the billingMode prop of the table to PAY_PER_REQUEST, to avoid incurring any charges, as we won't be making any requests. We've set the removalPolicy of the table to REMOVALPolicy.DESTROY, so the table gets deleted when we delete our stack.

By default the removalPolicy prop of stateful resources (S3 buckets, databases) is set to RETAIN, which means that when we delete our stack the resources will remain in our account.

Construct Parameters #

Looking at the code in the snippet, we can see a pattern - both the Bucket and Table constructs receive the same 3 parameters:

  • The scope parameter specifies the parent construct within which the child construct is initialized. In JavaScript we use the this keyword, in Python self, etc.

  • The id parameter - an identifier that must be unique within the scope. The combination of CDK identifiers for a resource builds the CloudFormation Logical ID of the resource. I've written an article - What is an identifier in AWS CDK if you want to read more.

  • The props parameter - key-value pairs used to set configuration options for the resources, that the construct provisions. Note that the props of different constructs vary.

At this point we have defined 2 resources in our CDK Stack:

  • an S3 bucket
  • a Dynamodb table

Before we move onto provisioning our resources it's very important to go over how CDK actually works.

CDK is just a wrapper around CloudFormation. CDK enables us to provision infrastructure using a programming language (TypeScript, Python, Java), rather than a configuration language (yaml, json).

The whole point of CDK is to improve developer experience, by providing a more maintainable approach to infrastructure provisioning than CloudFormation.

However, before we execute a CDK deployment, our CDK code gets compiled down to CloudFormation code.

After we deploy the CloudFormation code we can view the stack in the CloudFormation console.

Printing diffs of Resources #

Next we'll run the cdk diff command to see what changes would occur in case we deployed our CDK code:

shell
npx cdk diff

The output from the command looks like:

cdk diff output

We can see that if we were to deploy our CDK stack at this point we would provision 2 resources:

  • AWS::S3::Bucket
  • AWS::DynamoDB::Table

These are the resource type names from CloudFormation

The cdk diff command compares the deployed and local version of our stack. Since we have not deployed our stack yet, it just shows us that if we were to deploy right now, we'd provision the 2 resources.

It's a very handy command when iterating and updating your infrastructure, because if you make any changes that would delete or update a resource, you would immediately see the change in the output of cdk diff.

Listing CDK Stacks #

The next command we'll use is cdk list:

shell
npx cdk list

The output looks like:

cdk list output

The cdk list commands lists the names of all of the stacks in our CDK App.

The name of our stack is inferred from the id prop, passed when instantiating the stack in bin/cdk-app.ts:

bin/cdk-app.ts
const app = new cdk.App();
// ๐Ÿ‘‡ stack name inferred from here
new CdkAppStack(app, 'CdkAppStack', {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION,
  },
});

Let's update the name of our cdk stack to cdk-stack-dev.

Update the CdkAppStack instantiation to look like:

bin/cdk-app.ts
const app = new cdk.App();
new CdkAppStack(app, 'cdk-stack-dev', {
  stackName: 'cdk-stack-dev',
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION,
  },
});

Let's run the list command again:

shell
npx cdk list

The output reflects the change we've made:

cdk list updated

A very handy flag on the CDK list command is the --long flag, it includes information about the environment(account, region) of our CDK application:

shell
npx cdk list --long

The output looks like:

shell
- id: cdk-stack-dev
  name: cdk-stack-dev
  environment:
    account: '123456789012'
    region: us-east-1
    name: aws://123456789012/us-east-1

Generating CloudFormation templates with CDK Synth #

Next, we're going to generate and print the CloudFormation equivalent of the CDK stack we've defined.

In other words, we're going to synthesize a CloudFormation template, based on the stack we've written in lib/cdk-app-stack.ts.

For that we have to use the synth command.

shell
npx cdk synth

After we run the synth command we can see the CloudFormation equivalent of our stack.

The synth command did a couple of things, most importantly:

  • executed our CDK code, so we'd see any syntax or type errors in case we had any. The command knows how to execute our code, because of the app key in the cdk.json file, located in the root directory of our project
  • generated the CloudFormation template we are going to deploy. The file is located at cdk.out/cdk-stack-dev.template.json

If you open the cdk.out directory, you should be able to see the cdk-stack-dev.template.json CloudFormation template:

cdk out directory

As expected the CloudFormation template is equivalent to the stack we've defined, and provisions both of our resources - the bucket and the table.

Deploying our CloudFormation Stack #

At this point our template has been generated and stored in the cdk.out directory. We're ready to deploy our CloudFormation stack.

Let's execute the deploy command:

shell
npx cdk deploy

It shouldn't take long before our CloudFormation stack is deployed. You can visit the CloudFormation console to look at the details.

If we select cdk-stack-dev and click on Resources we can see the resources our stack has provisioned:

cloudformation console

At this point we've successfully deployed our CDK stack.

Since CDK is just a wrapper around CloudFormation, the service doesn't have its own console. For a visual representation of a CDK deployment we use the CloudFormation console.

Updating a CDK Stack #

Next, we're going to perform an update. We've decided that the partition key name for our Dynamodb table should be id, instead of todoId.

Let's make the change in lib/cdk-app-stack.ts:

lib/cdk-app-stack.ts
const table = new dynamodb.Table(this, 'todos-table', {
- partitionKey: {name: 'todoId', type: dynamodb.AttributeType.NUMBER},
+ partitionKey: {name: 'id', type: dynamodb.AttributeType.NUMBER},
  billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
  removalPolicy: cdk.RemovalPolicy.DESTROY,
})

Changing the partition key of a dynamodb table is an update that requires resource replacement. Our old table will be deleted and a new one with the new partition key will be created.

Let's run the diff command:

shell
npx cdk diff

The output of the command shows that if we were to deploy at this point the table resource would get deleted and a new one with the new partition key would get created:

resource replacement diff

It's a best practice to run the cdk diff command before deploying, especially when working with stateful resources (buckets, databases).

In order to update a CDK stack we run the cdk deploy command.

Running the cdk synth command before deploying is optional. When we run cdk deploy the CDK CLI automatically runs cdk synth before every deployment.

Let's deploy our stack:

shell
npx cdk deploy

If we open the Dynamodb console we can see that the new table with partition key of id has been provisioned, and the old one has been deleted:

dynamodb console

Identifiers in CDK #

Next, I'll demonstrate a common source of confusion (it was for me), for CDK beginners.

You don't have to write this code, it's just for demonstration purposes, I'll revert the code after.

I'll make a small change to the second parameter of my dynamodb table. Don't make this change, I'll revert the code after.

- const table = new dynamodb.Table(this, 'todos-table', {
+ const table = new dynamodb.Table(this, 'table', {
    partitionKey: {name: 'id', type: dynamodb.AttributeType.NUMBER},
    billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
    removalPolicy: cdk.RemovalPolicy.DESTROY,
})

I've changed the second parameter I'm passing to the Table construct - the identifier.

I'll now run the diff command:

shell
npx cdk diff

The output is:

diff after id change

We can see that if I were to deploy after I've changed the id prop of the Table construct, my old dynamodb table would get deleted and a new one would get created.

The reason is that by changing the id prop, I've changed the CloudFormation Logical ID of the table resource. Changing a Logical ID of a resource in CloudFormation deletes the old resource and creates a new resource with the new Logical ID.

We can see the Logical IDs of the resources we've provisioned by opening the CloudFormation console. The Logical ID is visible in the first column:

cloudformation console

You might be thinking, well then I'm never going to change the id prop I pass to constructs, that's an easy fix.

However the CloudFormation logical id is constructed as a combination of the id props of the different scopes (among other things). I've written another article on this - What is an identifier in AWS CDK,

In short - if we were to extract the code that instantiates the Table construct in another class, we would change the CloudFormation logical ID of the table resource. If we then deploy these changes - the old table would get deleted and a new one with the new logical id would get created.

The reason I've included this part, is because when I first started provisioning my infrastructure using a programming language I expected that I can refactor my code (extract classes, etc) in any way I want, without any consequences for my infrastructure, however that's not the case.

I've reverted the change:

- const table = new dynamodb.Table(this, 'table', {
+ const table = new dynamodb.Table(this, 'todos-table', {
    partitionKey: {name: 'id', type: dynamodb.AttributeType.NUMBER},
    billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
    removalPolicy: cdk.RemovalPolicy.DESTROY,
})

Adding Outputs to a Stack #

Next, we're going to add Outputs to our CDK stack, these are values that we can import into other stacks, or in our case redirect to a file on the local file system.

It's a very common scenario, that resource identifiers (i.e. bucket name, API URL) have to be kept in sync between our backend and frontend code.

By redirecting the outputs to a json file on the file system, we enable our frontend code to import the properties and use them.

Let's add Outputs to our stack in our lib/cdk-app-stack.ts:

lib/cdk-app-stack.ts
export class CdkAppStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // ... rest

    new cdk.CfnOutput(this, 'bucketName', {
      value: bucket.bucketName,
    });
    new cdk.CfnOutput(this, 'tableName', {value: table.tableName});
  }
}

In the code snippet we used the CfnOutput construct to create an output for the names of our bucket and table.

Let's run the diff command:

shell
npx cdk diff

We can see that if we were to deploy at this point, 2 Output values would get created:

added outputs

This time we'll add a new flag to the cdk deploy command. The --outputs-file flag allows us to write the outputs we've defined in our stack to a file on the local filesystem.

shell
npx cdk deploy \
  --outputs-file ./cdk-outputs.json

Let's take a look at the cdk-outputs.json file in the root directory of our project:

outputs-file

We can see that the bucket and table names have been written to the json file. Now our frontend (if we had one), would be able to import the file and use any of the values, such as an API url.

Creating a second Stack in our CDK App #

The next thing we're going to do is create a second stack in our CDK app.

We have to instantiate the second stack in our bin/cdk-app.ts file:

bin/cdk-app.ts
const app = new cdk.App();

new CdkAppStack(app, 'cdk-stack-dev', {
  stackName: 'cdk-stack-dev',
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION,
  },
});

// ๐Ÿ‘‡ now instantiating a prod stack as well
new CdkAppStack(app, 'cdk-stack-prod', {
  stackName: 'cdk-stack-prod',
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION,
  },
});

Let's run the diff command:

shell
npx cdk diff \
  cdk-stack-dev \
  cdk-stack-prod

The output shows that there aren't any differences in our cdk-stack-dev, however if we we're to deploy the cdk-stack-prod the Bucket and Table resources would be created:

diff with two stacks

Next let's synth the stacks to generate the CloudFormation templates:

npx cdk synth \
  cdk-stack-dev \
  cdk-stack-prod

Now if we take a look at the cdk.out directory, we can see that we've generated 2 CloudFormation templates. One for our cdk-stack-dev and one for cdk-stack-prod:

cdk out two stacks

In most real world applications, you're going to have to manage more than one stack.

A couple of reasons being:

  • you don't want to write to your production database while developing your application
  • usually you provision resources with lower capacity for your development environment, i.e. an EC2 instance type t3.micro instead of m5n.xlarge.

Next, we're going to deploy our stacks, we haven't made any changes to the cdk-stack-dev, but we'll deploy it anyway, to demo the syntax:

shell
npx cdk deploy \
  cdk-stack-dev \
  cdk-stack-prod

At this point we have both of our stacks deployed. If we open the CloudFormation console we can see that both of our stacks provision a separate bucket and table:

cloudformation two stacks

To only deploy, synth, or diff a specific stack we just have to specify the name in the command. For example, to only deploy the dev stack:

shell
npx cdk deploy cdk-stack-dev

Clean up #

In order to delete the stacks we've provisioned we have to use the destroy command:

shell
npx cdk destroy \
  cdk-stack-dev \
  cdk-stack-prod

Further Reading #

Join my newsletter

I'll send you 1 email a week with links to all of the articles I've written that week

Buy Me A Coffee