Setup a CI/CD Pipeline for AWS CDK Applications

CDK Pipelines is a high-level construct library that makes it easy to set up a continuous deployment pipeline for your CDK applications. The pipeline is powered by a popular Continuous Integration (CI) and Continuous Delivery (CD) tool called AWS CodePipeline.

A brief overview of the infrastructure we will create:

  • GitHub repository that contains source code for a lambda function and the pipeline itself.
  • The CI/CD pipeline will be defined though an AWS CDK stack that uses TypeScript programming language.
  • Each commit to the repo will publish the lambda function to a test environment.
  • We will have a manual approval step to deploy the lambda function code to a production environment.

Note: The solution just deploys a lambda but you can add some other configuration/resources that you want, this is just a starter template!

A pipeline consists of several stages, which represent logical phases of the deployment. Each stage contains one or more actions that describe what to do in that particular stage.

The pipeline created by CDK pipelines is self-mutating. This means you only need to run cdk deploy one time to get the pipeline started. After that, the pipeline automatically updates itself if you add new CDK applications or stages in the source code.

Prerequisites

We need to create a relationship between AWS and GitHub, this will be a token that has permissions (AWS can leverage) for access to a repository. We have to go into GitHub and generate an access token, then we will create a secret in AWS that will contain that token so that CodePipeline/CDK can use:

  • Click Settings on your GitHub profile
  • Scroll all the way to the bottom the click Developer Settings option
  • Choose Personal access tokens and click Generate token button
  • Name the token whatever you want, "AWS Access" in my case
  • Check the repo and admin:repo_hook scopes for the token
  • Click the Generate token button to get a string that will be the secret in AWS
  • Visit Secrets Manager on the AWS Console to create a github-token secret for setting up link to CDK and GitHub
  • Click Store a new secret, then choose Other type of secret option
  • Choose Plaintext and remove the content provided
  • Paste your GitHub token in plain text
  • Click on Next then fill out all options. Make sure to set secret name as github-token otherwise you will get errors during deployment step.

Step 1: Initialize a CDK project

Navigate to the directory that you want to create the project and run the below commands:

mkdir cdk-cicd-pipeline && cd cdk-cicd-pipeline
cdk init --language=typescript
Create the starter application

The CDK Pipelines construct library does not work with CDK version 2.9.0 as there seems to be compatibility issues. After we have successfully initialized the project, we need to check the version we are using in the package.json file:

  • Modify the aws-cdk and aws-cdk-lib dependencies to version 2.8.0
  • Update project's dependencies by running npm install in the root directory

At the root level of the application there's a bin folder, which contains your pipeline boiler plate code. Modify the file to look like this:

#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { CdkCicdPipelineStack } from '../lib/cdk-cicd-pipeline-stack';

const app = new cdk.App();
new CdkCicdPipelineStack(app, 'CdkCicdPipelineStack', {
  env: { account: 'your_account_id', region: 'us-east-1' },
});

app.synth();
Use your account ID and region

The code above just creates an instance of our application and stack, we are passing environment variables (AWS Account ID and Region) that we will be using for the stack. The app.synth() just bootstraps the entire process.

Step 2: Defining CI/CD pipeline

At the root level of our project, there's a lib folder that has a cdk-cicd-pipeline-stack.ts file that basically defines our infrastructure. Lets do some modifications:

import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { CodePipeline, CodePipelineSource, ShellStep } from 'aws-cdk-lib/pipelines';

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

        new CodePipeline(this, 'Pipeline', {
            pipelineName: 'TestPipeline',
            synth: new ShellStep('Synth', {
                input: CodePipelineSource.gitHub('thabo-lebelo/cdk-cicd-pipeline', 'main'), 
                commands: ['npm ci', 'npm run build', 'npx cdk synth']
            })
        });
    }
}
Creating a CI/CD pipeline stack

Let's go over the code above will provision in our cloud environment:

  1. We are using a construct library to create a TestPipeline for our application.
  2. We added a synthesis shell step pointing to our GitHub repository, the source follows gitHub('OWNER/REPO','main') format.
  3. The build step will install dependencies, build the application, and synthesize the infrastructure to generate a self mutating pipeline.

