The AWS CDK turns TypeScript code into CloudFormation templates. Understanding the three-tier hierarchy — App, Stack, Construct — unlocks everything else.
The Hierarchy
- App — the root node; a single CDK application. Contains one or more Stacks.
- Stack — maps 1:1 to a CloudFormation stack; the unit of deployment.
- Construct — everything inside a stack; can be a single resource or a nested tree.
// bin/app.ts — entry point
import * as cdk from 'aws-cdk-lib'
import { WebStack } from '../lib/web-stack'
const app = new cdk.App()
new WebStack(app, 'WebStack-Dev', {
env: { account: '123456789012', region: 'us-east-1' },
stage: 'dev',
})
new WebStack(app, 'WebStack-Prod', {
env: { account: '234567890123', region: 'eu-west-1' },
stage: 'prod',
})
A First Stack
// lib/web-stack.ts
import * as cdk from 'aws-cdk-lib'
import * as s3 from 'aws-cdk-lib/aws-s3'
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront'
import * as origins from 'aws-cdk-lib/aws-cloudfront-origins'
import { Construct } from 'constructs'
interface WebStackProps extends cdk.StackProps {
stage: 'dev' | 'staging' | 'prod'
}
export class WebStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: WebStackProps) {
super(scope, id, props)
const bucket = new s3.Bucket(this, 'StaticAssets', {
versioned: props.stage === 'prod',
removalPolicy:
props.stage === 'prod' ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY,
autoDeleteObjects: props.stage !== 'prod',
})
const distribution = new cloudfront.Distribution(this, 'CDN', {
defaultBehavior: {
origin: origins.S3BucketOrigin.withOriginAccessControl(bucket),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
},
httpVersion: cloudfront.HttpVersion.HTTP2_AND_3,
})
new cdk.CfnOutput(this, 'BucketName', { value: bucket.bucketName })
new cdk.CfnOutput(this, 'DistributionUrl', {
value: `https://${distribution.distributionDomainName}`,
})
}
}
Project Structure
cdk init app --language typescript scaffolds the standard layout:
my-infra/
├── bin/
│ └── app.ts # App entry point
├── lib/
│ ├── web-stack.ts # Stack definitions
│ └── constructs/ # Custom reusable constructs
├── test/
│ └── web-stack.test.ts # Jest tests
├── cdk.json # CDK configuration
├── package.json
└── tsconfig.json
cdk.json
{
"app": "npx ts-node --prefer-ts-exts bin/app.ts",
"watch": {
"include": ["**"],
"exclude": ["README.md", "cdk*.json", "**/*.d.ts", "**/*.js", "node_modules"]
},
"context": {
"@aws-cdk/aws-lambda:recognizeLayerVersion": true,
"@aws-cdk/core:stackRelativeExports": "true"
}
}
The context section holds CDK feature flags. Modern CDK initialised projects have @aws-cdk/core:newStyleStackSynthesis enabled by default — required for CDK Pipelines.
Bootstrapping
CDK needs a one-time per-account, per-region setup step: it creates an S3 bucket for staging assets (Lambda code, Docker images) and IAM roles the deployment uses:
cdk bootstrap aws://123456789012/us-east-1 --profile my-profile
For multi-account deployments (dev → staging → prod pipeline), bootstrap each account. The CDKToolkit stack is what gets deployed.
Daily CDK Workflow
# List all stacks in the app
cdk ls
# Synthesise to CloudFormation (no deploy)
cdk synth
# See what would change
cdk diff
# Deploy
cdk deploy
cdk deploy WebStack-Dev
cdk deploy --all
# Destroy
cdk destroy WebStack-Dev
# Watch mode (hot-reload-like for Lambdas)
cdk watch
cdk diff before every deploy is good discipline — it shows IAM changes, resource replacements, and security group modifications that might surprise you.
Tokens and CloudFormation References
CDK properties like bucket.bucketName are not resolved at synth time — they become CloudFormation references (!Ref, !GetAtt). They are Tokens that look like strings but carry a reference:
const bucket = new s3.Bucket(this, 'B')
bucket.bucketName // Token<string> looks like "${Token[S3Bucket.BucketName]}"
// When you use it in another resource, CDK knows to emit a Ref or GetAtt
new ssm.StringParameter(this, 'P', {
parameterName: '/config/bucket',
stringValue: bucket.bucketName, // → CloudFormation Ref
})
This is why you cannot console.log(bucket.bucketName) and expect a real name before deploy — it's a token. There is no way to resolve it locally without an actual deploy.
Cross-Stack References
// Stack A defines and exports
export class NetworkStack extends cdk.Stack {
public readonly vpc: ec2.Vpc
constructor(scope: Construct, id: string, props: cdk.StackProps) {
super(scope, id, props)
this.vpc = new ec2.Vpc(this, 'Vpc', { maxAzs: 3 })
}
}
// Stack B imports
export class AppStack extends cdk.Stack {
constructor(scope: Construct, id: string, vpc: ec2.Vpc, props: cdk.StackProps) {
super(scope, id, props)
new ecs.Cluster(this, 'Cluster', { vpc })
}
}
// bin/app.ts wires them
const net = new NetworkStack(app, 'Network', { env })
new AppStack(app, 'App', net.vpc, { env })
CDK emits CloudFormation Exports and Imports automatically. Be aware: cross-stack references create deployment order dependencies, and removing them requires two deployments (first stop using, then remove the export).
Context and Environment Variables
// cdk.json "context":
// { "instanceType": "t3.micro" }
// In stack:
const instanceType = this.node.tryGetContext('instanceType') ?? 't3.medium'
// Runtime CDK environment variables
// CDK_DEFAULT_ACCOUNT, CDK_DEFAULT_REGION
Pass context at deploy time: cdk deploy --context stage=prod.
What Gets Synthesised
cdk synth writes to the cdk.out/ directory:
- One JSON CloudFormation template per stack
- Lambda asset bundles (zip), Docker asset manifests
- The manifest.json describing all assets and their targets
You can inspect cdk.out/WebStack-Dev.template.json to see exactly what will be deployed. This is also what CI validates.
Dependencies
npm install aws-cdk-lib constructs
Just two packages. aws-cdk-lib is the monopackage (since CDK v2) that contains all service libraries. CDK v1 had hundreds of separate @aws-cdk/aws-* packages — avoid if you see legacy code.
One More Concept: Aspects
Aspects traverse the entire construct tree and can modify or validate every resource. Common uses:
- Tag every resource with your org tags
- Enforce encryption-at-rest on all buckets
- Add termination protection to all stacks in prod
class ApplyOrgTags implements cdk.IAspect {
visit(node: IConstruct): void {
if (cdk.CfnResource.isCfnResource(node)) {
cdk.Tags.of(node).add('cost-center', 'cc-1234')
cdk.Tags.of(node).add('managed-by', 'cdk')
}
}
}
cdk.Aspects.of(app).add(new ApplyOrgTags())
With App / Stack / Construct, bootstrapping, and the daily CLI workflow internalised, you can build real infrastructure. The next lesson focuses on construct patterns that make that infrastructure reusable and maintainable.