Skip to main content
aws in the trenches advanced cloud engineering for senior developers

CDK Pipelines and Continuous Deployment

4 min read Chapter 20 of 21

CDK Pipelines and Continuous Deployment

CDK Pipelines is a construct that creates a CodePipeline which deploys your CDK application. The key innovation: the pipeline deploys itself. When you change the pipeline definition (add a stage, modify a step), the pipeline updates itself first, then continues with the deployment. No chicken-and-egg problem.

Pipeline Architecture

from aws_cdk import (
    Stack, Stage, Environment,
    pipelines,
    aws_codebuild as codebuild,
)
from constructs import Construct

# Stage: A complete deployment of your application to one environment
class ApplicationStage(Stage):
    def __init__(self, scope, id, *, env, **kwargs):
        super().__init__(scope, id, env=env, **kwargs)

        # Each stage contains all your stacks
        network = NetworkStack(self, 'Network')
        data = DataStack(self, 'Data', vpc=network.vpc)
        service = ServiceStack(self, 'Service',
                              table=data.orders_table, vpc=network.vpc)

# Pipeline Stack: Lives in the CI/CD account
class PipelineStack(Stack):
    def __init__(self, scope, id, **kwargs):
        super().__init__(scope, id, **kwargs)

        # Source: GitHub/CodeCommit
        source = pipelines.CodePipelineSource.connection(
            'myorg/myrepo', 'main',
            connection_arn='arn:aws:codestar-connections:us-east-1:123456789012:connection/xxx'
        )

        # Synth step: Install deps + synthesize CDK
        synth = pipelines.ShellStep('Synth',
            input=source,
            commands=[
                'npm ci',
                'npx cdk synth'
            ],
            primary_output_directory='cdk.out'
        )

        # The pipeline itself
        pipeline = pipelines.CodePipeline(self, 'Pipeline',
            pipeline_name='app-deployment',
            synth=synth,
            cross_account_keys=True,  # Required for cross-account deployments
            docker_enabled_for_synth=True,
            code_build_defaults=pipelines.CodeBuildOptions(
                build_environment=codebuild.BuildEnvironment(
                    compute_type=codebuild.ComputeType.MEDIUM,
                    build_image=codebuild.LinuxBuildImage.STANDARD_7_0
                )
            )
        )

        # Deploy to staging first
        staging = ApplicationStage(self, 'Staging',
            env=Environment(account='111111111111', region='us-east-1')
        )
        staging_deployment = pipeline.add_stage(staging,
            pre=[
                # Run unit tests before deploying
                pipelines.ShellStep('UnitTests',
                    input=source,
                    commands=[
                        'pip install -r requirements-dev.txt',
                        'pytest tests/unit/ -v'
                    ]
                )
            ],
            post=[
                # Integration tests after deploying to staging
                pipelines.ShellStep('IntegrationTests',
                    input=source,
                    env_from_cfn_outputs={
                        'API_URL': service.api_url_output,
                    },
                    commands=[
                        'pip install -r requirements-dev.txt',
                        'pytest tests/integration/ -v --api-url $API_URL'
                    ]
                )
            ]
        )

        # Deploy to production with manual approval
        prod = ApplicationStage(self, 'Production',
            env=Environment(account='222222222222', region='us-east-1')
        )
        pipeline.add_stage(prod,
            pre=[
                pipelines.ManualApprovalStep('PromoteToProd',
                    comment='Staging integration tests passed. Approve production deployment?'
                )
            ]
        )
package com.mycompany.pipeline;

import software.amazon.awscdk.*;
import software.amazon.awscdk.pipelines.*;
import software.amazon.awscdk.services.codebuild.*;
import software.constructs.Construct;
import java.util.*;

public class PipelineStack extends Stack {

