Multi-region multi-account deployments with AWS CDK Pipelines

This post describes how you can create an AWS CDK project that defines resources in multiple regions and then automatically deploy it in multiple environments (AWS accounts) with CDK pipelines.

Full code

In this post, I am including the most important snippets of code. You can find the complete example project here github.com/codiply/multi-account-multi-region-cdk-pipeline-example. This is where you can find, for example, all the Python dependencies (see the requirements file), or all the imports.

In the code in this post, you will encounter parsed configuration objects. These objects are built from configuration files in YAML format. You can find the actual .yaml files and the configuration code in the GitHub repo in the config/ directory.

Accounts

A very common setup is to have 3 environments: Development (DEV), Preproduction (PRE) and Production (PRO). In addition, a 4th account (tools account) will be used to house the CI/CD pipeline.

In the examples below, I will use the following as AWS account numbers

  • DEV: 111111111111

  • PRE: 222222222222

  • PRO: 333333333333

  • TOOL: 999999999999

Manual steps

GitHub access token

I am hosting my code on GitHub. For Code Pipelines to be able to access the code, I generate a github access token with scopes repo and admin:repo_hook. This token needs to be stored as a secret in Secrets Manager in the tools account and in the region where the CI/CD pipeline will be deployed.

Bootstrap AWS regions for CDK

Bootstrapping of AWS accounts is described here. For how to install the AWS CDK toolkit see here.

In the account/region where the pipeline is deployed (the tools account), run

cdk bootstrap aws://<account number>/<region> \
  --profile <aws profile name> \
  --cloudformation-execution-policies arn:aws:iam::aws:policy/AdministratorAccess

In all account/region pairs where resources are deployed by the pipeline, run

cdk bootstrap aws://<account number>/<region> \ 
    --profile <aws profile name> \
    --cloudformation-execution-policies arn:aws:iam::aws:policy/AdministratorAccess \ 
    --trust <tools/pipeline account number>

Composition

Before diving into CDK code, here is a diagram summarising the composition of different classes. I hope this will aid you to understand how the pieces are put together.

CDK Code

Constructs

The smallest building block is a Construct. This is a reusable component containing one or more resources, for example, a VPC. (full code)

class Vpc(Construct):
    def __init__(
        self,
        scope: Construct,
        construct_id: str,
        props: VpcProps,
        **kwargs: typing.Any,
    ) -> None:
        super().__init__(scope, construct_id, **kwargs)

        vpc_name = props.naming.prefix

        if props.name_suffix is not None:
            vpc_name += f"-{props.name_suffix}"

        vpc = ec2.Vpc(
            self,
            "vpc",
            ip_addresses=ec2.IpAddresses.cidr(props.cidr or "10.0.0.0/16"),
            max_azs=props.max_availability_zones,
            nat_gateways=props.nat_gateways,
        )

        cdk.Tags.of(vpc).add("Name", vpc_name)

Stacks

One level higher, we create Stacks, for example, a networking stack. (full code)

class NetworkingStack(cdk.Stack):
    def __init__(
        self,
        scope: Construct,
        construct_id: str,
        props: NetworkingStackProps,
        **kwargs: typing.Any,
    ) -> None:
        super().__init__(scope, construct_id, **kwargs)

        Vpc(
            self,
            "vpc",
            VpcProps(
                naming=props.naming,
                cidr=props.networking_config.vpc_cidr,
                max_availability_zones=props.networking_config.max_availability_zones,
                nat_gateways=props.networking_config.nat_gateways,
            ),
        )

Stages

One level higher, we have stages. Stages can span regions. Notice that I create several copies of the networking stack, one in each region. I set the region part of the environment env=cdk.Environment(region=region) within the stage. (full code)

class ArchStage(cdk.Stage):
    def __init__(
        self,
        scope: Construct,
        construct_id: str,
        props: ArchStageProps,
        **kwargs: typing.Any,
    ) -> None:
        super().__init__(scope, construct_id, **kwargs)

        config = load_arch_config(deployment_id=props.deployment_id, environment_id=props.environment_id)

        for region in config.stacks.networking.regions:
            NetworkingStack(
                self,
                f"networking-{region}",
                NetworkingStackProps(naming=config.naming, networking_config=config.networking),
                env=cdk.Environment(region=region),
            )

Pipeline Stack

Finally, I create a pipeline stack. (full code) This stack is going to be deployed to the Tools account.

