Creating your serverless web-application using AWS CDK – part 1

-

So you are looking to build a web-application. You want it to be serverless and hosted on AWS, what do you do? In this blog post I will tell you why I like to use CDK for this. I will also explain how to use CDK to set up your own web-application with ease.

Infrastructure as code

Creating a serverless application in AWS means that you will be configuring certain AWS resources to behave the way you want them to. By doing this, you set up the infrastructure for your application. A quick and easy way to set up this infrastructure would be to navigate to the AWS portal using your browser and manually configure everything there. But what will you do if you need to deploy your application a second time? You would have to painstakingly document every step you take and every field you fill so that you can repeat it at a later time. Also if you make any changes you need to make sure to update your documentation. Sounds exhausting right?

This is where infrastructure as code solutions come in to save the day. By defining your infrastructure as code, just as you would with other code in your application, you can maintain it in a version control system. Any change you make in the infrastructure is being tracked and at any time you can checkout the latest version of the infrastructure and deploy it immediately. You can even automate such deployments to start automatically whenever you check in your changes.

Why I use CDK

Nowadays there are plenty of options for you to choose from when it comes to infrastructure as code tooling. Each of these tools comes with its own pro’s and con’s. It is hard to pick one tool that is best for every single situation. CDK however has two strong points which for me, makes it the tool I choose.

The first is that as a developer, CDK feels very familiar. CDK allows you to define your infrastructure setup using Typescript, Javascript, Python, Java, C#/.Net and Go. This means that if you are a developer with experience with one of these languages, you do not have to spend time learning a syntax first. Right away you can put all your focus on learning about AWS resources and how to set them up.

The second is that CDK offers a very nice balance between helping you deploy resources with little effort and giving you the flexibility where you need it. It has a bunch of higher level constructs with reasonable defaults that you can easily use to quickly get started. However you can override the defaults on these constructs, or use lower level constructs in the same framework to create whatever you need. This means you are always in control.

What do you need?

The first thing you need is to decide on the language you are going to use with CDK. In this case I chose to use Typescript.

Apart from picking a language, in order to get started with AWS CDK you need:

The basic setup

We will start building our web-application with a simple basic setup which contains the minimum you would need to see something work. After that is set up, we will expand upon this basic setup until we have something that we could put into production.

Our basic setup will look like this:

The static web resources of our application frontend such as the HTML, CSS and Javascript will be hosted in an S3 bucket. We will use DynamoDB to store the persistent data of our application and Lambda and API Gateway to offer an API over HTTPs that allows access to this data.

The first thing we will need to do is create a new directory for our application. In my case I am creating a simple Todo list application, so I create a directory called TodoApplication. Move into this directory and execute the command:

cdk init app --language typescript

This will give us the files and folders we need to get started with our application.

In order to keep the length of this blog somewhat limited I am only showing the CDK code in this blog, with a few exceptions here and there. If you want to see the full solution however it is available on GitHub. For the basic setup, you have to checkout the basic_setup tag.

The frontend

The next thing we will do is set up the frontend. We create a directory called application and another directory called frontend in that. In this directory we will put the code for the frontend application. I have used Angular to create my frontend, but feel free to use another frontend framework if you like.

One of the files that we created when we initialised our CDK application is libs/todo_application-stack.ts. This is where we define all AWS resources our application uses. We do this by defining them in the constructor of the TodoApplicationStack class that was created for us.  For our frontend application to be hosted in S3, we add the following:

const frontendBucket = new s3.Bucket(this, 'TodoApplicationFrontend', {
  websiteIndexDocument: 'index.html',
  publicReadAccess: true,
  removalPolicy: cdk.RemovalPolicy.DESTROY,
  autoDeleteObjects: true
});

const bucketDeployment = new s3deploy.BucketDeployment(this, 'DeployTodoApplicationFrontend', {
  sources: [s3deploy.Source.asset(`application/frontend/dist/todo-application`)],
  destinationBucket: frontendBucket
});
bucketDeployment.node.addDependency(frontendBucket);

This code adds two CDK constructs to the CDK stack. The first construct is an S3 bucket that we create. By setting the websiteIndexDocument property we also enable static website hosting for this bucket and provide the name for the index document of the website. The second setting we set on the S3 bucket is that we enable public read access on the bucket. The last two settings dictate what happens to this bucket when the CDK stack is destroyed.

The removal policy tells CDK what to do with the bucket itself. The default setting “retain” orphans the S3 bucket, leaving it disconnected from the CDK stack but does not remove the bucket or the files in it. I want the bucket and its contents to be deleted along with the rest of the CDK stack which is why I set it to destroy. In the case of an S3 bucket, this action will fail if there are still any files left in the bucket. To allow these files to be removed along with the bucket, I set autoDeleteObjects to true.

The other construct is a deployment of resources to S3. In this case, we deploy everything in application/frontend/dist/todo-application to the S3 bucket we just created. This directory is where Angular stores the frontend distribution after running npm run build. If you use a different framework to create your frontend you may have to change this path.

