Deploy an AWS CDK project on multiple environments

Photo by Dmytro Bayer / Unsplash
Photo by Dmytro Bayer / Unsplash

The AWS CDK makes it easy to write your AWS infrastructure as code and deploy the resources on AWS. Developers use the CLI to generate, initialize, and deploy a project.

Although the CDK project generated is configured for a single environment, you can edit it to simultaneously deploy the same infrastructure to different AWS accounts.

This post will show how to deploy an AWS CDK project on many environments.

The use case

You are building enterprise-grade software where availability, reliability, and security are essential. To have a robust software development lifecycle, you want to run the software in three environments:

  • Development: Developers use this environment to test a feature or quickly implement a proof of concept. It is expected to be less reliable.
  • Staging: QA engineers and product managers use staging to review features to be released and perform prototyping. This environment is expected to be as reliable as the production one.
  • Production: It is used to serve applications to real users and is expected to be highly reliable.

For simplicity, the API will have a single endpoint exposed by an AWS Lambda Function URL that returns a message and the environment where it is deployed.

The API requirements differ across environments based on the request load they receive. The table below defines the characteristics of each environment.

Environment/Configuration Memory size Concurrent executions
Development 512 MB 05
Staging 512 MB 50
Production 1024 MB 300

This tutorial will show you how to use the AWS CDK to deploy this AWS Lambda Function on multiple environments.

Prerequisites

To follow this tutorial, make sure you have the following tools installed on your computer.

  • An AWS account, a free tier is enough
  • AWS CLI configured; I wrote a blog post to guide you.
  • Node.js 20 or higher - Read my blog post to install it.
  • Docker is required to build the Lambda function code and test it locally.
  • The AWS CDK v2 and the AWS SAM CLI installed and configured.
Create a REST API with AWS Lamda & API Gateway using AWS CDK
This post shows how to define RESTful API routes on AWS API Gateway that invoke Lambda functions. We will write the infrastructure stack using the AWS CDK and SAM for local testing.

Set up the AWS CDK project

Create a folder that will hold the source code of our project.


mkdir backend-api
cd backend-api

We will write the infrastructure in TypeScript; let's initialize a new project with the TypeScript template by running the command below:


cdk init app --language typescript

The above command creates the required files and folders and installs the node modules. The folder structure looks like this.

The structure of the project generated with the AWS CDK v2.
The structure of the project generated with the AWS CDK v2.

Define the API infrastructure with AWS CDK

The API is a single Lambda Function that exposes a public URL through a Function URL. Open the file "backend-api-stack.ts" and replace the code with the one below:


import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { resolve } from "node:path";
import { NodejsFunction} from "aws-cdk-lib/aws-lambda-nodejs";
import { FunctionUrlAuthType, InvokeMode, Runtime } from "aws-cdk-lib/aws-lambda";

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

    const lambdaApi = new NodejsFunction(this, 'BackendApiLambda', {
      bundling: {
        target: 'es2022',
      },
      entry: resolve(__dirname, '../src/handler.ts'),
      environment: {
        ENVIRONMENT: process.env.ENVIRONMENT,
      },
      functionName: 'backend-api-lambda',
      handler: 'handler',
      memorySize: 512,
      runtime: Runtime.NODEJS_20_X,
      timeout: cdk.Duration.seconds(60),
    });

    const lambdaFunctionUrl = lambdaContact.addFunctionUrl({
      authType: FunctionUrlAuthType.NONE,
      invokeMode: InvokeMode.BUFFERED,
    });

    new cdk.CfnOutput(this, 'BackendApiUrl', {
      value: lambdaFunctionUrl.url,
    });
  }
}

The Lamda Function entry points to the path "src/handler.ts" from the project root directory. Let's create this file and add the code below:


import type { LambdaFunctionURLHandler } from 'aws-lambda';

