Sijil Salim
Live By The Code

Live By The Code

How to architect your first AWS project using CDK with ease

Photo by Vitolda Klein on Unsplash

How to architect your first AWS project using CDK with ease

Sijil Salim's photo
Sijil Salim
·Jun 8, 2022·

19 min read

CDK being a new kid on the block, it can be a bit tricky to set your foot on the ground on CDK development at this point of time. But no worries, I am going to guide you step-by-step and also take you through the gotchas I ran into while I was dabbling with CDK.

So, lets start with the obvious. What is CDK? It is an SDK for AWS Cloud, hence CDK (Cloud development Kit). With this "kit", you can define your cloud insfrastructure using the programming language of your choice. This approach, as opposed to writing down the required infrastructure in a Bill Of Materials (BOM) style, brings along with it many advantages.

  • You can programmatically define your infrastructure using all the programming language constructs like conditional statements, loop statements, etc. With great power, comes great documentation as well! The documentation can be accessed using autocompletion from within the IDE.
  • Your application logic can link with the infrastructure code. Without this approach, you would have to develop the code logic and infrastructure provisioning separately and then deploy your code onto the provisioned infrastructure. With the CDK approach, you can directly link which code can and should go into which infrastructure.
  • CDK supports higher level constructs which is a kind of "bundling" the commonly used constructs into a single construct, which you can directly use in your CDK stack's code. For example, creating a new instance of ApplicationLoadBalancedFargateService gives a completely load balanced fargate cluster. You dont have to separately define Application Load Balancer (ALB), Fargate cluster and link them. In CDK terminology, ApplicationLoadBalancedFargateService is a Level 3 Construct.

You may be wondering won't adopting CDK tie you to a single cloud provider, AWS? If you think for working with other cloud providers you will have to depend on tools like Terraform, then you are in for a shock. cdktf is a cdk library with which you can define terraform constructs using the programming constructs of your chosen language. Even though a major version is not yet released, its in active development and some companies are already running it in production. Once a major release is out, you can handle other cloud providers also using CDK by synthesizing terraform templates.

Now, its time to dig deeper. For now, we will focus on deploying stacks into AWS.

Stacks are deployed in the scope of an "App". First you need to create an instance of the "App" construct. We can create multiple stacks and associate those stacks with the instantiated app. Each stack is created with "constructs" as building blocks. A construct basically represents a cloud component. There are 3 levels of constructs in the AWS construct library, named as L1, L2 and L3 constructs respectively. L1 constructs are the lowest level constructs. The higher levels constructs are abstractions of lower level ones.

L1 constructs are also called as CfnResources. These constructs are named as CfnXyz, where Xyz is the name of the AWS resource. For example, an S3 bucket is created using CfnBucket construct. L2 constructs also represent AWS resources but with defaults, boilerplate and glue code logic. For example, the s3.Bucket construct comes with defaults and methods like bucket.addLifeCycleRule(). L3 constructs are the highest level constructs, also known as patterns. They are essentially a "package" of multiple resources. As highlighted earlier, an example of an L3 construct is ApplicationLoadBalancedFargateService.

Now that we have the basics covered, its time to go hands-on.


Lets build an application which will reveal the alter-ego of the superhero we type in. The frontend is a react app and the backend will be a Node.js Express server. The backend will be deployed as Fargate ECS cluster and the frontend will be served from S3 bucket. The application will be served from cloudfront which will then be mapped into a custom public domain and will be served with SSL encryption.


Repository Structure

For the sake of better flexibility, I am using Gitlab for storing the repositories . The code is structured in such a way that the whole project will reside inside a group "Devops - IaaC". The group will have the main CDK application "CDK Demo" and a subgroup "Application". The subgroup will contain two separate repositories "Frontend" and "Backend" respectively. These repositories will be referenced as submodules in the main CDK application.


Domain creation