You might notice that if you paste the above code in the CDK stack, it will not compile. This is because we are missing the @aws-cdk/aws-s3 and @aws-cdk/aws-s3-deployment node modules. We can install those using:

npm install @aws-cdk/aws-s3 @aws-cdk/aws-s3-deployment

We also need to import them into the stack:

import * as s3 from '@aws-cdk/aws-s3';
import * as s3deploy from '@aws-cdk/aws-s3-deployment';

Now we can deploy this stack to AWS using:

cdk deploy

When this is done, you will have a publicly accessible S3 bucket that hosts your frontend.

Database

Now that we have our frontend, we shall set up the DynamoDB database we use to store our data.

First we need to add the @aws-cdk/aws-dynamodb node module to our project and import it just like we did earlier. Then secondly we add the following to the TodoApplicationStack:

const todoItemsTable = new dynamodb.Table(this, 'TodoApplicationTodoItemsTable', {
  partitionKey: {
    name: 'who',
    type: dynamodb.AttributeType.STRING,
  },
  sortKey: {
    name: 'creationDate',
    type: dynamodb.AttributeType.STRING
  },
  removalPolicy: cdk.RemovalPolicy.DESTROY
});

This code adds a new construct to our CDK stack that creates a DynamoDB table. The table will have a partition key of type String named “who”. It’s value will contain the username of the user the Todo item was created by. The table will also have an additional sort key, also of type String, that contains the creation date of the Todo item. This allows us to retrieve the Todo items from the table ordered by their creation date. The last property that I added to my table construct is again an override of the removal policy. By default the removal policy for a DynamoDB table is “retain”. Like with the S3 bucket I want the table to be removed when I delete the CDK stack, so I set it to destroy.

API

It is a good thing to have our database table set up, however at the moment the frontend has no way to access it. To solve this, we need to set up our API. Setting up the API that allows the frontend to reach the database is the largest part of our basic setup. To keep things clear and orderly, we will do this in a couple of steps.

Shared code layer

We start with a Lambda layer that houses shared code that can be used by multiple Lambda functions. In my Todo list application I have a TodoItem class that I want to reuse. Another reason why I want to have this shared code layer is that I use NodeJS for my Lambda functions. This means my functions use node modules, but I do not want to include those in every Lambda function when I deploy them to AWS. Instead I put all the node modules in this shared code layer. This way I keep my functions nice and clean with only function specific code.

To create this shared code layer, we start by creating a directory called functions in the application directory we created earlier. Here we keep the code for our Lambda functions. Within that directory we then create another directory called shared-code and finally a directory called nodejs in that. In this directory we place a package.json for the node modules as well as the TodoItem.ts and the tscompile.json and tsconfig.json files to compile it.

To use Lambda for the shared code layer in the TodoApplicationStack, we need to import it just as we did earlier on with S3 and DynamoDB:

import * as lambda from '@aws-cdk/aws-lambda';

Next, to deploy our shared code layer we add the following code to the stack:

const sharedCodeLayer = new lambda.LayerVersion(this, 'TodoApplicationSharedCode', {
  code: lambda.Code.fromAsset('application/functions/shared-code'),
  compatibleRuntimes: [lambda.Runtime.NODEJS_14_X]
});

This adds a LayerVersion construct for us. The settings we give it are not all the special. We simply point to the location of the code to include and specify that that we want to use the NodeJS 14.X runtime.

Lambda functions

Time to make a Lambda function that uses the shared code layer we created just now. In this case I have two Lambda functions, one to retrieve Todo items from the DynamoDB table and one to create items in the table. Within the existing directory application/functions we create the directories get-items and add-item. The code for our Lambda functions goes into these directories.

Most of the code in our Lambda function is like in any other Lambda function. I will therefore not be going into the code of the Lambda functions here, if you want you can check them out here. However there is one thing I do want to point out. Because we use a Lambda layer to house some shared code, any class that is in this layer must be imported as follows:

import { TodoItem } from '/opt/nodejs/todoItem';
This in turn will make the compiler complain when you compile the Typescript on your local machine, because this path cannot be found in the local repository. To counteract this, add the following to the tsconfig.json of the Lambda functions:
"paths": {
  "/opt/nodejs/*": ["../shared-code/nodejs/*"]
}

Now we can add the Lambda function to the TodoApplicationStack with the following code:

const getItemsLambda = new lambda.Function(this, 'TodoApplicationGetItemsFunction', {
  runtime: lambda.Runtime.NODEJS_14_X,
  handler: 'index.handler',
  code: lambda.Code.fromAsset('application/functions/get-items', {exclude: ["node_modules", "*.json"]}),
  environment: {
    TODO_ITEMS_TABLE_NAME:todoItemsTable.tableName,
    ALLOWED_ORIGINS:'*'
  },
  layers: [
    sharedCodeLayer
  ]
})
todoItemsTable.grantReadData(getItemsLambda)

With this code we add another CDK construct, this time for a Lambda function. In this case the function shown here is get-items. Apart from some names and paths the add-item function is exactly the same.

