Writing the infrastructure code is one thing; running it safely in a multi-stage pipeline with approval gates, tests, and automatic rollback is another. CDK has a first-class answer.
CDK Pipelines
CDK Pipelines is a L3 construct that deploys a CodePipeline which manages itself. The "self-mutating" property means when you push a change to the pipeline's infrastructure definition, the pipeline updates itself before deploying anything downstream.
Structure
// lib/pipeline-stack.ts
import * as cdk from 'aws-cdk-lib'
import * as pipelines from 'aws-cdk-lib/pipelines'
import { Construct } from 'constructs'
import { WebStage } from './web-stage'
export class PipelineStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: cdk.StackProps) {
super(scope, id, props)
const pipeline = new pipelines.CodePipeline(this, 'Pipeline', {
pipelineName: 'WebDeployment',
synth: new pipelines.ShellStep('Synth', {
input: pipelines.CodePipelineSource.connection(
'acme/infra',
'main',
{ connectionArn: 'arn:aws:codestar-connections:...' }
),
commands: [
'npm ci',
'npm run build',
'npm test',
'npx cdk synth',
],
}),
})
// dev stage — deploys immediately
pipeline.addStage(new WebStage(this, 'Dev', {
env: { account: '111111111', region: 'us-east-1' },
stage: 'dev',
}))
// staging — approval required
pipeline.addStage(new WebStage(this, 'Staging', {
env: { account: '222222222', region: 'us-east-1' },
stage: 'staging',
}), {
pre: [new pipelines.ShellStep('RunIntegTests', {
commands: ['npx integ-runner --parallel-regions us-east-1'],
})],
})
// prod — manual approval gate
pipeline.addStage(new WebStage(this, 'Prod', {
env: { account: '333333333', region: 'eu-west-1' },
stage: 'prod',
}), {
pre: [new pipelines.ManualApprovalStep('PromoteToProd')],
})
}
}
// lib/web-stage.ts
interface WebStageProps extends cdk.StageProps { stage: string }
export class WebStage extends cdk.Stage {
constructor(scope: Construct, id: string, props: WebStageProps) {
super(scope, id, props)
new WebStack(this, 'Web', { stage: props.stage })
}
}
Bootstrapping for CDK Pipelines
CDK Pipelines requires newer-style bootstrapping with cross-account trust:
# Bootstrap the tools account (where CodePipeline lives)
cdk bootstrap aws://111111111/us-east-1 --cloudformation-execution-policies arn:aws:iam::aws:policy/AdministratorAccess
# Bootstrap target accounts, trusting the tools account
cdk bootstrap aws://222222222/us-east-1 --trust 111111111 --cloudformation-execution-policies arn:aws:iam::aws:policy/AdministratorAccess
GitHub Actions — the Lightweight Alternative
For teams without CodePipeline requirements, GitHub Actions with OIDC-to-AWS is often simpler:
# .github/workflows/deploy.yml
name: Deploy Infrastructure
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
diff:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '22', cache: npm }
- run: npm ci
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111111111:role/GithubActionsRole
aws-region: us-east-1
- run: npx cdk diff --app "npx ts-node bin/app.ts"
deploy-dev:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: dev
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '22', cache: npm }
- run: npm ci
- run: npm test
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111111111:role/GithubActionsRole
aws-region: us-east-1
- run: npx cdk deploy --all --require-approval never --app "npx ts-node bin/app.ts"
deploy-prod:
needs: deploy-dev
runs-on: ubuntu-latest
environment: production # requires manual approval via GitHub Environments
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '22', cache: npm }
- run: npm ci
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::333333333:role/GithubActionsRole
aws-region: eu-west-1
- run: npx cdk deploy --all --require-approval never
OIDC Trust Policy
Set up once via CDK itself or the AWS console:
const githubProvider = new iam.OpenIdConnectProvider(this, 'GitHub', {
url: 'https://token.actions.githubusercontent.com',
clientIds: ['sts.amazonaws.com'],
})
new iam.Role(this, 'GitHubActionsRole', {
assumedBy: new iam.WebIdentityPrincipal(
githubProvider.openIdConnectProviderArn,
{
StringEquals: {
'token.actions.githubusercontent.com:aud': 'sts.amazonaws.com',
},
StringLike: {
'token.actions.githubusercontent.com:sub': 'repo:acme/infra:ref:refs/heads/main',
},
}
),
managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('AdministratorAccess')],
})
This is the "no long-lived secret" approach — GitHub issues OIDC JWTs that AWS validates via the IdP trust.
The PR / Main Branch Model
| Event | Action |
|---|---|
| PR opened / updated | npm test + cdk diff — comment diff output to the PR |
| PR merged to main | Deploy to dev; run integration tests |
| Git tag (v1.2.3) | Deploy to staging → approval → prod |
Security Gates in CI
# In the diff step, fail if any IAM statement with * resource or action
- name: Security check
run: |
cdk synth 2>/dev/null
# check for overly permissive IAM
node -e "
const fs = require('fs')
const template = JSON.parse(fs.readFileSync('cdk.out/AppStack.template.json', 'utf8'))
const resources = template.Resources || {}
Object.values(resources).forEach((r) => {
if (r.Type === 'AWS::IAM::Policy') {
const stmts = r.Properties?.PolicyDocument?.Statement || []
stmts.forEach((s) => {
if (s.Resource === '*' && s.Effect === 'Allow') {
console.error('Over-privileged statement:', JSON.stringify(s))
process.exit(1)
}
})
}
})
"
echo 'IAM check passed'
Drift Detection
CloudFormation stacks can drift when someone modifies resources through the console. Add a weekly check:
# .github/workflows/drift.yml
on:
schedule: [{ cron: '0 9 * * 1' }] # Every Monday 9am
jobs:
drift:
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@v4
with: { role-to-assume: ${{ secrets.AWS_ROLE_ARN }}, aws-region: us-east-1 }
- run: |
aws cloudformation detect-stack-drift --stack-name WebStack-Prod
sleep 60
status=$(aws cloudformation describe-stack-drift-detection-status --stack-drift-detection-id $id --query 'StackDriftStatus' --output text)
if [[ "$status" != "IN_SYNC" ]]; then echo "Drift detected!"; exit 1; fi
What a Mature Pipeline Looks Like
A production CDK pipeline:
- PR:
npm test+cdk diff+ IAM policy checker + cdk-nag - Merge to main: auto-deploy dev; run integration tests
- Weekly: staging deployment + full regression
- Release tag: staging → manual approval → prod (with automatic rollback on CloudWatch alarm)
- Monday 9am: drift detection across all stacks
That is safe, auditable, and automated. Two lessons remain — multi-cloud IaC with Pulumi, and Kubernetes manifests with cdk8s.