    public PipelineStack(Construct scope, String id, StackProps props) {
        super(scope, id, props);

        // Source
        CodePipelineSource source = CodePipelineSource.connection(
            "myorg/myrepo", "main",
            ConnectionSourceOptions.builder()
                .connectionArn("arn:aws:codestar-connections:us-east-1:123456789012:connection/xxx")
                .build());

        // Synth
        ShellStep synth = ShellStep.Builder.create("Synth")
            .input(source)
            .commands(List.of("npm ci", "npx cdk synth"))
            .primaryOutputDirectory("cdk.out")
            .build();

        // Pipeline
        CodePipeline pipeline = CodePipeline.Builder.create(this, "Pipeline")
            .pipelineName("app-deployment")
            .synth(synth)
            .crossAccountKeys(true)
            .build();

        // Staging
        Stage staging = new ApplicationStage(this, "Staging",
            StageProps.builder()
                .env(Environment.builder()
                    .account("111111111111").region("us-east-1").build())
                .build());

        pipeline.addStage(staging, AddStageOpts.builder()
            .post(List.of(
                ShellStep.Builder.create("IntegrationTests")
                    .input(source)
                    .commands(List.of(
                        "mvn test -Dtest=IntegrationTest",
                        "echo 'Integration tests passed'"
                    ))
                    .build()
            ))
            .build());

        // Production with approval
        Stage prod = new ApplicationStage(this, "Production",
            StageProps.builder()
                .env(Environment.builder()
                    .account("222222222222").region("us-east-1").build())
                .build());

        pipeline.addStage(prod, AddStageOpts.builder()
            .pre(List.of(
                ManualApprovalStep.Builder.create("PromoteToProd")
                    .comment("Approve production deployment?")
                    .build()
            ))
            .build());
    }
}

Wave-Based Deployment (Multi-Region)

For multi-region deployments, use waves to deploy to multiple regions in parallel:

# Deploy to multiple regions within a wave
wave = pipeline.add_wave('MultiRegion')
wave.add_stage(ApplicationStage(self, 'US-East',
    env=Environment(account='222222222222', region='us-east-1')))
wave.add_stage(ApplicationStage(self, 'EU-West',
    env=Environment(account='222222222222', region='eu-west-1')))
wave.add_stage(ApplicationStage(self, 'AP-Southeast',
    env=Environment(account='222222222222', region='ap-southeast-1')))
# All three regions deploy simultaneously

Deployment Safety

# Canary deployment for Lambda using CodeDeploy
from aws_cdk import aws_codedeploy as codedeploy

class SafeLambdaDeployment(Construct):
    def __init__(self, scope, id, *, function: lambda_.Function):
        super().__init__(scope, id)

        # Create alias for traffic shifting
        alias = function.add_alias('live')

        # CodeDeploy deployment group with canary traffic shifting
        deployment_group = codedeploy.LambdaDeploymentGroup(self, 'DeploymentGroup',
            alias=alias,
            deployment_config=codedeploy.LambdaDeploymentConfig.CANARY_10_PERCENT_5_MINUTES,
            # Route 10% of traffic to new version for 5 minutes
            # If no errors: shift 100% to new version
            # If errors: automatic rollback to previous version
            alarms=[
                function.metric_errors(period=Duration.minutes(1))
                    .create_alarm(self, 'ErrorAlarm', threshold=5, evaluation_periods=1)
            ],
            auto_rollback=codedeploy.AutoRollbackConfig(
                failed_deployment=True,
                stopped_deployment=True,
                deployment_in_alarm=True  # Rollback if alarm fires during deployment
            )
        )

Cross-Account Bootstrapping

Before CDK Pipelines can deploy to other accounts, those accounts must be bootstrapped with trust:

# Bootstrap the CI/CD account (where pipeline lives)
cdk bootstrap aws://000000000000/us-east-1 \
  --qualifier myapp \
  --cloudformation-execution-policies arn:aws:iam::aws:policy/AdministratorAccess

# Bootstrap target accounts with trust to the CI/CD account
cdk bootstrap aws://111111111111/us-east-1 \
  --qualifier myapp \
  --trust 000000000000 \
  --trust-for-lookup 000000000000 \
  --cloudformation-execution-policies arn:aws:iam::aws:policy/AdministratorAccess

cdk bootstrap aws://222222222222/us-east-1 \
  --qualifier myapp \
  --trust 000000000000 \
  --trust-for-lookup 000000000000 \
  --cloudformation-execution-policies arn:aws:iam::aws:policy/AdministratorAccess

Pipeline self-mutation: When you push a change to your CDK code that modifies the pipeline itself (add a stage, change a build step), the pipeline first deploys its own update, then restarts and continues with the application deployment. This eliminates the “how do I update my pipeline?” bootstrap problem.