In settings we set the runtime for the function to NodeJS 14.X and set the handler for the function to the exported handler function of Index.ts. We also direct CDK to the location of the code for this function. From this path, we exclude everything under node_modules and everything that has the extension json. The node modules we exclude because they are in our shared code layer. The JSON files in our function we exclude because we only use them at compile time before pushing the code to AWS. For these two functions I also included two environment variables that can be accessed within the function. This keeps me from having to hard code certain values in the function code itself. Finally I pointed this Lambda function to the shared code layer so that it can access the code there.

The last line of this code instructs the DynamoDB table construct to allow read access to the Lambda function. This creates the correct IAM access rights for the Lambda function.

API Gateway

The last component needed for the basic setup is an API Gateway that exposes our lambda Functions to the frontend. To set up this API Gateway we add the @aws-cdk/aws-apigateway node module to our project and import it into the TodoApplicationStack. Next we add the following code to the stack:

const apiGateway = new apigateway.RestApi(this, 'TodoApplicationApiGateway', {
  restApiName: 'TodoApplicationApi'
})

const itemResource = apiGateway.root.addResource('item')
itemResource.addCorsPreflight({
  allowOrigins: [ '*' ],
  allowMethods: [ 'GET', 'PUT' ]
});
itemResource.addMethod('PUT', new apigateway.LambdaIntegration(addItemLambda), {})
itemResource.addMethod('GET', new apigateway.LambdaIntegration(getItemsLambda), {})

The first thing this code does is add the CDK RestApi construct for the API Gateway. Other than setting the name, we currently do not need to change any settings on this construct from their default setting.

With the RestApi construct in place, we add a resource to the root endpoint of this API called item. To this resource we then add the CORS preflight OPTIONS method. We set this resource to allow all origins since we want to create a public API. We also set it to allow the GET and PUT methods as those are the ones our Lambda functions will listen to.

Finally we add the GET and PUT methods to the item resource by creating two new LambdaIntegration instances. Into their constructor we pass the two Lambda functions that were created earlier in the stack as the handler functions for these methods.

Config JS

With the API Gateway in place we have all the components that we require for the basic setup. We have one problem left to solve however before the basic setup is fully functional. Because we do not yet use a custom domain for the API we created, the url to invoke this API is one in the amazonaws.com domain and generated when we deploy the API Gateway. This means we cannot simply hard code this url into the code we deploy for the frontend.

I solve this by creating a config.js file and uploading this to the frontend S3 bucket at the end of the deployment. At this stage of the deployment the API Gateway construct is in place. This means I can use the property url on the construct to get access to the url and write that into config.js. As my frontend is created with Angular, it can read out this config.js file to access the url to the API Gateway.

To create this custom resource and upload it, I need to add the following import to the TodoApplicationStack:

import * as customResources from '@aws-cdk/custom-resources';

Then I add the following code to the stack:

const frontendConfig = {
  itemsApi: apiGateway.url,
  lastChanged: newDate().toUTCString()
};

constdataString = `window.AWSConfig = ${JSON.stringify(frontendConfig, null, 4)};`;

const putUpdate = {
  service: 'S3',
  action: 'putObject',
  parameters: {
    Body: dataString,
    Bucket: `${frontendBucket.bucketName}`,
    Key: 'config.js',
  },
  physicalResourceId: customResources.PhysicalResourceId.of(`${frontendBucket.bucketName}`)
};
const s3Upload = new customResources.AwsCustomResource(this, 'TodoApplicationSetConfigJS', {
  policy: customResources.AwsCustomResourcePolicy.fromSdkCalls({ resources: customResources.AwsCustomResourcePolicy.ANY_RESOURCE }),
  onUpdate: putUpdate,
  onCreate: putUpdate,
});
s3Upload.node.addDependency(bucketDeployment);
s3Upload.node.addDependency(apiGateway);

This code creates an object that contains the API url and a last changed date. It then turns that object into a data string. Lastly it makes an AwsCustomResource instance. We instruct this instance to perform the action putObject on S3 with the specified parameters whenever it is either created or updated. We make CDK generate the correct IAM policy statements based on the SDK calls made by the actions performed. Because we give the AwsCustomResource instance a dependency on the bucketDeployment and apiGateway, we ensure that it is deployed after those were created.

Deploying

Before we deploy everything, make sure to compile the frontend and all Lambda functions. In my project this means I use:

npm install
npm run build

on all of them as they are all NodeJS based. With the code compiled, we use:

cdk deploy

to deploy the stack. In the AWS portal you can find the S3 bucket. In its properties under static web hosting you will find the url where your web application is now running.

My Todo list application now looks like this:

todo item application

We have now completed the basic setup for our serverless web-application with AWS CDK. We have a running frontend to serve to our users. To store the data of our users we have created a DynamoDB database. Finally we created a HTTP API that connects to frontend to the database.

This basic setup however is not quite ready for us to use in production. To get the application production ready, we need to address the following:

  • The url to the application is somethingsomething.s3-website-region.amazonaws.com. This does not help users easily reach the application.
  • The application does not offer a HTTPS connection for the frontend, so access to the application is not secure.
  • The current application has no user authentication. You can access the data of any user by simply entering the correct username.

We solve these issues in the second part of this blog.