First, I create the pipeline.

pipeline = pipelines.CodePipeline(
    self,
    "pipeline",
    pipeline_name=f"{project_name}",
    synth=pipelines.ShellStep(
        "Synth",
        input=pipelines.CodePipelineSource.git_hub(
            repo_string=config.cicd_pipeline.github_repo,
            branch=config.cicd_pipeline.git_branch,
            authentication=cdk.SecretValue.secrets_manager(config.cicd_pipeline.github_token_secret_key),
        ),
        commands=[
            "npm install -g aws-cdk",
            "python -m pip install -r requirements/requirements.txt",
            "cdk synth",
        ],
    ),
    cross_account_keys=True,
)

This is where the GitHub repo and the main branch are defined, together with the key in Secrets Manager where the GitHub token is stored.

Then I create one stage for each environment. If you have several stages per environment, you can create a Wave and add the stages to the wave. All stages in a wave are deployed in parallel.

for environment_id, account_config in props.accounts_config.accounts.items():
    if account_config.is_enabled and not account_config.is_cicd_account:
        wave = pipeline.add_wave(f"wave-{environment_id}")

        account_config = props.accounts_config.accounts[environment_id]
        account_id = account_config.account_id

        if account_config.needs_manual_approval:
            wave.add_pre(
                pipelines.ManualApprovalStep(
                    f"approve-{environment_id}", comment=f"Approve deployment to {environment_id}"
                )
            )

        wave.add_stage(
            ArchStage(
                self,
                f"{project_name}-{environment_id}",
                props=ArchStageProps(deployment_id=props.deployment_id, environment_id=environment_id),
                env=cdk.Environment(account=account_id),
            )
        )

Notice that for each Stage that I am instantiating, I am setting only the account in the environment env=cdk.Environment(account=account_id). The regions are set within the stage code.

For environments PRE and PRO, I have included a manual approval step.

Deploy the pipeline

In my example, I have configured the pipeline to be deployed in eu-west-1 and I create a networking stack in 2 regions eu-west-1 and eu-west-2.

When I list the stacks, I see the following.

> cdk ls
example-cdk-pipeline-pipeline
cross-region-stack-999999999999:eu-west-2
example-cdk-pipeline-pipeline/example-cdk-pipeline-dev/networking-eu-west-1
example-cdk-pipeline-pipeline/example-cdk-pipeline-dev/networking-eu-west-2
example-cdk-pipeline-pipeline/example-cdk-pipeline-pre/networking-eu-west-1
example-cdk-pipeline-pipeline/example-cdk-pipeline-pre/networking-eu-west-2
example-cdk-pipeline-pipeline/example-cdk-pipeline-prod/networking-eu-west-1
example-cdk-pipeline-pipeline/example-cdk-pipeline-prod/networking-eu-west-2

First, you have to make sure that you have pushed your code to the main branch of your GitHub repo. Then, deploy the pipeline stack.

cdk deploy example-cdk-pipeline-pipeline

This will create the Code Pipeline project, and it will trigger it.

From that point forward, you do not need to deploy anything manually, not even when you make changes to the pipeline stack. The Code Pipeline project is self-mutating.

While developing, you can deploy individual stacks from your development environment by passing the full name of the stack. For example:

cdk deploy example-cdk-pipeline-pipeline/example-cdk-pipeline-dev/networking-eu-west-1

When using cdk deploy, you will need to have the right credentials in your environment for the account you are deploying to. Alternatively, if you are using profiles then pass --profile <name of the profile>.

Running the pipeline

The pipeline runs automatically when new code is merged into the main branch. You can also trigger it manually by clicking Release Change in the page of the pipeline Management Console.

After deploying to DEV, the pipeline will wait for manual approval before deploying to PRE. The same will happen before deploying to PRO environment.

Cleanup

Delete manually all Cloudformation stacks. Do not forget to do that for all AWS accounts and regions. See the output of cdk ls to see all stacks that need to be deleted.

Alternatively, you can do the following for each stack from the command line

cdk destroy --profile <aws profile> <name of the stack>

Wrap up

In this post, I described how you can create a CDK project that creates resources in multiple regions, and then I deployed the project in several environments (AWS accounts) with CDK Pipelines.

Do not forget to check the full code in the GitHub repo: github.com/codiply/multi-account-multi-region-cdk-pipeline-example