Step 3: Deploy CI/CD pipeline

Assuming you have setup the token/secret correctly, we can provision the pipeline in AWS. Let's go the the terminal and run cdk deploy command, please ensure you have committed the code to GitHub already:

Everything got created successfully, let's go to CodePipeline to see the changes

Step 4: Create a Lambda stack

Go to the lib directory and create lambda-stack.ts file that defines the AWS Lambda function for our application:

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Function, Runtime, Code} from 'aws-cdk-lib/aws-lambda';
import * as path from 'path';

export class LambdaStack extends cdk.Stack {
    constructor(scope: Construct, id: string, stageName: string, props?: cdk.StackProps) {
      super(scope, id, props);
      new Function(this, 'LambdaFunction', {
        runtime: Runtime.NODEJS_14_X,
        handler: 'index.handler',
        code: Code.fromAsset(path.join(__dirname, '/../src/')), 
        environment: { "stageName": stageName } 
      });
    }
}
Creating an application stack

Lets go over what the code will provision:

  1. A lambda function that takes a stageName that defines either test or production environment for the lambda function. This configuration can enable the function to communicate with specific resources when in a test/prod environment.
  2. The lambda function code is stored inside the src directory, in a handler file named index. The name of the exported function is called handler.

Lets create the lambda code in src/index.ts file:

export async function handler(event: string, context: string) {
    console.log('Stage Name is: ' + process.env.stage);
    return {
        body: 'Hello from a Lambda Function 👋',
        statusCode: 200,
    };
}
Basic function for demo purposes

Step 5: Setup Test & Production stages

We have provisioned a pipeline that's not currently deploying the application (lambda function). We need to create a lambda function for each stage, lets create a lib/pipeline-app-stage.ts file that encapsulates this behaviour:

import * as cdk from 'aws-cdk-lib';
import { Construct } from "constructs";
import { LambdaStack } from './lambda-stack';

export class PipelineAppStage extends cdk.Stage {

    constructor(scope: Construct, stageName: string, props?: cdk.StageProps) {
        super(scope, stageName, props);

        const lambdaStack = new LambdaStack(this, 'LambdaStack', stageName);
    }
}
Application stage stack configuration

The above code snippet just creates a lambda function based on the stageName provided when using this custom construct.

Let's update our cdk-cicd-pipeline-stack.ts to add the stages we want to deploy. Here are the changes required:

import { ManualApprovalStep } from 'aws-cdk-lib/pipelines';
import { PipelineAppStage } from './pipeline-app-stage';

// ...
const testingStage = pipeline.addStage(new PipelineAppStage(this, "test", {
            env: { account: "your_account_id", region: "us-east-1" }
        }));
        
        testingStage.addPre(new ShellStep("Run Unit Tests", { commands: ['npm install', 'npm test'] }));
        testingStage.addPost(new ManualApprovalStep('Manual approval before production'));

        const prodStage = pipeline.addStage(new PipelineAppStage(this, "prod", {
            env: { account: "your_account_id", region: "us-east-1" }
        }));

Step 6: Trigger stages deployment

Now we all we have to do is to commit and push the latest changes to the repo, and the pipeline automatically reconfigures itself to add the new stages and deploys the lambda function:

npm run build
git add *
git commit -am 'Deploy lambda to Test & Prod stages'
git push
Run commands in CDK project directory

The Pipeline will build again, and when it gets to the UpdatePipeline we should see some changes (Test and Production stages). The pipeline should be on Waiting for approval step for the production deployment:

Once approved, the lambda function should be deployed to the production stage. You can also checkout the lambda function that was deployed, the configuration details will contain the stageName for the specific function:

Summary

That was quite a lot of information shared on how to deploy CDK applications using a CI/CD pipeline. You can also view the cdk-cicd-pipeline repo on GitHub to check your code against mine.

It's also important to note that we can deploy an application to a different account and region using CDK pipeline, best practice when you have multiple stages.

To cleanup/destroy the resources, log into the AWS console of the different accounts you used, go to the AWS CloudFormation console and select and click Delete on the following stacks: CdkCicdPipelineStack, test-LambdaStack, prod-LambdaStack.