CDK

AWS Intermediate/Advanced

Chapter 5 · The AWS CDK

AWS Intermediate/Advanced 4 wrote a stack as a YAML template. The AWS Cloud Development Kit (CDK) lets you write the same thing as ordinary code (here TypeScript): you build objects, run the program, and it generates the CloudFormation template. Deployment is still CloudFormation, so everything in chapter 4 still applies. What changes is how you write the template, how you test it, and which surprises are waiting. This chapter rebuilds the chapter 4 stack in the CDK, runs cdk synth for real, reads what comes out, and measures it against the hand-written version.

How every example in this chapter was verified
There is no AWS account and no AWS credentials on the machine that wrote this course, so nothing was deployed.
  • Run here (level 1): Node 24.18.0 on Windows 11, with aws-cdk (the CLI) 2.1145.0, aws-cdk-lib 2.273.0, constructs 10.8.1, TypeScript 7.0.2 and Jest 30.5.2, installed into a scratch folder outside this site (the install is about 277 MB; nothing was installed globally). cdk synth was run for real, the tests were run for real, and the generated templates were checked with the cfn-lint and models from chapter 4.
  • Needs an AWS account (level 2): cdk bootstrap, cdk deploy, cdk diff, cdk destroy and context lookups. I ran them anyway, with no credentials, so the chapter shows how they fail, not how they succeed.
  • From the documentation, not run (level 3): what the CDK guide says about constructs, apps, bootstrapping, the CLI, testing and escape hatches (read 2026-10-13 through a page-fetch tool that summarises pages).
I chose TypeScript because the outline's default was TypeScript; nothing here depends on that. I did not use cdk init, so this project is hand-made and its feature flags are unset: that matters, and the chapter shows how.

Apps, Stacks and Constructs

The CDK guide's definitions. An app "is a collection of one or more CDK stacks. Stacks are a collection of one or more constructs, which define AWS resources and properties." App "is the only construct that can be used as the root", and "all constructs that represent AWS resources must be defined, directly or indirectly, within the scope of a Stack". "Apps are then synthesized to create AWS CloudFormation templates for your stacks." Constructs come in three levels:

LevelWhat the guide saysExample in this chapter
L1 (CFN resources)"the lowest-level construct and offer no abstraction. Each L1 construct maps directly to a single AWS CloudFormation resource"; names start with Cfns3.CfnBucket
L2 (curated)Higher-level constructs with sensible defaults and "helper methods for most resources that make it simpler and quicker to define properties, permissions, event-based interactions"s3.Bucket, kms.Key, sqs.Queue, iam.Role
L3 (patterns)"the highest-level of abstraction ... a collection of resources that are configured to work together to accomplish a specific task"The guide's example: ecsPatterns.ApplicationLoadBalancedFargateService (not used here)

Constructs form a construct tree: every construct is created with a scope (its parent) and an id, and the app is the root. "The AWS CDK uses the IDs of all constructs in the path from the tree's root to each child construct to generate the unique IDs required by AWS CloudFormation." That sentence has a consequence, which the guide states and this chapter measures: "if you move a construct to a different scope, its generated stack-unique ID changes, and AWS CloudFormation won't consider it the same resource" (see Construct IDs below). The newer CDK also has Mixins and Facades (the guide describes grants as a facade); I did not use them.

The Project

The layout is small: bin/app.ts (creates the app and the stack), lib/reports-stack.ts (the stack), lib/aspects.ts, test/, cdk.json (tells the CLI how to run the app) and package.json. Two practical notes from building it with TypeScript 7.0.2: its compiler rejects "moduleResolution": "node" ("Option 'moduleResolution=node10' has been removed"), so the project uses node16; and because I did not want a TypeScript runner in the loop, the app is compiled first and cdk.json runs the compiled file:

{ "app": "node dist/bin/app.js", "context": { "environment": "dev" } }

The CLI reads cdk.json, runs node dist/bin/app.js, and the app writes a cloud assembly (a folder, cdk.out by default, holding one template per stack). The environment name comes in as context (-c environment=prod).

The Stack in TypeScript

The same design as chapter 4: a key, an encrypted bucket, two queues, a worker role, and an alarm in production only. Note where the chapter 4 features went. The Mappings table is a plain object; the condition is a plain if; the parameter constraints are a check that throws; there are no CloudFormation Parameters or Conditions of my own, because those decisions are made while the program runs, not when CloudFormation deploys.