For the sake of saving up on costs, I will be getting a domain, registered for free from Freenom, and will serve the application in that domain. Freenom is a DNS registrar which provides domain names for free. But the catch here is that the free ones are not the likes of .com, .net, etc. The top-level domains Freenom provides for free are .ml, .tk, .ga, .cf and .gq which are not standard ones. Also, the domains we get from Freenom are not stable ones, as they can take them down especially if the domain gets no traffic for a considerable amount of time. For temporary purposes, Freenom would do just fine.

So how do you get a free domain? Navigate to Freenom. There we will be presented with a search bar. We can check for the domain name we want is already taken or not by searching for the domain name in that search bar. We should get a result list showing the availability of 5 domains, .. Identify which domains are available. Say, freenom has only .ml to offer. I have did a search for a domain name "democdk".


Now, due to bug in the website, if we click on the "Get it now" button, we will get the message not available even if it is available.


To go about this issue, what we need to do, we need to come to the home page again and this time search for the complete domain name, including the top-level domain we want. Lets say, we want, enter the complete string "" in the search bar and click on "Check Availability". Now you should see that domain is available.


Click on "Checkout" and you should see the resulting page.


Change the period for which you want the domain for free from the dropdown on the right. You can get it for free for upto a maximum of 12 months. Without making any other changes, click on continue to completion. You should get confirmation mail using which you need to create an account in Freenom. Once you have an account with the domain you purchased (for FREE), you can do the domain mappings as per your wish in the future.

CDK development

CDK supports multiple programming languages. We are going to go ahead with Node.js. In order to get started, we need to have the latest version of node and aws cli installed in the system. For node installation, refer this link and for aws cli installation, check here. Once we have that out of the way, we have to configure the AWS CLI in our system with a user created from the AWS console with administrator priveleges. Check this link on how to do the same.

If you have reached till here, we are ready to roll now. Let start by installing the aws-cdk toolkit. Run the below command to globally install the latest version in your system.

npm install -g aws-cdk

The constructs can then be imported from the package aws-cdk-lib. AWS maintains the stable libraries to this module. Experimental modules are named with a suffix "alpha" and are available in @aws-cdk package. For example, since the appflow CDK module is unstable at the time of writing this blog, the construct will be available in @aws-cdk as @aws-cdk/aws-appflow-alpha. Once the construct gets stabilized, the module will move into aws-cdk-lib.

a) Backend

The backend app is an Express server. To create the server, create an empty folder and give it a name of your choice. I am going to call it "backend". Run npm init inside the the folder. This will initialize the node project. Install express and body-parser by running npm install express body-parser. The package body-parser is a middleware which parses the incoming request body. Next, create a file named index.js in the folder and add the below code into it.

const express = require('express');
const bodyParser = require('body-parser');
const app = express();
const port = 80;