export const handler: LambdaFunctionURLHandler = async (event) => {
  return {
    body: JSON.stringify({
      message: 'Hello from the backend API!',
      environment: process.env.ENVIRONMENT,
    }),
    headers: { 'Content-Type': 'text/json' },
    statusCode: 200,
  };
};

The handler needs the AWS Lambda typing to work correctly; let's install it:


npm install --save-dev @types/aws-lambda

Define a custom configuration per environment

If we look at the Lambda Function IaC code, we can see the memory size is fixed to 512MB, and the concurrency execution is not configured. However, our environments have different values for them.

We should be able to set these values when creating the infrastructure stack for each environment. To that end, we will extend the CDK stack properties interface to add our own.


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

interface BackendApiStackProps extends cdk.StackProps {
  readonly memorySize: number;
  readonly concurrencyExecutions: number;
  readonly environment: 'development' | 'staging' | 'production';
}

Update the API stack to use this custom interface to set the Lambda memory size and reserved concurrency executions.


import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { resolve } from "node:path";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import { FunctionUrlAuthType, InvokeMode, Runtime } from "aws-cdk-lib/aws-lambda";

interface BackendApiStackProps extends cdk.StackProps {
  readonly memorySize: number;
  readonly concurrencyExecutions: number;
  readonly environment: 'development' | 'staging' | 'production';
}

export class BackendApiStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: BackendApiStackProps) {
    super(scope, id, props);

    const lambdaApi = new NodejsFunction(this, 'BackendApiLambda', {
      bundling: {
        target: 'es2022',
      },
      entry: resolve(__dirname, '../src/handler.ts'),
      environment: {
        ENVIRONMENT: props.environment,
      },
      functionName: 'backend-api-lambda',
      handler: 'handler',
      memorySize: props.memorySize,
      runtime: Runtime.NODEJS_20_X,
      timeout: cdk.Duration.seconds(60),
      reservedConcurrentExecutions: props.concurrencyExecutions,
    });

    const lambdaFunctionUrl = lambdaApi.addFunctionUrl({
      authType: FunctionUrlAuthType.NONE,
      invokeMode: InvokeMode.BUFFERED,
    });

    new cdk.CfnOutput(this, 'BackendApiUrl', {
      value: lambdaFunctionUrl.url,
    });
  }
}

Define the AWS CDK Stack per environment

A separate environment for development, testing (pre-production), and production is essential when building enterprise-grade software.

One AWS account per environment is recommended to keep the resources separated, avoid unexpected resource override/deletion, and isolate the application data.

I will use the same AWS account but in a different AWS region per environment to keep it simple.

We must create an instance of the backend API stack with the expected parameters for each environment. This is done in the file "bin/backend-api.ts" so let's update it with the code below:


#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import { BackendApiStack } from '../lib/backend-api-stack';

const app = new cdk.App();

new BackendApiStack(app, 'BackendApiDevStack', {
  concurrencyExecutions: 5,
  environment: 'development',
  memorySize: 512,
  env: {
    account: process.env.AWS_ACCOUNT,
    region: process.env.AWS_REGION // eu-west-1
  },
});

new BackendApiStack(app, 'BackendApiStagingStack', {
  concurrencyExecutions: 50,
  environment: 'staging',
  memorySize: 512,
  env: {
    account: process.env.AWS_ACCOUNT,
    region: process.env.AWS_REGION // eu-west-2
  },
});

new BackendApiStack(app, 'BackendApiProdStack', {
  concurrencyExecutions: 300,
  environment: 'production',
  memorySize: 1024,
  env: {
    account: process.env.AWS_ACCOUNT,
    region: process.env.AWS_REGION // eu-west-3
  },
});

From the above code,

  • The development environment will be deployed in Ireland.
  • The staging environment will be deployed in London.
  • The production environment will be deployed in Paris.

Synthesize the AWS CDK stack

Before deploying the services defined in the stack on AWS, we must generate the CloudFormation template from the TypeScript code.

Make sure Docker is running, then run the following commands:


aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws

npm run cdk synth

