Hamburger Icon

Building a serverless API with the AWS CDK

Build a serverless API with the AWS CDK! Deploy Lambda functions, API Gateway, S3, and IAM roles, and use Amazon Comprehend to redact PII from text.

Jan 27, 2025 • 8 Minute Read

Please set an alt value for this image...

Serverless is supposed to make things simpler, but depending on how you deploy your code and components, you might be making things harder on yourself. One solution to the issue of deploying and managing serverless architecture is to code it, and one option out of the many infrastructure-as-code tools is the AWS Cloud Development Kit. You're already writing your application code in an IDE, after all, why not build out your infra alongside it? And to figure out how, we'll be building a little Lambda-backed API that uses Amazon Comprehend to redact personally identifiable information from text.

Project Rundown

The Lambda function is written in Node.js, and I'll be using the CDK with TypeScript to write the infrastructure code, although the CDK works with a number of other languages.

The components of the application the CDK will deploy include:

  • The Lambda function

  • An HTTP API Gateway

  • An S3 Bucket

  • Any related IAM permissions

These components will be deployed using constructs, which are the building blocks of the CDK. These are modular, reusable bits of code that can do things like deploy an S3 bucket or create an API Gateway:

      const s3PIIRemovedBucket = new s3.Bucket(this, 
'PIIRemovedBucket', {
  removalPolicy: cdk.RemovalPolicy.DESTROY,
});

    

The CDK API Reference is an invaluable resource in working with constructs, which is the majority of what you do when working with the CDK. Bookmark it! This will tell you how to use the constructs you need to build your application.

These constructs are organized into stacks, which are collections of construct code that are deployed together. In this case, our entire API will be contained in a single stack, although if you wanted to you could have it so the Lambda function deploys with its permissions, separate from the S3 bucket, which is separate from the API Gateway—or any number of configurations. There's no one right or wrong way to organize a stack, I'm just keeping things concise for this tutorial.

Prepare the Project

To begin, you'll need to have the AWS CDK installed. The CDK uses Node.js under the hood, and can be installed using the Node Package Manager:

      npm install -g aws-cdk

    

You'll also want to have the AWS CLI installed to manage your permissions. If you need to install it, check out AWS's instructions for your system.

Then make sure you're credentials are appropriately configured:

      aws configure
    

With that configured, the CDK has some configuration work of its own. The CDK uses an S3 bucket to store necessary files while deploying your projects. To provision this, run:

      cdk bootstrap

    

Now move into wherever you keep your code (or learning projects), and initialize your CDK project:

      mkdir pii-remover
cd pii-remover
cdk init app --language typescript
    

Notice the structure of the initialized directory: The bin/pii-remover.ts file contains the code wherein you can define your stacks. Remember, you’re only going to have one at the end of this, so you don't need to touch this.

The lib directory is where you’ll do most of the work. How you structure your code here can depend: You can dump everything in the pre-generated pii-remover-stack.ts file or create separate functions for various components saved in their own files and folders and called in one or more greater stack functions. It's up to you! I'll be taking the "dump everything in pii-remover-stack.ts route.

The node_modules and test directories contain necessary node modules and a skeleton directory for test code, respectively. You won't be working with these directly in this tutorial.

Creating the Stack

Now let's get started with some code! Open the pii-remover-stack.ts file. All of the construct code for the application will be written as part of the PiiRemoverStack class and added after the super(scope, id, props); line.

But before you get into that, let's update the necessary libraries that you'll use by adding the imports, or the construct libraries the application will use:

      import * as cdk from 'aws-cdk-lib';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as apigatewayv2 from 
'aws-cdk-lib/aws-apigatewayv2';
import { HttpLambdaIntegration } from 
'aws-cdk-lib/aws-apigatewayv2-integrations';
import { Construct } from 'constructs';
    

Storage

We'll start with the simplest construct definition: The one that adds our S3 bucket. Remember that example construct from above? That's the one you’ll use to create a bucket! Notable about this bucket is you'll want to be able to reference it later within the Lambda function. Remember that!

          // Storage bucket for feedback with redacted PII
    const s3PIIRemovedBucket = new s3.Bucket(this, 
'PIIRemovedBucket', {
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });
    

Notice how it uses the s3 construct library to define that it wants a new bucket and includes any configuration parameters within that definition. In this case, you just want to destroy the bucket when you destroy the applications. Auto-destroying isn't always the default action the CDK will take for certain components, such as S3 buckets and DynamoDB tables.

Permissions

Let's now define the permissions our Lambda code needs to run successfully. This means creating a new policy with the appropriate permitted actions, and then creating a new role for our Lambda function. You'll do this by using the iam construct library from the aws-cdk-lib.

The first construct will define the policy:

          // Lambda Function Permissions
    // Policy to access S3 and Comprehend
    const lambdaPIIRemoverPolicy = new 
iam.ManagedPolicy(this, 'PIIRemoverPolicy', {
      managedPolicyName: 'PIIRemoverPolicy',
      statements: [
        // S3 access policy
        new iam.PolicyStatement({
          effect: iam.Effect.ALLOW,
          actions: [
            "s3:PutObject",
            "s3:GetObject",
            "s3:ListBucket"
          ],
          resources: [
            s3PIIRemovedBucket.bucketArn,
            `${s3PIIRemovedBucket.bucketArn}/*`
          ]
        }),
        // Comprehend access policy
        new iam.PolicyStatement({
          effect: iam.Effect.ALLOW,
          actions: [
            "comprehend:DetectPiiEntities"
          ],
          resources: ["*"]
        })
      ]
    });
    