app.use(bodyParser.urlencoded({ extended: false }));
app.use((req, res, next) => {
  const allowedOrigins = [ 'http://localhost:3000', process.env.origin || "http://localhost" ];
  const origin = req.headers.origin;
  if (allowedOrigins.includes(origin)) {
       res.setHeader('Access-Control-Allow-Origin', origin);
  return next();

const databank = {
  "batman":"Bruce Wayne",
  "superman":"Clarke Kent"

// Health check endpoint
app.get("/health", (req, res) => {

app.get("/superheroes/identify/:name", (req, res) => {
  let output = "";
  let inputName =;
  try {
    if (databank.hasOwnProperty(inputName)) {
    }else {
      output="Is there a superhero with that name?";
  } catch (e) { 
  res.json({ result: output });

app.listen(port, () => {
  console.log(`App listening on port ${port} !`);

The backend code has 2 endpoints: /health and /superheroes/identify/{name}. The health endpoint comes into play as the healthcheck endpoint for the fargate cluster. The second API is responsible for returning the actual name of the superhero passed in from the frontend. Note the port number used in the code, its 80. If it is something else, additional accommodations need to be made.

Fargate hosts the code as docker containers. So, we have to create a Dockerfile and a .dockerignore file in the node project. Add the below code into the dockerfile.

FROM node:16-alpine as build
ARG origin_default='http://localhost'
ENV origin=$origin_default
COPY package.json /app
RUN npm install
COPY . /app
CMD ["npm", "start"]

The dockerfile takes in origin as an environment variable. This origin variable is used in the index file of the backend code to resolve CORS issue.

The .dockerignore file is responsible for controlling which files should not go into the docker container while the image is created. This file, for the current setup, will be a very minimal file having only a single entry, node_modules. It would look like:


b) Frontend

Now that we have the backend out of our way, its time to create the frontend react app. Run the below command to setup a minimal react app using create-react-app (CRA) named "frontend": npx create-react-app frontend

In the src folder of the project created, there will a file named index.js which renders the actual root component from App.js. Replace the contents of App.js with the below code snippet.

import React, { useState } from 'react';
function App() {

  const [name, setName] = useState("");
  const [result, setResult] = useState("");

  const handleSubmit = async (event) => {
      .then(function (response) {
        return response.json();
      .then(function (data) {
      .catch(function (error) {
        console.log("Error in identifying the superhero");

  return (
    <div className="App"  >
      <h2>Unmask that superhero!</h2>
      <form onSubmit={handleSubmit}>
        <label>Name: </label>
        <input type="text" onChange={(e) => setName(} />
        <button type="submit">Reveal</button>
        <h2>{ result }</h2>

export default App;

The frontend app gets the url for backend from environment variables. To facilitate fetching values from an environment file, create a .env file with the following contents.


Modify the build script in package.json into "build": "env-cmd -f .env react-scripts build". Note here that the CRA app requires the package env-cmd to read the env file. Install env-cmd using the command npm install env-cmd.

c) CDK app

Once the frontend and the backend apps have been tested locally, push them to their respective repositories. Its time to create the CDK app now. Create an empty folder named cdk-demo (You can name it however you wish). In the project thats gets created, add an empty folder named application (Again, its upto you to name it as per your wish). This folder will hold the back and frontend repos. Clone these repos into the "application" folder. Navigate back to the root of cdk-demo folder and add the git repos cloned as submodules of cdk-demo repo. This is to logically tie the frontend and backend with the cdk-demo application. This is not a mandatory step but it is done to logically organize the codes together. To add git repos as sub modules into a parent git repo, use the command git submodule add <clone_url>. After this organization is made, once you pull in the cdk-demo application in any other location/machine, running the command git submodule update --init --recursive --merge will pull in not only just the CDK app but also the frontend and backend repos with their master branches checked out.

Alright! Lets start defining the stacks. We will define and deploy 3 stacks:

  • FargateDemoStack: ALB load balanced Fargate cluster serving backend code
  • CertificateDemoStack: Stack for creating SSL certificate
  • CloudfrontDemoStack: Stack for serving frontend

The folders bin and lib are of importance here. The bin folder houses the code for the actual CDK app and lib folder house the code for stack. We can create multiple stacks inside the lib folder and reference them from the app we create inside the bin folder.

In the lib, I will create 3 custom files named fargate.ts, certificate.ts, cloudfront.ts , each housing code for FargateDemoStack, CertificateDemoStack and CloudfrontDemoStack repectively.

Populate certificate.ts with the below contents.

import { Certificate, CertificateValidation } from 'aws-cdk-lib/aws-certificatemanager';
import { Stack, StackProps, CfnOutput } from 'aws-cdk-lib';
import { Construct } from 'constructs';

interface CustomStackProps extends StackProps {
  market: string;
export class CertificateDemoStack extends Stack {
  constructor(scope: Construct, id: string, props?: CustomStackProps) {
    super(scope, id, props);

    // Getting external configuration values from cdk.json file
    const config = this.node.tryGetContext("markets")[props?.market||'apac'];

    // The code that defines your stack goes here
    const cert = new Certificate(this, 'Certificate', {
      domainName: "*."+config.domainName,
      validation: CertificateValidation.fromDns(),

     // Certificate Arn
    new CfnOutput(this, "certificateArn", {
      value: cert.certificateArn,
      exportName: "certificateArnExport",


As you can see, the imports are coming from aws-cdk-lib. This stack takes in an environment variable named market, that is why a new interface named CustomStackProps is defined and that is used as type of props in the constructor of CertificateDemoStack class. The concept of market is just a logical concept which I have introduced for demo purposes. The intention here is to control to which market the application should be deployed like "EMEA" or "APAC". The value for the key "EMEA" or "APAC" is resolved the cdk.json file. This file tells the CDK toolkit how to execute the app. It has a key named context, the values of which is used as the context on which the app runs. The value for the market key, whether it be "EMEA" or "APAC", is resolved from this context key of cdk.json. A sample context is given below.

The Certificate construct create the certificate for the domain name resolved from cdk.json and the CfnOutput contructs exports the arn of the created certificate.

"context": {
      "emea": {
        "domainName": "<emea_domain_name>"
      "apac": {
        "domainName": "<apac_domain_name>"
    "@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": true,
    "@aws-cdk/core:stackRelativeExports": true,
    "@aws-cdk/aws-rds:lowercaseDbIdentifier": true,
    "@aws-cdk/aws-lambda:recognizeVersionProps": true,
    "@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": true,
    "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true,
    "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true,
    "@aws-cdk/core:checkSecretUsage": true,
    "@aws-cdk/aws-iam:minimizePolicies": true,
    "@aws-cdk/core:target-partitions": [

Next, create fargate.ts as shown below:

import { Stack, StackProps,Fn,CfnOutput} from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Vpc } from "aws-cdk-lib/aws-ec2";
import { Cluster, ContainerImage } from "aws-cdk-lib/aws-ecs";
import { ApplicationLoadBalancedFargateService } from "aws-cdk-lib/aws-ecs-patterns";

// import * as sqs from 'aws-cdk-lib/aws-sqs';

interface CustomStackProps extends StackProps {
  market: string;
export class FargateDemoStack extends Stack {
  constructor(scope: Construct, id: string, props?: CustomStackProps) {
    super(scope, id, props);

     // VPC
    const vpc = new Vpc(this, "fargateVPC", {
      maxAzs: 2,
      natGateways: 1,

    // Fargate cluster
    const cluster = new Cluster(this, "fargateCluster", {
      vpc: vpc as any,

    // Getting external configuration values from cdk.json file
    const config = this.node.tryGetContext("markets")[props?.market||'apac'];

    // Fargate service
    const backendService = new ApplicationLoadBalancedFargateService(this, "backendService", {
      cluster: cluster,
      memoryLimitMiB: 1024,
      cpu: 512,
      desiredCount: 2,
      taskImageOptions: {
        image: ContainerImage.fromAsset("./application/backend/"),
        environment: {
          origin: "https://"+config.subDomain+"."+config.domainName,

    // Health check
    backendService.targetGroup.configureHealthCheck({ path: "/health" });

    // Load balancer url
    new CfnOutput(this, "loadBalancerUrl", {
      value: backendService.loadBalancer.loadBalancerDnsName,
      exportName: "loadBalancerUrl",


In this stack, we get the domain values for the market selected (this is done from the main cdk app which will come to in a later part of this article) from the cdk.json. This value is passed in as environment variable "origin" while creating the fargate service using the ApplicationLoadBalancedFargateService construct. The key is named "origin" here because that is what the Dockerfile and ultimately the backend code expects as environment variable. The code to be hosted is resolved using the option taskImageOptions. This option takes the Dockerfile from the location ./application/backend/ and dockerizes the code into an image and pushes it to ECR. Fargate then uses this image for deployment.

Since we have defined the /health endpoint in the backend code, this endpoint can be configured in the stack class for healthcheck. Finally, the load balancer URL will be outputted by the stack.

Finally, lets create the cloudfront.ts file. Paste in the below code to cloudfront.ts

import { Stack, StackProps,Fn,RemovalPolicy,CfnOutput} from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Bucket } from "aws-cdk-lib/aws-s3";
import { BucketDeployment, Source } from "aws-cdk-lib/aws-s3-deployment";
import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
import * as acm from "aws-cdk-lib/aws-certificatemanager";
import {
} from "aws-cdk-lib/aws-cloudfront";

interface CustomStackProps extends StackProps {
  market: string;

export class CloudfrontDemoStack extends Stack {
  constructor(scope: Construct, id: string, props?: CustomStackProps) {
    super(scope, id, props);

    // Importing ALB domain name
    const loadBalancerDomain = Fn.importValue("loadBalancerUrl");

    // Getting external configuration values from certificate stack
    const certificateArnImport = Fn.importValue("certificateArnExport");

    // SSL certificate 
    const certificateArn = acm.Certificate.fromCertificateArn(this, "tlsCertificate", certificateArnImport);

    // Web hosting bucket
    let websiteBucket = new Bucket(this, "websiteBucket", {
      versioned: false,
      publicReadAccess: true,
      removalPolicy: RemovalPolicy.DESTROY,

    // Trigger frontend deployment
    new BucketDeployment(this, "websiteDeployment", {
      sources: [Source.asset("./application/frontend/build")],
      destinationBucket: websiteBucket as any

    // Create Origin Access Identity for CloudFront
    const originAccessIdentity = new OriginAccessIdentity(this, "cloudfrontOAI", {
      comment: "OAI for web application cloudfront distribution",

    // Getting external configuration values from cdk.json file
    const config = this.node.tryGetContext("markets")[props?.market||'apac'];

    // Creating CloudFront distribution
    let cloudFrontDist = new Distribution(this, "cloudfrontDist", {
      defaultRootObject: "index.html",
      domainNames: [config.subDomain+"."+config.domainName],
      certificate: certificateArn,
      defaultBehavior: {
        origin: new origins.S3Origin(websiteBucket as any, {
          originAccessIdentity: originAccessIdentity as any,
        }) as any,
        compress: true,
        allowedMethods: AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
        viewerProtocolPolicy: ViewerProtocolPolicy.HTTPS_ONLY,

    // Creating custom origin for the application load balancer
    const loadBalancerOrigin = new origins.HttpOrigin(loadBalancerDomain, {
      protocolPolicy: OriginProtocolPolicy.HTTP_ONLY,

    // Creating the path pattern to direct to the load balancer origin
    cloudFrontDist.addBehavior("/superheroes/identify/*", loadBalancerOrigin as any, {
      compress: true,
      viewerProtocolPolicy: ViewerProtocolPolicy.ALLOW_ALL,
      allowedMethods: AllowedMethods.ALLOW_ALL,

    // Cloudfront url output
    new CfnOutput(this, "cloudfrontDomainUrl", {
      value: cloudFrontDist.distributionDomainName,
      exportName: "cloudfrontDomainUrl",

In this class, we can see that the loadBalancerUrl value exported from the fargate.ts stack and the certificateArnExport exported from the certificate.ts stack is imported. Using the imported certificateArnExport value, certificateArn is created. These 2 values will now be used in creating the cloudfront distribution. Now, there are 2 behaviours for the cloudfront distribution we are going to create, / and /superheroes/identify/*. For /, the react app build files need to be served from an S3 bucket. The build folder should be created before deploying this stack. To create the build, navigate to the frontend folder and run npm run build. To automate even this step, CDK pipelines can be used, but it is not in the scope of this article. Using the BucketDeployment construct, the frontend build files can deployed into an S3 bucket.

Create an Origin Access Identity (OAI) for the cloudfront distribution. This is required so that the S3 bucket contents can only be viewed via the Cloudfront and not via direct public access. The S3 bucket will then be associated with this OAI while creating the cloudfront distribution using the defaultBehavior option of the Distribution construct. The loadBalancerUrl value exported from the previous stack will be used in adding a behaviour for the path /superheroes/identify/* of the cloudfront distribution. This whole setup would gives a distribution which serves the react build files at the / path and when the API call for fetching the alter-ego of the superhero is called (/superheroes/identify/<superhero_name>), the request would be directed to the loadbalancer handling the fargate cluster.

For better security, we can set the allowedMethods option of the cloudfront distribution to accept only https traffic. Finally, the URL of the cloudfront distribution will be outputted using the construct CfnOutput.

We have the stack configuration ready. Now lets head over to the bin folder and define our CDK app. Create a file named cdk-demo.ts (and again, this is a custom name). Paste the below contents into this file.

#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { FargateDemoStack } from "../lib/fargate";
import { CloudfrontDemoStack } from "../lib/cloudfront";
import { CertificateDemoStack } from "../lib/certificate";

const app = new cdk.App();
const deploymentMarket="emea";

// Fargate stack
new FargateDemoStack(app, "FargateDemoStack", {
  market: deploymentMarket,
  env: { account: "465392140585", region: "us-east-1" },

// Certificate stack
new CertificateDemoStack(app, "CertificateDemoStack", {
  market: deploymentMarket,
  env: { account: "465392140585", region: "us-east-1" },

// Cloudfront stack
new CloudfrontDemoStack(app, "CloudfrontDemoStack", {
  market: deploymentMarket,
  env: { account: "465392140585", region: "us-east-1" },

In the app, we are creating the 3 stacks using the classes we defined for them in their respective files. The environment variable key env defines in which AWS environment should the stacks be created. The "market" environment variable is one we have created for our "business" logic.

d) Deployment

The first and foremost thing after we have all the code in place is to run cdk bootstrap. This command would do the necessary bootstrapping for the CDK toolkit in the AWS account we have mentioned in the app. It will create, among other things, an S3 bucket to hold the cloudformation template to be deployed. The command cdk synth would synthesize the cloudformation template. We can verify if our CDK app is able to generate the cloudformation template to be deployed without any errors. Once we have verified the synth command produces a well-formed template, we can issue the cdk deploy command. This will push the template onto the S3 bucket and deploy the template into the account. cdk synth is not a necessary command as cdk deploy will perform it behind the scenes, nevertheless, we run to ensure there are no errors.

In our setup, we have stacks dependent on other stacks, so we have to make sure the stacks are deployed in order. First we need to have the the fargate cluster deployed first as it generates the loadBalancerUrl which goes as an input to the cloudfront stack. Next we need the certificate stack to run as the output of this stack, certificateArnExport, is an input to the cloudfront stack. Once these two stacks have been deployed, we need the cloudfront stack to deployed. Since, in our app, we have arranged the stacks in order, we can directly run cdk deploy. To deploy each stack independently, run cdk deploy <class_name_of_stack>.

Domain setup

For the certificate generated from the CertificateDemoStack, we have to prove to AWS the domain for which i generated the certificate, actually belongs to me. For this, login to the Freenom account and go to the "My Domains" page in the "Services" tab.


Click on the "Manage Domain" button to get to the settings of the domain.


Click on "Manage Freenom DNS" and you get to the page where you can add the records for the domain. We need to add two CNAME records. The first CNAME record is for proving to AWS the domain belongs to us only. For this, navigate to the Certificate Manager page in AWS console and click on the certificate created by the stack. In there you should see a row under the section "Domains" which provides a CNAME name and CNAME value. Copy CNAME name and paste it into the name field and copy the CNAME value and paste it into the Target field. Select the type of record to be CNAME. Once this is done and the details are saved, wait for a couple of minutes (as the change takes a couple of minutes to propagate through the DNS servers) and head back to the Certificate Manager in AWS and you should see the status of the certificate changed from "Pending Validation" to "Issued".

Add a second CNAME record with the name as "www" and value as the cloudfront URL which got generated when the cloudfront stack was deployed. This CNAME record would ensure that when the domain name is prefixed with www, DNS would route the request to the cloudfront URL.


All you got to do is to enter the web address, .ml (the top-level domain which you have chosen) in a browser and just hit enter. Superhero app should now be live now in your own domain. Do note that the app accepts only https traffic as we have explicitly mentioned in the cloudfront stack to accept only https traffic using the option viewerProtocolPolicy. If this option is set to ViewerProtocolPolicy.ALLOW_ALL, it would accept both http and https traffic.

Share this