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.
- Run here (level 1): Node 24.18.0 on Windows 11, with
aws-cdk(the CLI) 2.1145.0,aws-cdk-lib2.273.0,constructs10.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 synthwas 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 destroyand 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).
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:
| Level | What the guide says | Example 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 Cfn | s3.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:
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.
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):
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.
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.)
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):
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:
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:
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:
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:
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:
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:
Run here (level 1): cdk synth on an app with an unversioned bucket, and on the real app with the aspect attached:
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:
Run here (level 1): cdk synth exited with status 0, and wrote:
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:
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:
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):
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:
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 gave | CDK cost (measured above) |
|---|---|
One word for a safety setting (enforceSSL, blockPublicAccess); intent-based grants | Grants 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 Jest | No 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 tree | Raw overrides bypass the typed API and the aspects (the Suspended bucket) |
| Fast feedback: synth in 8 to 18 seconds, tests in about 10, no account | Account-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.
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
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.
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 solutionExplore 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.
Chapter 5 Quick Reference
- App → stacks → constructs; L1 (
Cfn*, one CloudFormation resource), L2 (curated defaults and helpers), L3 (patterns). Every resource lives in aStackinside anApp; thescopeandidbuild the construct tree cdk synthruns 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
CDKMetadatacondition and theBootstrapVersionparameter 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:
grantReadWriteincludeds3:DeleteObject*; test least privilege on the generated template. Defaults you did not set show up (Deleteon 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,destroyand lookups need credentials and failed with exit status 1 without them - Feature flags change generated templates: set them deliberately in
cdk.json(cdk flagslists 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