Once the execution finishes, a folder "cdk.out" is generated with the following content:

The output folder of the CDK synth command execution.
The output folder of the CDK synth command execution.

The files containing the CloudFormation stack for each environment are:

  • BackendApiDevStack.template.json
  • BackendApiStagingStack.template.json
  • BackendApiProdStack.template.json

To deploy resources for a specific environment, you must use the related CloudFormation template.

Deploy the API in each environment

Now that we have generated the CloudFormation template for each environment, to deploy it on AWS using the AWS CDK for the first time, you must deploy the AWS resources needed by the CDK to work correctly, such as storing the assets, the deployment state, etc... The AWS CDK provides the command bootstrap for that.

You must provide the AWS region of the environment you want to deploy. Once the CDK is bootstrapped, you can deploy your API stack on AWS.

Deploy the API in the development environment

Run the following commands to deploy your API stack for the development environment.


export AWS_ACCOUNT=112233445566
export AWS_REGION=eu-west-1

npm run cdk bootstrap

npm run cdk deploy --app 'cdk.out/' BackendApiDevStack

Replace the value of the environment variable AWS_ACCOUNT with your AWS account ID.

The last command will ask you to confirm the deployment and then proceed. Once the deployment is completed, the command execution will print the Lambda Function URL.

Deploy the AWS resources for the development environment.
Deploy the AWS resources for the development environment.

Copy the Lambda Function URL and save it somewhere.

Deploy the API in the staging environment

Run the following commands to deploy your API stack for the development environment.


export AWS_ACCOUNT=112233445566
export AWS_REGION=eu-west-2

npm run cdk bootstrap

npm run cdk deploy --app 'cdk.out/' BackendApiStagingStack

You get the following output:

Deploy the AWS resources for the staging environment.
Deploy the AWS resources for the staging environment.

Deploy the API in the production environment

Run the following commands to deploy your API stack for the development environment.


export AWS_ACCOUNT=112233445566
export AWS_REGION=eu-west-3

npm run cdk bootstrap

npm run cdk deploy --app 'cdk.out/' BackendApiProdStack

You get the following output:

Deploy the AWS resources for the production environment.
Deploy the AWS resources for the production environment.

Test the deployment in all environments

Use your favorite HTTP client to send a request to the API URL for each environment.

0:00
/0:21

Test the API deployed on each environment.

Deploy a change only in the development environment

Open the file "src/handler.ts" and update the message returned. Run the following commands to deploy in the development environment.


npm run cdk synth

export AWS_ACCOUNT=112233445566
export AWS_REGION=eu-west-1

npm run cdk deploy --app 'cdk.out/' BackendApiDevStack

Once deployed, verify that the development environment has the new version while the staging and production environments still use the previous version.

0:00
/0:18

Test the API deployed on the development environment.

Destroy the AWS CDK stack

To destroy the AWS resources deployed on a specific environment, run the command below:


export AWS_REGION=eu-west-1
npm run cdk destroy --app 'cdk.out/' BackendApiDevStack

export AWS_REGION=eu-west-2
npm run cdk destroy --app 'cdk.out/' BackendApiStagingStack

export AWS_REGION=eu-west-3
npm run cdk destroy --app 'cdk.out/' BackendApiProdStack

To destroy all the stacks at once, run the command:


npm run cdk destroy -- --all

Wrap up

This post showed how to deploy an AWS CDK project in multiple environments, which provides benefits such as a better developer experience, increased confidence in production releases, user data protection, and more.

The AWS CDK makes it easy to write and deploy one infrastructure stack on many AWS accounts while customizing the infrastructure according to the environment's requirements.

Here are some ideas to go further with this setup:

  • Use a separate AWS account for each environment
  • Write a bash script to deploy the task in a single command
  • Automate the deployment with CI/CD pipelines such as GitHub Actions.

You can find the code source on the GitHub repository.

Follow me on Twitter or subscribe to my newsletter to avoid missing the upcoming posts and the tips and tricks I occasionally share.