this.key = new kms.Key(this, "DataKey", { description: `Data key for ${this.stackName} (${props.environment})`, enableKeyRotation: true, alias: `alias/${this.stackName}-data`, removalPolicy: cdk.RemovalPolicy.RETAIN, }); this.key.addToResourcePolicy(new iam.PolicyStatement({ sid: "Allow access for Key Administrators", principals: [new iam.ArnPrincipal(props.keyAdminRoleArn)], actions: ["kms:Create*", "kms:Describe*", "kms:Enable*", "kms:List*", "kms:Put*", "kms:Update*", "kms:Revoke*", "kms:Disable*", "kms:Get*", "kms:Delete*", "kms:TagResource", "kms:UntagResource", "kms:ScheduleKeyDeletion", "kms:CancelKeyDeletion"], resources: ["*"], })); this.bucket = new s3.Bucket(this, "ReportBucket", { encryption: s3.BucketEncryption.KMS, encryptionKey: this.key, bucketKeyEnabled: true, versioned: true, blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, enforceSSL: true, lifecycleRules: [{ id: "expire-reports", expiration: cdk.Duration.days(props.retentionDays) }], removalPolicy: cdk.RemovalPolicy.RETAIN, }); const dlq = new sqs.Queue(this, "WorkDeadLetterQueue", { retentionPeriod: cdk.Duration.days(14), encryption: sqs.QueueEncryption.KMS, encryptionMasterKey: this.key, }); this.queue = new sqs.Queue(this, "WorkQueue", { visibilityTimeout: cdk.Duration.seconds(s.visibilityTimeout), encryption: sqs.QueueEncryption.KMS, encryptionMasterKey: this.key, deadLetterQueue: { queue: dlq, maxReceiveCount: s.maxReceive }, }); const role = new iam.Role(this, "WorkerRole", { assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com") }); this.queue.grantConsumeMessages(role); // intent-based grants: CDK writes the IAM statements this.bucket.grantRead(role); // grantReadWrite also allows s3:DeleteObject*: the worker only reads and writes this.bucket.grantPut(role);

Three things to notice before running anything. Grants are intent-based: queue.grantConsumeMessages(role) says what the role should be able to do, and the CDK writes the IAM statements (and, for the key, the extra permissions the bucket's encryption needs). Removal policies: RemovalPolicy.RETAIN is the chapter 4 DeletionPolicy and UpdateReplacePolicy pair. Safety options are one word: enforceSSL: true becomes a bucket policy and blockPublicAccess becomes four properties. The rest of the file (the alarm and the outputs):

if (props.environment === "prod") { // an ordinary 'if', evaluated when the app runs: no CloudFormation Condition is emitted const alarm = new cloudwatch.Alarm(this, "BacklogAlarm", { alarmDescription: "Work queue backlog is growing", metric: this.queue.metricApproximateNumberOfMessagesVisible({ period: cdk.Duration.minutes(5), statistic: "Maximum" }), threshold: 1000, evaluationPeriods: 1, comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING, }); new cdk.CfnOutput(this, "AlarmName", { value: alarm.alarmName, description: "Backlog alarm (production only)" }); } new cdk.CfnOutput(this, "BucketName", { value: this.bucket.bucketName, description: "Name of the report bucket", exportName: `${this.stackName}-BucketName` }); new cdk.CfnOutput(this, "QueueUrl", { value: this.queue.queueUrl, description: "URL of the work queue" }); new cdk.CfnOutput(this, "KeyArn", { value: this.key.keyArn, description: "ARN of the data key" }); } }

cdk synth, for Real

Run here (level 1): cdk synth -c environment=dev. It succeeded (about 18 seconds the first time, 7 to 8 seconds later) and printed two lines on its error stream that are not part of the template: AI agent detected and 84 feature flags are not configured. Run 'cdk flags --unstable=flags' to learn more. (I report them as printed; the second one is explained under Feature Flags). The generated template, 372 lines of YAML, in its two interesting parts: the worker role and the policy the grants wrote, and the extra sections the CDK adds.

WorkerRole8DD27D41: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Statement: - Action: sts:AssumeRole Effect: Allow Principal: Service: lambda.amazonaws.com Version: "2012-10-17" Tags: - Key: project Value: reports Metadata: aws:cdk:path: Reports-dev/WorkerRole/Resource WorkerRoleDefaultPolicy1750E153: Type: AWS::IAM::Policy Properties: PolicyDocument: Statement: - Action: - sqs:ReceiveMessage - sqs:ChangeMessageVisibility - sqs:GetQueueUrl - sqs:DeleteMessage - sqs:GetQueueAttributes Effect: Allow Resource: Fn::GetAtt: - WorkQueue94013F35 - Arn - Action: kms:Decrypt Effect: Allow Resource: Fn::GetAtt: - DataKey17AFBB84 - Arn - Action: - s3:GetObject* - s3:GetBucket* - s3:List* Effect: Allow Resource: - Fn::GetAtt: - ReportBucket577F0FCD - Arn - Fn::Join: - "" - - Fn::GetAtt: - ReportBucket577F0FCD - Arn - /* - Action: - kms:Decrypt - kms:DescribeKey Effect: Allow Resource: Fn::GetAtt: - DataKey17AFBB84 - Arn - Action: - s3:PutObject - s3:PutObjectLegalHold - s3:PutObjectRetention - s3:PutObjectTagging - s3:PutObjectVersionTagging - s3:Abort* Effect: Allow Resource: Fn::Join: - "" - - Fn::GetAtt: - ReportBucket577F0FCD - Arn - /* - Action: - kms:Encrypt - kms:ReEncrypt* - kms:GenerateDataKey* - kms:Decrypt Effect: Allow Resource: Fn::GetAtt: - DataKey17AFBB84 - Arn Version: "2012-10-17" PolicyName: WorkerRoleDefaultPolicy1750E153 Roles: - Ref: WorkerRole8DD27D41 Metadata: aws:cdk:path: Reports-dev/WorkerRole/DefaultPolicy/Resource CDKMetadata: Type: AWS::CDK::Metadata
- us-west-2 Parameters: BootstrapVersion: Type: AWS::SSM::Parameter::Value<String> Default: /cdk-bootstrap/hnb659fds/version Description: Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]

What you are looking at. Logical IDs such as WorkerRole8DD27D41 are the construct path plus a hash, and each resource carries aws:cdk:path metadata so you can find the code that made it. The role's permissions are a separate AWS::IAM::Policy resource (the hand-written template used an inline policy). There is an AWS::CDK::Metadata resource (version reporting, on by default in the CLI documentation's options) and a BootstrapVersion parameter that reads /cdk-bootstrap/hnb659fds/version from Parameter Store at deploy time: the template itself expects the environment to have been bootstrapped. The tag from Tags.of(app) was applied to every taggable resource without any resource mentioning it.

Against the hand-written template

I compared the generated templates (dev and prod) with the chapter 4 template, using the chapter 4 tools: the resolver renders the hand-written one, the change set approximation compares the two generated ones, and cfn-lint checks them. (Output of a script that reads only real files.)

C:/lwaws/c4/.venv/Scripts/python.exe compare_templates.py
1. resource types: hand-written template (rendered by the chapter 4 resolver) against what cdk synth generated dev: hand-written 7 resources, CDK 9 resources AWS::CDK::Metadata hand-written 0 CDK 1 <-- differs AWS::IAM::Policy hand-written 0 CDK 1 <-- differs AWS::IAM::Role hand-written 1 CDK 1 AWS::KMS::Alias hand-written 1 CDK 1 AWS::KMS::Key hand-written 1 CDK 1 AWS::S3::Bucket hand-written 1 CDK 1 AWS::S3::BucketPolicy hand-written 1 CDK 1 AWS::SQS::Queue hand-written 2 CDK 2 prod: hand-written 8 resources, CDK 10 resources AWS::CDK::Metadata hand-written 0 CDK 1 <-- differs AWS::CloudWatch::Alarm hand-written 1 CDK 1 AWS::IAM::Policy hand-written 0 CDK 1 <-- differs AWS::IAM::Role hand-written 1 CDK 1 AWS::KMS::Alias hand-written 1 CDK 1 AWS::KMS::Key hand-written 1 CDK 1 AWS::S3::Bucket hand-written 1 CDK 1 AWS::S3::BucketPolicy hand-written 1 CDK 1 AWS::SQS::Queue hand-written 2 CDK 2 2. sections of the generated template: CDK made these decisions while the app ran, so there is no Parameters/Mappings/Conditions of its own dev: sections ['Conditions', 'Outputs', 'Parameters', 'Resources', 'Rules']; Parameters ['BootstrapVersion']; Conditions ['CDKMetadataAvailable']; Mappings [] hand-written template sections: ['AWSTemplateFormatVersion', 'Conditions', 'Description', 'Mappings', 'Metadata', 'Outputs', 'Parameters', 'Resources'] prod: sections ['Conditions', 'Outputs', 'Parameters', 'Resources', 'Rules']; Parameters ['BootstrapVersion']; Conditions ['CDKMetadataAvailable']; Mappings [] hand-written template sections: ['AWSTemplateFormatVersion', 'Conditions', 'Description', 'Mappings', 'Metadata', 'Outputs', 'Parameters', 'Resources'] 3. the worker role's allowed actions: hand-written inline policy against the policy CDK's grant methods wrote hand-written (7): ['kms:Decrypt', 'kms:GenerateDataKey', 's3:GetObject', 's3:PutObject', 'sqs:DeleteMessage', 'sqs:GetQueueAttributes', 'sqs:ReceiveMessage'] CDK (19): ['kms:Decrypt', 'kms:DescribeKey', 'kms:Encrypt', 'kms:GenerateDataKey*', 'kms:ReEncrypt*', 's3:Abort*', 's3:GetBucket*', 's3:GetObject*', 's3:List*', 's3:PutObject', 's3:PutObjectLegalHold', 's3:PutObjectRetention', 's3:PutObjectTagging', 's3:PutObjectVersionTagging', 'sqs:ChangeMessageVisibility', 'sqs:DeleteMessage', 'sqs:GetQueueAttributes', 'sqs:GetQueueUrl', 'sqs:ReceiveMessage'] only in CDK's policy: ['kms:DescribeKey', 'kms:Encrypt', 'kms:GenerateDataKey*', 'kms:ReEncrypt*', 's3:Abort*', 's3:GetBucket*', 's3:GetObject*', 's3:List*', 's3:PutObjectLegalHold', 's3:PutObjectRetention', 's3:PutObjectTagging', 's3:PutObjectVersionTagging', 'sqs:ChangeMessageVisibility', 'sqs:GetQueueUrl'] only in the hand-written one: ['kms:GenerateDataKey', 's3:GetObject'] 4. DeletionPolicy / UpdateReplacePolicy per resource CDK AWS::KMS::Key DeletionPolicy Retain UpdateReplacePolicy Retain CDK AWS::KMS::Alias DeletionPolicy - UpdateReplacePolicy - CDK AWS::S3::Bucket DeletionPolicy Retain UpdateReplacePolicy Retain CDK AWS::S3::BucketPolicy DeletionPolicy - UpdateReplacePolicy - CDK AWS::SQS::Queue DeletionPolicy Delete UpdateReplacePolicy Delete CDK AWS::SQS::Queue DeletionPolicy Delete UpdateReplacePolicy Delete CDK AWS::IAM::Role DeletionPolicy - UpdateReplacePolicy - CDK AWS::IAM::Policy DeletionPolicy - UpdateReplacePolicy - CDK AWS::CDK::Metadata DeletionPolicy - UpdateReplacePolicy - 5. dev -> prod, as the chapter 4 change set approximation sees the two CDK templates -- CDK Reports-dev -> Reports-prod (the stack name also changes, so names derived from it change too) Add BacklogAlarm1E83516A AWS::CloudWatch::Alarm Replacement: - Modify DataKey17AFBB84 AWS::KMS::Key Replacement: False Modify /Properties/Description "Data key for Reports-dev (dev)" -> "Data key for Reports-prod (prod)" Modify DataKeyAliasF989EB84 AWS::KMS::Alias Replacement: True Modify /Properties/AliasName "alias/Reports-dev-data" -> "alias/Reports-prod-data" Modify WorkQueue94013F35 AWS::SQS::Queue Replacement: False Modify /Properties/RedrivePolicy/maxReceiveCount 3 -> 5 Modify /Properties/VisibilityTimeout 30 -> 120 6. capabilities and quotas, with the chapter 4 checkers dev: capability CAPABILITY_IAM; over a quota: none; template 13,372 bytes prod: capability CAPABILITY_IAM; over a quota: none; template 14,298 bytes 7. cfn-lint (chapter 4 environment) on the generated templates Reports-dev.template.json: exit 0 (no findings) Reports-prod.template.json: exit 0 (no findings)

The same resource types appear, plus the policy resource and the metadata resource: 9 resources against 7 in dev, 10 against 8 in prod. Both generated templates lint clean (cfn-lint exit status 0) and need only CAPABILITY_IAM. The generated template has no Mappings, no Parameters of its own and, apart from the CDK's metadata one, no Conditions. Two differences deserve attention. The role's permissions are much wider than the hand-written ones: 19 distinct actions against 7, even after I narrowed them (next section). Defaults you did not write: the queues get DeletionPolicy: Delete and UpdateReplacePolicy: Delete (the hand-written template left them unset), while the key and bucket got Retain only because I asked. The dev-to-prod comparison shows the alias name changing: here that is only because the stack name contains the environment, and dev and prod are separate stacks.

Grants, and the Test That Caught One

Grants save a lot of typing and hide a lot. My first version of the stack used bucket.grantReadWrite(role). It looked right, and I had written a test for least privilege: "the worker role may not delete objects and has no wildcard action". The test failed, which is what it is for. Run here (level 1):

jest (first run: 13 passed, 1 failed on purpose)
FAIL dist/test/reports-stack.test.js (8.296 s) ● ReportsStack, fine-grained assertions › least privilege: the worker role is not allowed to delete objects or use a wildcard action expect(received).toEqual(expected) // deep equality - Expected - 1 + Received + 3 - Array [] + Array [ + "s3:DeleteObject*", + ] 101 | const allowed = policies.flatMap((p) => p.Properties.PolicyDocument.Statement.flatMap((s) => (s.Effect === "Allow" ? [s.Action].flat() : []))); 102 | expect(allowed.filter((a) => a === "*" || a.endsWith(":*"))).toEqual([]); > 103 | expect(allowed.filter((a) => a.startsWith("s3:Delete"))).toEqual([]); | ^ 104 | }); 105 | test("the template has no CloudFormation Conditions or Mappings of its own: those decisions were made while the app ran", () => { 106 | const j = dev.toJSON(); at Object.toEqual (dist/test/reports-stack.test.js:103:66) Test Suites: 1 failed, 1 total Tests: 1 failed, 13 passed, 14 total Snapshots: 1 passed, 1 total Time: 9.654 s Ran all test suites.

grantReadWrite had added s3:DeleteObject*. The worker only reads and writes, so I replaced it with grantRead plus grantPut. That changed the generated template, and the snapshot test (a stored copy of the whole template) failed in a different way, which is exactly what the guide warns about:

jest (second run: after narrowing the grants)
FAIL dist/test/reports-stack.test.js (8.296 s) ● snapshot › the dev template matches the stored baseline expect(received).toMatchSnapshot() Snapshot name: `snapshot the dev template matches the stored baseline 1` - Snapshot - 7 + Received + 37 @@ -281,17 +281,10 @@ { "Action": [ "s3:GetObject*", "s3:GetBucket*", "s3:List*", - "s3:DeleteObject*", - "s3:PutObject", - "s3:PutObjectLegalHold", - "s3:PutObjectRetention", - "s3:PutObjectTagging", - "s3:PutObjectVersionTagging", - "s3:Abort*", ], "Effect": "Allow", "Resource": [ { "Fn::GetAtt": [
jest (second run, summary)
› 1 snapshot failed. Snapshot Summary › 1 snapshot failed from 1 test suite. Inspect your code changes or re-run jest with `-u` to update them. Test Suites: 1 failed, 1 total Tests: 1 failed, 13 passed, 14 total Snapshots: 1 failed, 1 total Time: 9.679 s Ran all test suites.

The failing snapshot shows the delete permission gone and the put permissions moved to their own statement. The change was intentional, so I accepted the new baseline with jest -u, and then everything passed:

jest -u
Snapshot Summary › 1 snapshot updated from 1 test suite. Test Suites: 1 passed, 1 total Tests: 14 passed, 14 total Snapshots: 1 updated, 1 total Time: 7.705 s, estimated 9 s Ran all test suites.

Testing a Stack

The guide describes two kinds of tests: "Fine-grained assertions test specific aspects of the generated AWS CloudFormation template" and are "the most frequently used"; "Snapshot tests test the synthesized AWS CloudFormation template against a previously stored baseline", which let you refactor freely, but "CDK upgrades can also cause synthesized templates to change, so you can't rely only on snapshots". The tools are Template.fromStack, hasResourceProperties (a partial match by default), resourceCountIs and the Match helpers. My 14 tests, all passing at the end:

jest results (names)
passed ReportsStack, fine-grained assertions the data key rotates automatically and is kept when the stack is deleted passed ReportsStack, fine-grained assertions the key policy keeps the root statement that delegates to IAM, and names only the administrator role passed ReportsStack, fine-grained assertions the bucket is encrypted with the key, versioned, and blocks all public access passed ReportsStack, fine-grained assertions the bucket policy denies requests that do not use TLS passed ReportsStack, fine-grained assertions every queue is encrypted with the key passed ReportsStack, fine-grained assertions the work queue's settings come from the environment table passed ReportsStack, fine-grained assertions the backlog alarm exists only in production passed ReportsStack, fine-grained assertions a retention outside 1 to 3650 days stops synthesis passed ReportsStack, fine-grained assertions least privilege: the worker role is not allowed to delete objects or use a wildcard action passed ReportsStack, fine-grained assertions the template has no CloudFormation Conditions or Mappings of its own: those decisions were made while the app ran passed snapshot the dev template matches the stored baseline passed aspects and environments the versioning aspect turns an unversioned bucket into a synthesis error passed aspects and environments the same aspect is quiet for a versioned bucket passed aspects and environments an environment-agnostic stack leaves the Region as a token; an environment-specific one fixes it 14 passed, 0 failed of 14

How they are built: a helper creates an App and the stack for a given environment and returns Template.fromStack(stack), and each test asserts one thing. The least-privilege test is the one that mattered:

test("least privilege: the worker role is not allowed to delete objects or use a wildcard action", () => { const policies = Object.values(dev.findResources("AWS::IAM::Policy")); const allowed: string[] = policies.flatMap((p) => p.Properties.PolicyDocument.Statement.flatMap((s: { Effect: string; Action: string | string[] }) => (s.Effect === "Allow" ? [s.Action].flat() : []))); expect(allowed.filter((a) => a === "*" || a.endsWith(":*"))).toEqual([]); expect(allowed.filter((a) => a.startsWith("s3:Delete"))).toEqual([]); });

The tests ran against compiled JavaScript (Jest finds the compiled files in dist/test), because I did not want a TypeScript transform layer between Jest and TypeScript 7. Also: a test template does not contain the CLI's CDKMetadata resource (my first version of the "no Conditions" test assumed it did and failed; the test now ignores that one name). Tests need no AWS account and ran in about 10 seconds.

The guide's own testing example

I typed in the guide's example (a stack with a state machine, a Lambda function and an SNS subscription) and the assertions it prints. It compiles, and the test fails as printed:

jest on the guide's example (selected lines)
[WARNING] aws-cdk-lib.aws_stepfunctions.StateMachineProps#definition is deprecated. use definitionBody: DefinitionBody.fromChainable() WARNING Runtime: Runtime 'nodejs18.x' was deprecated on '2025-09-01'. Creation will be disabled on '2027-02-01', and updates will be disabled on '2027-03-03'. Please consider updating to 'nodejs24.x' (CloudFormation Validate) Template has 1 resources with type AWS::Lambda::Function, but none match as expected. !! Expected nodejs14.x but received nodejs18.x Tests: 1 failed, 1 total

The stack in the guide creates the function with Runtime.NODEJS_18_X and the assertion on the same page expects "nodejs14.x", so the example cannot pass. It also uses the definition property, which the library now marks deprecated ("use definitionBody"), and Node.js 18 as a Lambda runtime, which the CloudFormation validation notice says was deprecated on 2025-09-01. The lesson is not about that page: documentation examples age, and a test that fails with a clear diff ("Expected nodejs14.x but received nodejs18.x") is how you find out.

Aspects: Rules That Apply to the Whole Tree

An Aspect visits every construct in the tree (the guide: "a simple way to visit every node in the construct tree and perform an operation on each one"). Mine checks every bucket for versioning and, if it is missing, adds an error annotation:

import * as cdk from "aws-cdk-lib"; import * as s3 from "aws-cdk-lib/aws-s3"; import { IConstruct } from "constructs"; /** An Aspect visits every construct in the tree. This one turns a missing rule into a synthesis ERROR: no deployable template is produced. */ export class RequireBucketVersioning implements cdk.IAspect { visit(node: IConstruct): void { if (node instanceof s3.CfnBucket) { const v = node.versioningConfiguration as s3.CfnBucket.VersioningConfigurationProperty | undefined; if (!v || v.status !== "Enabled") { cdk.Annotations.of(node).addError("Bucket versioning must be Enabled (organisation rule)"); } } } }

Run here (level 1): cdk synth on an app with an unversioned bucket, and on the real app with the aspect attached:

cdk synth (the aspect fails an unversioned bucket) and the no-credentials runs
== cdk synth of an app that breaks the organisation rule (unversioned bucket) -> exit status 1 AI agent detected ERROR Bucket versioning must be Enabled (organisation rule) (Construct Annotations) Unversioned/Plain/Resource Rule Annotation::error-annotation Synthesis finished with errors (the cloud assembly files were still written: cdk.out manifest.json tree.json Unversioned.assets.json Unversioned.metadata.json Unversioned.template.json validation-report.json )

The synthesis printed the error and exited with status 1, but the cloud assembly files, including a template for the bad stack, were still written. A pipeline must test the exit status, never the existence of a template. Everything else the aspect did is checked in the tests above (a versioned bucket is quiet).

Escape Hatches

When an L2 construct lacks something, the guide lists the ways out: use the Cfn L1 directly; reach the L1 inside an L2 with construct.node.defaultChild and change it; use raw overrides (addOverride, addPropertyOverride, addPropertyDeletionOverride); or use CfnResource for a type the CDK does not know. There are also "un-escape hatches" (Bucket.fromCfnBucket) and, for features CloudFormation lacks, custom resources. I tried the raw route on a bucket created as versioned: true:

import * as cdk from "aws-cdk-lib"; import * as s3 from "aws-cdk-lib/aws-s3"; import { RequireBucketVersioning } from "../lib/aspects"; // escape hatches: reach the L1 resource inside an L2 and rewrite it with raw overrides const app = new cdk.App(); const stack = new cdk.Stack(app, "Hatch"); const bucket = new s3.Bucket(stack, "Reports", { versioned: true }); const cfn = bucket.node.defaultChild as s3.CfnBucket; cfn.addPropertyOverride("VersioningConfiguration.Status", "Suspended"); // raw override: bypasses the typed API cfn.addPropertyOverride("ObjectLockEnabled", true); // a property the L2 above never set cfn.addMetadata("reviewed-by", "platform-team"); // template metadata cfn.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.RETAIN; // a CloudFormation option cdk.Aspects.of(app).add(new RequireBucketVersioning()); // the organisation rule from lib/aspects.ts

Run here (level 1): cdk synth exited with status 0, and wrote:

Resources: Reports65402A0A: Type: AWS::S3::Bucket Properties: ObjectLockEnabled: true VersioningConfiguration: Status: Suspended UpdateReplacePolicy: Retain DeletionPolicy: Retain Metadata: aws:cdk:path: Hatch/Reports/Resource reviewed-by: platform-team CDKMetadata:

The override won: the template says Status: Suspended, a property the L2 never set (ObjectLockEnabled) appeared, and the metadata and Retain policy came through. And the organisation's versioning aspect did not complain, because it reads the typed property (still Enabled) while the raw override changes only the final template. So an escape hatch is a way around the CDK's own safety checks as well as its limits. If you use one, put a test on the generated template (as above) rather than relying on the aspect.

Construct IDs and Refactoring

The guide's warning about moving a construct is easy to check. This program prints the logical IDs of "the same bucket" in different places in the tree:

console.log("bucket directly in the stack :", ids((s) => new s3.Bucket(s, "ReportBucket"))); console.log("same code run again :", ids((s) => new s3.Bucket(s, "ReportBucket"))); console.log("bucket inside a construct :", ids((s) => new Storage(s, "Storage"))); console.log("construct renamed to Archive :", ids((s) => new Storage(s, "Archive"))); console.log("bucket id changed to Reports :", ids((s) => new s3.Bucket(s, "Reports")));
node dist/bin/ids.js
bucket directly in the stack : [ 'ReportBucket577F0FCD' ] same code run again : [ 'ReportBucket577F0FCD' ] bucket inside a construct : [ 'StorageReportBucketC5ACC6CD' ] construct renamed to Archive : [ 'ArchiveReportBucketFA2C8983' ] bucket id changed to Reports : [ 'Reports65402A0A' ]

The IDs are stable when the code is the same, and different the moment the bucket moves into a wrapper construct, the wrapper is renamed, or the id changes. CloudFormation sees a different logical ID as a different resource: a remove and an add, as chapter 4's change set experiment showed, and for a bucket or a key with data in it that is a serious event. The CLI has a cdk refactor command described as "Preserve deployed resources when refactoring code in your CDK application"; I did not read or run it. The safe habit: treat construct IDs and scopes as part of the stack's identity, and pin the logical ID or test for it before refactoring.

Environments, Context and Bootstrapping

A stack can be environment-agnostic (no account or Region given: the Region and account stay as tokens resolved at deploy time) or environment-specific (env: { account, region }). My tests check both: the Region is an unresolved token in the first case and eu-west-1 in the second. Both synthesize without credentials as long as nothing needs to ask AWS. Things that do (looking up a VPC, an AMI, a Route 53 zone) are context lookups: the CLI option reference says --lookups defaults to true and "Synthesis will fail if this value is false and context lookups need to be performed". (A file named cdk.context.json appeared in the project holding just {} after the failed lookup below; I did not read the page on context, so I will not describe its caching rules.)

Bootstrapping prepares an environment: "Before you deploy a CDK stack into an AWS environment, the environment must first be bootstrapped." It deploys a stack named CDKToolkit containing "an Amazon S3 bucket" (for files and assets), "an Amazon ECR repository" (for Docker images) and "IAM roles" that the CDK uses to deploy; "each environment that you want to use with the AWS CDK must first be bootstrapped". (I did not read what the bootstrap resources cost.) The generated template's BootstrapVersion parameter is how a stack checks that.

Run here (level 1), with no credentials: what the account-dependent commands do. Every one printed the same message and exited with status 1:

cdk diff, deploy, bootstrap, destroy and a context lookup, without credentials
== cdk diff (no AWS credentials on this machine) -> exit status 1 AI agent detected Need to perform AWS calls for account 111122223333, but no credentials have been configured Need to perform AWS calls for account 111122223333, but no credentials have been configured == cdk deploy (no AWS credentials on this machine) -> exit status 1 AI agent detected ✨ Synthesis time: 8.17s Need to perform AWS calls for account 111122223333, but no credentials have been configured == cdk bootstrap (no AWS credentials on this machine) -> exit status 1 AI agent detected ⏳ Bootstrapping environment aws://111122223333/eu-west-1... ❌ Environment aws://111122223333/eu-west-1 failed bootstrapping: NoCredentials: Need to perform AWS calls for account 111122223333, but no credentials have been configured Need to perform AWS calls for account 111122223333, but no credentials have been configured == cdk destroy (no AWS credentials on this machine) -> exit status 1 AI agent detected Reports-dev: destroying... [1/1] ❌ Reports-dev: destroy failed NoCredentials: Need to perform AWS calls for account 111122223333, but no credentials have been configured Need to perform AWS calls for account 111122223333, but no credentials have been configured == cdk synth of an app that does a context lookup (Vpc.fromLookup) -> exit status 1 AI agent detected ERROR Need to perform AWS calls for account 111122223333, but no credentials have been configured (Construct Annotations) Lookup Rule Annotation::error-annotation Synthesis finished with errors (the cloud assembly files were still written: cdk.out Lookup.assets.json Lookup.metadata.json Lookup.template.json manifest.json tree.json validation-report.json )

The CLI command list in the guide (acknowledge, bootstrap, context, deploy, destroy, diff, docs, doctor, drift, flags, import, init, list, metadata, migrate, notices, orphan, publish-assets, refactor, synthesize, watch) is the map. The commands that need an account are bootstrap, deploy, destroy, diff, drift, import and anything that performs lookups. Level 2, not run here (account and Region are placeholders):

# level 2: need AWS credentials and an account; NOT RUN here (they failed with "no credentials", see above). Account and Region are placeholders. # one-time per environment: create the CDKToolkit stack (bucket, ECR repository, IAM roles) cdk bootstrap aws://111122223333/eu-west-1 # see what would change, then deploy; the environment name is passed as context cdk diff -c environment=prod -c account=111122223333 -c region=eu-west-1 cdk deploy -c environment=prod -c account=111122223333 -c region=eu-west-1 # remove what the stack created (the key and the bucket stay: RemovalPolicy.RETAIN) cdk destroy -c environment=prod -c account=111122223333 -c region=eu-west-1

Feature Flags

The CDK changes some defaults over time, and to avoid changing existing templates it hides the new behaviour behind feature flags. A project created with cdk init is, I understand, set up with the current flags (I did not use it or read that page); mine was made by hand, which is why cdk synth said "84 feature flags are not configured". Run here (level 1): cdk flags --unstable=flags lists them. The table has four columns (the flag, the recommended value, the value you set, and the effective value) and for my project 84 flags show <unset> with an effective value of false, including ones that touch this stack, such as @aws-cdk/aws-iam:minimizePolicies, @aws-cdk/aws-kms:aliasNameRef and @aws-cdk/aws-s3:publicAccessBlockedByDefault. To see what one flag does, I set @aws-cdk/aws-iam:minimizePolicies on the command line and compared the worker's policy:

node (compare the policy with and without the flag)
flag unset (default) 6 statements: 5 actions, 1 actions, 3 actions, 2 actions, 6 actions, 4 actions minimizePolicies = true 4 statements: 5 actions, 5 actions, 3 actions, 6 actions actions listed (with repeats): 21 -> 19; distinct: 19 -> 19

The same 19 distinct actions, in 4 statements instead of 6. That one is harmless, but the point is that the same code can produce different templates depending on flags, so set them deliberately (the CLI offers cdk flags --set ... and a --safe option for flags "that don't change templates"; I did not run those), keep them in cdk.json, and let a snapshot test tell you when they change.

CDK, CloudFormation or Terraform?

What this chapter measured, as a list of trade-offs rather than a verdict:

CDK gaveCDK cost (measured above)
One word for a safety setting (enforceSSL, blockPublicAccess); intent-based grantsGrants wider than I would write by hand (19 actions against 7); a test caught one delete permission
Logic in a real language: tables, if, loops, types, unit tests with JestNo CloudFormation Conditions or Parameters: decisions are fixed at synth time, so one generated template is one environment
A template you can still read, lint and diff (it linted clean)Extra generated resources and metadata (9 resources against 7), defaults you did not write (Delete on queues)
Aspects that apply a rule to the whole treeRaw overrides bypass the typed API and the aspects (the Suspended bucket)
Fast feedback: synth in 8 to 18 seconds, tests in about 10, no accountAccount-dependent steps (lookups, diff, deploy) fail without credentials; documentation examples that no longer pass; a version-coupled CLI and library and 84 feature flags
Identity risk: construct IDs and scopes generate logical IDs, so a tidy refactor can be a delete and a create

Hand-written CloudFormation is the most direct and the least abstract; the CDK is worth it when you write many similar stacks or want real tests and reuse; Terraform / Infrastructure as Code covers the third option, which I did not test here, so I make no claims about it beyond that pointer. In every case the thing that gets deployed is the same kind of object, a template, and chapter 4's tools (lint, change set, failure model) apply to it.

Cost and Clean-Up

Nothing in this chapter created anything in AWS. The only thing left on this machine is the scratch folder (the 277 MB of installed packages and the generated cdk.out* folders); it is outside this site and can be deleted. If you deploy this stack for real (level 2, not run), cdk destroy removes what the stack created, except the key and the bucket, because their removal policy is RETAIN: the chapter 4 clean-up notes apply (empty and delete the bucket, schedule the key's deletion, 7 to 30 days, irreversible). Bootstrapping an environment leaves a CDKToolkit stack and its bucket, repository and roles in the account until you remove them; I did not check their cost.

What was and was not checked
Run here: cdk synth for dev and prod, for an aspect-failing app, an escape-hatch app and a lookup app; 14 Jest tests (including the deliberate failure, the snapshot failure and the update); the guide's own testing example; the generated templates compared with the chapter 4 template and linted with cfn-lint; the construct ID experiment; cdk ls, cdk doctor and cdk flags; the effect of one feature flag; and cdk diff, deploy, bootstrap and destroy without credentials (failures only). From the documentation, not run: the construct levels, app and bootstrapping descriptions, the CLI command list and options, the testing guidance and the escape hatch API (read 2026-10-13). Not read or not run: the Aspects, Stacks, Environments and feature-flag pages themselves; cdk init, cdk refactor, cdk watch, cdk drift, cdk import; cross-stack references; custom resources; Mixins; other languages; and every command that needs an account. Not checked at all: a real deployment of this stack.

Practice

Exercise 1

Create a CDK project by hand (TypeScript, scratch folder, nothing installed globally) and rebuild the encrypted bucket, two queues, worker role and production-only alarm from the CloudFormation chapter. Run cdk synth for dev and prod, lint the generated templates with cfn-lint, and compare them with the hand-written template: resource types, sections, the role's allowed actions and the deletion policies. Report every difference you did not write yourself.

📄 View solution
Exercise 2

Write Jest tests for the stack with fine-grained assertions and one snapshot. Include a least-privilege test, let it fail, fix the stack, watch the snapshot fail, and update it. Add an Aspect that rejects unversioned buckets and test both the failing and the passing case. Then run the CDK guide's own testing example as printed and report what happens.

📄 View solution
Exercise 3

Explore the edges: run cdk diff, deploy, bootstrap and destroy with no credentials, and a context lookup; show what an aspect error leaves in the output folder; use raw overrides and show they bypass the aspect; print the logical IDs for the same bucket in four places in the tree; list the unset feature flags and measure what one of them changes.

📄 View solution

Chapter 5 Quick Reference

  • App → stacks → constructs; L1 (Cfn*, one CloudFormation resource), L2 (curated defaults and helpers), L3 (patterns). Every resource lives in a Stack inside an App; the scope and id build the construct tree
  • cdk synth runs your program and writes a cloud assembly (cdk.out) with one CloudFormation template per stack; deployment is still CloudFormation. It needs no account unless something does a context lookup
  • Tables, conditions and parameter checks are ordinary code: the generated template has none of them (apart from the CLI's own CDKMetadata condition and the BootstrapVersion parameter and rule)
  • Logical IDs come from the construct path plus a hash: moving a construct, renaming it or changing its ID makes CloudFormation see a remove and an add. Same code gives the same IDs
  • Grants are convenient and broad: grantReadWrite included s3:DeleteObject*; test least privilege on the generated template. Defaults you did not set show up (Delete on queues)
  • Tests: Template.fromStack, hasResourceProperties (partial match), resourceCountIs, Match; snapshots catch every change, including intended ones and CDK upgrades, so use them sparingly. No account needed
  • Aspects visit every construct; an error annotation makes synth exit with status 1 but still writes the assembly: check the exit status. Raw overrides (addPropertyOverride) bypass both the typed API and aspects
  • Bootstrapping (CDKToolkit: bucket, ECR repository, IAM roles) is per environment and needed before deploying. bootstrap, deploy, diff, destroy and lookups need credentials and failed with exit status 1 without them
  • Feature flags change generated templates: set them deliberately in cdk.json (cdk flags lists them; 84 were unset in a hand-made project); the CLI prints a warning when they are unconfigured
  • Documentation examples age: the guide's own testing example fails as printed (Node 14 against Node 18) and uses a deprecated property
Coming next
Chapter 6 is containers on AWS: ECR repositories and image scanning, ECS clusters, task definitions and services, Fargate versus EC2 capacity, task roles versus execution roles, logs, and a task definition written and synthesised with the CDK. Docker is installed on this machine but its engine was not running while I wrote this chapter, so chapter 6 starts by starting it. Start with AWS Intermediate/Advanced 6.