The second will define the role:

          // Role
    const lambdaPIIRemoverRole = new iam.Role(this, 
'PIIRemoverRole', {
      assumedBy: new iam.ServicePrincipal
('lambda.amazonaws.com'),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName
('service-role/AWSLambdaBasicExecutionRole'),
        lambdaPIIRemoverPolicy
      ],
    });
    

Notice how it references the lambdaPIIRemoverPolicy constant you just created.

Function

Now let's add the function! But first... you need to add the Lambda function itself. At the root level of the project directory, create a functions directory either through your IDE or in the terminal:

      mkdir functions

    

Then create a file within that directory, pii-remover.mjs. Copy in the following code:

      import { S3Client, PutObjectCommand } from 
"@aws-sdk/client-s3";
import { ComprehendClient, DetectPiiEntitiesCommand } 
from "@aws-sdk/client-comprehend";

const s3 = new S3Client();
const comprehend = new ComprehendClient();
const bucketName = process.env.BUCKET_NAME;

export const handler = async (event) => {
    const { comment } = JSON.parse(event.body || '{}');
    if (!comment) return { statusCode: 400, body: 
'{"message":"Comment is required."}' };

    const pii = await comprehend.send
(new DetectPiiEntitiesCommand({ Text: 
comment, LanguageCode: 'en' }));
    const redacted = pii.Entities?.sort((a, b) => 
b.BeginOffset - a.BeginOffset).reduce(
    (text, entity) => text.slice(0, entity.BeginOffset) 
+ '[REDACTED]' + text.slice(entity.EndOffset),
        comment
    );

  const fileName = `redacted-comment-${Date.now()}.txt`;
  await s3.send(new PutObjectCommand({ 
Bucket: bucketName, Key: fileName, Body: redacted, 
ContentType: 'text/plain' }));

    return { statusCode: 200, body: JSON.stringify({ 
message: 'Comment saved.', fileName, fileUrl: 
`https://${bucketName}.s3.amazonaws.com/$
{fileName}` }) };
};

    

This function accepts feedback sent via an API body and uses Comprehend to redact personal information. Notice how it expects an environmental variable (const bucketName = process.env.BUCKET_NAME;), which you'll configure within the CDK code.

Speaking of the CDK code, head back to lib/pii-remover-stack.ts and continue where left off by defining the Lambda function:

          // Lambda function
    const piiRemoverFunction = new lambda.Function
(this, 'PIIRemoverFunction', {
      runtime: lambda.Runtime.NODEJS_20_X,
      handler: 'pii-remover.handler',
      code: lambda.Code.fromAsset('functions'),
      environment: {
        BUCKET_NAME: s3PIIRemovedBucket.bucketName,
      }
    });
    

Notice how the environmental variable for the bucket references the s3PIIRemovedBucket constant, and uses the bucketName property accessory of the overall s3.Bucket construct to reference the bucket name.

API Gateway

Next, the API Gateway. There are two main construct libraries for working with the API Gateway service, and for this the best choice is the apigatewayv2 module, which is specifically focused on working with HTTP and websocket-based APIs. Only one route is needed: POST, for providing survey comments.

          // API Gateway integration
    const surveyCommentIntegration = new 
HttpLambdaIntegration('SurveyCommentIntegration', 
piiRemoverFunction);

    const api = new apigatewayv2.HttpApi(this, 
'surveyAPI', {
      apiName: 'Survey API'
    });

    // API Gateway routes
    // POST /comment
    api.addRoutes ({
      path: '/comment',
      methods: [ apigatewayv2.HttpMethod.POST],
      integration: surveyCommentIntegration
    })
    

And because knowing the URL of the API Gateway is fairly important for actually using it, let's use CfnOutput to ensure the URL is output on the terminal when deployed:

          // Output API URL
    new cdk.CfnOutput(this, 'APIUrl', {
      value: api.apiEndpoint,
      description: 'The base URL for the API Gateway'
    });
    

Synthesize, Deploy, and Test

Now, let's see if this thing works!

Synthesize

A quick way to see if there's any issue in your CDK code (but not your Lambda function) is to run a:

      cdk synth

    

This "synthesizes" the code, which just means it creates a CloudFormation template. This is the template used to deploy the actual infra when the time comes.

Deploy

And if that looks good, deploy!

      cdk deploy

    

Once deployed, retrieve the output API URL—you're going to use this to test the function.

Test

To test the code, use curl to POST a comment with PII. Use that retrieved API URL and update the following command:

      curl -X POST \ 
-H "Content-Type: application/json" \
-d '{"comment": "This is a comment with PII with an 
email hello@example.com and a phone number 
555-555-5555."}' \
<YOUR_API_URL>/comment

    

You can further check by logging in to the AWS Console and bringing up CloudFormation. Look for the provisioned bucket, which should contain your comment with its PII redacted:

      This is a comment with PII with an email 
[REDACTED] and a phone number [REDACTED]
    

Success! Your app is deployed and working.

Destroy

Now, to save money since this is a tutorial: Tear it down!

      cdk destroy

    

Take it further: Refactoring  and other tips

This was just one way to organize an API working with the CDK, and if you want to take things further, consider how you might refactor this so the code is a little more... bite-sized. If you need help or ideas on how to do this, check out my course, Deploying Applications with the AWS CDK, here on Pluralsight! You'll get to deploy another application, and check out some different ways for app organization.