Guide · AWS CDK Pipelines · Infrastructure as Code
CDK Pipelines for MCP Servers — Self-Mutating Pipeline, Cross-Account Deploy, Synth Step
CDK Pipelines is an opinionated construct library that sits on top of AWS CodePipeline and eliminates the boilerplate of wiring up a CDK application's deployment pipeline — the pipeline defines itself in CDK code, bootstraps the underlying CodePipeline stages, and self-mutates when you change the pipeline definition. For MCP server teams using CDK for infrastructure, CDK Pipelines solves the "how do I deploy my CDK stack without running cdk deploy from my laptop" problem: you push code, the pipeline synthesizes the CDK app, computes a changeset, and deploys it — infrastructure and application changes travel through the same pipeline, same access control model, same audit log. Self-mutation means you can add a new deployment stage to the pipeline by editing a TypeScript file and pushing; the pipeline updates its own CodePipeline definition on the next run. Critical CDK Pipelines decisions: structuring the CDK app so stages map cleanly to environments, setting up cross-account trust via cdk bootstrap --trust, configuring the synth step with the right installCommands for your Node.js version, and publishing Docker assets from CodeBuild without registry credential management.
TL;DR
Create a CodePipeline construct (from aws-cdk-lib/pipelines) with a CodeBuildStep as the synth step. Add deployment stages with pipeline.addStage(). Bootstrap your accounts with cdk bootstrap --trust <pipeline-account-id> --cloudformation-execution-policies. Commit and push — the pipeline self-installs on the first manual deploy (cdk deploy PipelineStack once). After that, all changes go through the pipeline. Add pre/post step checks at the stage level for integration tests and manual approval gates. See CodePipeline fundamentals for artifact store and IAM role background, and CodeBuild for build environment configuration.
CDK Pipelines construct basics
CDK Pipelines uses a different construct namespace than core CDK — import from aws-cdk-lib/pipelines, not aws-cdk-lib/aws-codepipeline. The high-level CodePipeline construct (capital P) creates the underlying CodePipeline resource plus the self-mutation stage automatically.
// infrastructure/lib/pipeline-stack.ts
import * as cdk from 'aws-cdk-lib';
import { CodePipeline, CodeBuildStep, CodePipelineSource } from 'aws-cdk-lib/pipelines';
export class PipelineStack extends cdk.Stack {
constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const pipeline = new CodePipeline(this, 'Pipeline', {
pipelineName: 'mcp-server-pipeline',
selfMutation: true, // pipeline updates itself when CDK code changes
crossAccountKeys: true, // encrypt artifacts with CMK (required for cross-account)
dockerEnabledForSynth: true, // allow Docker in synth step (for asset bundling)
synth: new CodeBuildStep('Synth', {
// GitHub source via CodeStar connection
input: CodePipelineSource.connection('my-org/mcp-server', 'main', {
connectionArn: 'arn:aws:codestar-connections:us-east-1:123456789012:connection/abc123',
triggerOnPush: true,
}),
installCommands: [
'npm install -g aws-cdk@2',
'node --version', // verify Node.js version in build environment
],
commands: [
'cd infrastructure',
'npm ci',
'npm run build', // compile TypeScript
'npx cdk synth',
],
primaryOutputDirectory: 'infrastructure/cdk.out',
buildEnvironment: {
buildImage: codebuild.LinuxBuildImage.STANDARD_7_0, // Node.js 18
computeType: codebuild.ComputeType.SMALL,
},
// Pass environment variables to synth — e.g., account IDs from Parameter Store
env: {
STAGING_ACCOUNT: process.env.STAGING_ACCOUNT!,
PROD_ACCOUNT: process.env.PROD_ACCOUNT!,
},
}),
});
// Add staging deployment stage
const stagingStage = pipeline.addStage(new McpServerStage(this, 'Staging', {
env: { account: process.env.STAGING_ACCOUNT, region: 'us-east-1' },
}));
// Add integration tests after staging deployment
stagingStage.addPost(new CodeBuildStep('IntegrationTests', {
commands: [
'cd infrastructure',
'npm ci',
'npm run test:integration',
],
envFromCfnOutputs: {
// Inject CloudFormation outputs from the staging stack as env vars
STAGING_ENDPOINT: stagingStage.stacks[0].mcpEndpointOutput,
},
}));
// Add manual approval before production
stagingStage.addPost(new pipelines.ManualApprovalStep('PromoteToProduction'));
// Add production deployment stage
pipeline.addStage(new McpServerStage(this, 'Production', {
env: { account: process.env.PROD_ACCOUNT, region: 'us-east-1' },
}));
}
}
// infrastructure/lib/mcp-server-stage.ts
import * as cdk from 'aws-cdk-lib';
import { McpServerStack } from './mcp-server-stack';
export class McpServerStage extends cdk.Stage {
constructor(scope: cdk.Construct, id: string, props?: cdk.StageProps) {
super(scope, id, props);
// All stacks added here are deployed as part of this stage
new McpServerStack(this, 'McpServerStack');
}
}
The selfMutation: true setting causes CDK Pipelines to add an "UpdatePipeline" stage before any deployment stages. This stage re-runs cdk deploy PipelineStack to apply any changes to the pipeline definition itself. If you add a new deployment stage to the TypeScript code, the next pipeline run first updates the pipeline, then runs the newly added stage — the pipeline evolves without manual cdk deploy calls.
Cross-account deployment bootstrap
For MCP server teams with separate staging and production accounts, CDK Pipelines requires that each target account trusts the pipeline account. You do this with cdk bootstrap --trust — it installs the CDK bootstrap stack in the target account and creates an IAM role that CodePipeline's deploy action assumes via cross-account role assumption.
# Bootstrap the pipeline account (where CodePipeline lives)
# Run once, from the pipeline account's credentials
cdk bootstrap aws://123456789012/us-east-1
# Bootstrap staging account — trust the pipeline account
# Run with staging account credentials
cdk bootstrap aws://456789012345/us-east-1 \
--trust 123456789012 \
--trust-for-lookup 123456789012 \
--cloudformation-execution-policies arn:aws:iam::aws:policy/AdministratorAccess
# Bootstrap production account — trust the pipeline account
# Run with production account credentials
cdk bootstrap aws://789012345678/us-east-1 \
--trust 123456789012 \
--trust-for-lookup 123456789012 \
--cloudformation-execution-policies arn:aws:iam::aws:policy/AdministratorAccess
# After bootstrap, each account has:
# - cdk-bootstrap CloudFormation stack
# - cdk-arn-... S3 bucket (cross-account asset staging)
# - cdk-cfn-exec-... IAM role (CloudFormation execution)
# - cdk-lookup-... IAM role (context lookups during synth)
# - cdk-deploy-... IAM role (pipeline deploy action assumes this)
# Verify bootstrap in staging account
aws cloudformation describe-stacks \
--stack-name CDKToolkit \
--query 'Stacks[0].{Status:StackStatus,Version:Parameters[?ParameterKey==`BootstrapVersion`].ParameterValue|[0]}'
The --trust flag adds the pipeline account ID to the trust policy of the CDK deploy role in the target account. Without this, CodePipeline's deploy action cannot assume the role in the target account and the deployment fails with AccessDenied. The --cloudformation-execution-policies flag controls what CloudFormation can do when it executes the template — AdministratorAccess is common for CDK stacks but can be scoped down to a custom policy that covers only the resources your MCP server stack creates.
Docker asset publishing
If your CDK stack references Docker images (e.g., ECS task definitions using assets), CDK Pipelines adds a "Assets" stage to the pipeline that builds and pushes the Docker images to ECR in each target account before the deploy stage runs. This happens automatically when you use ContainerImage.fromAsset() in your CDK code.
// In McpServerStack — Docker image built and pushed by CDK Pipelines asset stage
import * as ecr_assets from 'aws-cdk-lib/aws-ecr-assets';
import * as ecs from 'aws-cdk-lib/aws-ecs';
const image = new ecr_assets.DockerImageAsset(this, 'McpServerImage', {
directory: path.join(__dirname, '../../src'), // Dockerfile location
platform: ecr_assets.Platform.LINUX_AMD64,
buildArgs: { NODE_ENV: 'production' },
});
const taskDef = new ecs.FargateTaskDefinition(this, 'TaskDef', {
cpu: 512, memoryLimitMiB: 1024,
});
taskDef.addContainer('mcp-server', {
image: ecs.ContainerImage.fromDockerImageAsset(image),
portMappings: [{ containerPort: 3000 }],
logging: ecs.LogDrivers.awsLogs({ streamPrefix: 'mcp-server' }),
});
# CDK Pipelines automatically:
# 1. Builds the Docker image in a CodeBuild project ("PublishAssetsMyImage")
# 2. Authenticates to ECR in the target account
# 3. Tags the image with a content hash
# 4. Pushes to an ECR repository in the target account
# For this to work, the synth step needs Docker enabled:
# dockerEnabledForSynth: true (for asset bundling during synth)
# dockerEnabledForSelfMutation: true (if using Docker in the pipeline stack itself)
# The CodeBuild project for asset publishing needs privileged mode:
# CDK Pipelines sets this automatically for Docker asset publishing steps
Asset hashing means the same Docker build is only pushed once even if the pipeline runs multiple times with the same source code. CDK computes a hash of the Dockerfile and build context; if the hash matches an existing ECR image tag, the push is skipped. This makes repeated pipeline runs fast when only non-Docker infrastructure changes.
Waves for parallel stage deployment
When deploying to multiple regions or multiple accounts simultaneously, CDK Pipelines supports waves — a wave deploys all its stages in parallel. You add stages to a wave to run them concurrently rather than sequentially. This is useful for deploying an MCP server to us-east-1 and eu-west-1 simultaneously.
// Deploy to two regions in parallel within a wave
const prodWave = pipeline.addWave('Production');
prodWave.addStage(new McpServerStage(this, 'ProdUSEast1', {
env: { account: PROD_ACCOUNT, region: 'us-east-1' },
}));
prodWave.addStage(new McpServerStage(this, 'ProdEUWest1', {
env: { account: PROD_ACCOUNT, region: 'eu-west-1' },
}));
// Both stages deploy simultaneously in the same wave
// Pipeline waits for both to succeed before continuing
// Add a sequential stage after the wave completes
pipeline.addStage(new PostDeployStage(this, 'PostDeploy', {
env: { account: PROD_ACCOUNT, region: 'us-east-1' },
}));
// Adding pre/post steps to a wave stage
const stagingDeployment = pipeline.addStage(new McpServerStage(this, 'Staging', {
env: { account: STAGING_ACCOUNT, region: 'us-east-1' },
}));
// ShellStep for lightweight checks that don't need a full CodeBuild project
stagingDeployment.addPost(
new pipelines.ShellStep('SmokeTest', {
envFromCfnOutputs: {
ENDPOINT: stagingDeployment.stacks[0].endpointOutput,
},
commands: [
'curl -f $ENDPOINT/health',
'curl -f $ENDPOINT/tools/list | jq ".tools | length"',
],
})
);
Failure modes reference
| Failure | Symptom | Fix |
|---|---|---|
| Cross-account deployment fails: AccessDenied | Deploy stage fails with "User is not authorized to assume role" | Target account not bootstrapped with --trust <pipeline-account>; run cdk bootstrap --trust from target account credentials and redeploy pipeline stack |
| Self-mutation loop: pipeline constantly updates itself | UpdatePipeline stage triggers on every run even with no code changes | Non-deterministic synth output — a construct is generating timestamps, random IDs, or CDK version diffs on each synth; pin CDK version, avoid Date.now() in logical IDs, check for context lookups that resolve differently each run |
| Docker build fails in synth: Docker not enabled | Synth CodeBuild step fails: "Cannot connect to Docker daemon" | Set dockerEnabledForSynth: true on the CodePipeline construct; for self-mutation step also set dockerEnabledForSelfMutation: true |
| Asset publishing fails: ECR repository not found | PublishAssets step fails: "repository does not exist" | Target account not bootstrapped; CDK asset publishing creates repositories in the CDK bootstrap ECR — run cdk bootstrap with correct --trust flag in target account |
| Synth fails: cannot read context value | Context lookup error during CDK synth in pipeline | Context lookups (VPC IDs, AMI IDs) resolved at synthesis time require the --trust-for-lookup flag in bootstrap; add it and re-bootstrap; alternatively cache context values in cdk.context.json |