CDK & SAM

Infra in code; serverless; when to use which.

Intermediate30 min · lesson 5 of 15

Writing raw CloudFormation YAML (YAML Ain't Markup Language, the indented text format AWS reads) is bricklaying. Every brick placed by hand, every one labelled by you. The AWS Cloud Development Kit (CDK) hands you prefabricated rooms instead: classes you snap together in a real programming language (TypeScript, Python, Java, C#, or Go), plus a compiler called cdk synth that expands your code back into ordinary CloudFormation bricks. AWS SAM (Serverless Application Model) is a narrower stencil, a shorthand for exactly one building type: serverless apps. Both sit *on top of* CloudFormation, AWS's declarative provisioning engine that turns a template into real resources with change sets and automatic rollback. You keep every safety guarantee and gain loops, functions, types, and unit tests. What you need to learn is when each one earns its place, and what the machinery is doing underneath.

Constructs: L1, L2, and L3

A construct is CDK's unit of reuse, a class that stands in for one or more cloud resources. They come in three levels, and a hardware store is the right mental model. L1 constructs (they all start with Cfn) are the loose screws and brackets: a mechanical one-to-one mapping of raw CloudFormation resources, where you set every property yourself. L2 constructs are the curated shelf, hand-written by the CDK team. s3.Bucket turns on SSE-S3 (server-side encryption using keys AWS manages) by default and reduces the settings that matter to one-line props, blockPublicAccess: BlockPublicAccess.BLOCK_ALL and versioned: true. It also gives you grant helpers such as bucket.grantRead(fn), which write a least-privilege IAM (Identity and Access Management) policy for you. L3 constructs, called patterns, are the flat-pack furniture: ApplicationLoadBalancedFargateService stands up a load balancer, a service, a task definition, and security groups from a handful of lines (Fargate is the AWS option that runs containers without you managing servers). Write at the highest level that fits. Drop to L1 only for the rare property CDK has not surfaced yet. Then package your organization's standards as your *own* construct, so every team inherits them without copy-paste.

lib/app-stack.ts
// lib/app-stack.ts — real CDK v2 (aws-cdk-lib) code, not pseudocode
import { Stack, StackProps, RemovalPolicy, Duration } from 'aws-cdk-lib';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import { Construct } from 'constructs';
export class AppStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
// L2: SSE-S3 encryption is the L2 default; versioning turned on explicitly
const assets = new s3.Bucket(this, 'AppBucket', {
encryption: s3.BucketEncryption.S3_MANAGED,
versioned: true,
removalPolicy: RemovalPolicy.RETAIN, // keep data if the stack is destroyed
});
const api = new lambda.Function(this, 'ApiFn', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'index.handler',
code: lambda.Code.fromAsset('src'),
timeout: Duration.seconds(10),
});
assets.grantRead(api); // synthesizes a scoped s3:GetObject policy for you
}
}

Bootstrap: the one-time setup CDK refuses to skip

Before your first deploy into any account and region pair, you run cdk bootstrap once. It is the equivalent of wiring the workshop before the first job: power, a loading dock, a set of keys. It provisions a stack named CDKToolkit holding an S3 (Simple Storage Service) bucket for file assets like Lambda zips, an ECR (Elastic Container Registry) repository for container images, and a set of IAM roles the CLI (command-line interface) assumes to publish those assets and run CloudFormation. It also stamps a version number, one that only ever counts upward, into the SSM (Systems Manager) parameter /cdk-bootstrap/hnb659fds/version. Every cdk deploy reads that number and refuses to run when the bootstrap is too old for your CDK version. That mismatch is the single most common first-deploy failure. Once bootstrapped, cdk synth compiles your code into a template plus an asset manifest under ./cdk.out. That directory *is* the deployable artifact, so build it once in CI (continuous integration) and deploy the same cdk.out to every stage.

bootstrap-and-synth.sh
# One-time per account+region: create the CDK "toolkit" stack
$ cdk bootstrap aws://123456789012/us-east-1
⏳ Bootstrapping environment aws://123456789012/us-east-1...
✅ Environment aws://123456789012/us-east-1 bootstrapped.
# Deploys check this SSM version for compatibility
$ aws ssm get-parameter --name /cdk-bootstrap/hnb659fds/version \
--query Parameter.Value --output text
28
# Compile the code to a CloudFormation template in ./cdk.out
$ cdk synth AppStack | head -n 11
Resources:
AppBucket8588D1AA:
Type: AWS::S3::Bucket
Properties:
BucketEncryption:
ServerSideEncryptionConfiguration:
- ServerSideEncryptionByDefault: { SSEAlgorithm: AES256 }
VersioningConfiguration: { Status: Enabled }
UpdateReplacePolicy: Retain # from removalPolicy: RETAIN
DeletionPolicy: Retain
Bootstrap hands out AdministratorAccess by default
cdk bootstrap creates a CloudFormation *execution role*, and unless you say otherwise it attaches the AWS-managed AdministratorAccess policy to that role. From that moment on, anyone who can trigger a deploy can create any resource in the account, and that includes a build job somebody has stolen credentials from. In shared or production accounts, re-bootstrap with --cloudformation-execution-policies pointing at a customer-managed policy scoped to exactly the resources your stacks create. The CLI warns you those policies have to cover every deployment, so write them from your real templates rather than from guesswork. Limit which accounts you --trust for cross-account pipelines too. The bootstrap stack is production security surface, not boilerplate you skim past.

SAM: serverless in a dozen lines

SAM is CloudFormation with one extra line at the top, Transform: AWS::Serverless-2016-10-31. Treat that line as autocomplete for infrastructure: terse resource types such as AWS::Serverless::Function, ::Api, ::SimpleTable, and ::StateMachine get expanded by a macro into the full CloudFormation they stand for. Brevity is the smaller half of the payoff. The bigger half is the local loop. sam build packages your handler and its dependencies. sam local invoke runs the function inside a Docker container that mimics the Lambda runtime. sam local start-api stands up your API Gateway routes on localhost. API there means application programming interface, in this case the HTTP front door that turns a web request into a function call, and you can curl those routes before a single byte reaches AWS. For fast iteration against real cloud resources, sam sync --watch pushes code changes in seconds. Validate templates in CI with sam validate --lint. Ship with sam deploy --guided, which walks you through the parameters once and writes your answers into samconfig.toml so later deploys run unattended.

template.yaml
# template.yaml — AWS SAM
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31 # expands the shorthand into full CFN
Resources:
HelloFn:
Type: AWS::Serverless::Function
Properties:
CodeUri: hello/
Handler: app.handler
Runtime: python3.12
Events:
Api:
Type: Api
Properties: { Path: /hello, Method: get }
# --- build once, then invoke locally in a Lambda-like container ---
# $ sam build && sam local invoke HelloFn
# Building codeuri: hello/ runtime: python3.12 ...
# Build Succeeded
# Invoking app.handler (python3.12)
# START RequestId: 5f2c-... Version: $LATEST
# END RequestId: 5f2c-...
# REPORT RequestId: 5f2c-... Duration: 3.42 ms Billed Duration: 4 ms \
# Memory Size: 128 MB Max Memory Used: 39 MB
# {"statusCode": 200, "body": "{\"message\": \"hello\"}"}

diff, deploy, and the hotswap trap

CDK feels like writing an app, which is exactly where the danger hides. The things on the other end are real, stateful, and billed by the hour. cdk diff is your safety check: it synthesizes, compares the result against the deployed stack, and prints the resource-level change set. Run it during code review, not after the fact. cdk deploy then creates a CloudFormation change set and applies it, with full rollback if anything breaks. For the dev inner loop, CDK offers cdk deploy --hotswap (and cdk watch, which uses it underneath). For a short list of resource types, Lambda code, Step Functions definitions, and ECS (Elastic Container Service) services, it calls the service API directly and patches the running resource in place. A 60-second deploy collapses to a few seconds. The price is that CloudFormation is cut out of the loop entirely, and hotswap implies --no-rollback, so the live resource no longer matches the stack template. That gap has a name: drift. Fine in a sandbox you would happily delete tomorrow. Never on a stack anyone else depends on.

diff-and-deploy.sh
$ cdk diff AppStack
Stack AppStack
Resources
[+] AWS::S3::Bucket AppBucket AppBucket8588D1AA
[+] AWS::Lambda::Function ApiFn ApiFn9C1B2D3E
[+] AWS::IAM::Policy ApiFn/ServiceRole/DefaultPolicy # the grantRead policy
$ cdk deploy AppStack --require-approval never
✨ Synthesis time: 3.1s
AppStack: deploying... [1/1]
AppStack: creating CloudFormation changeset...
AppStack | 3/3 | CREATE_COMPLETE
✅ AppStack
✨ Deployment time: 48.6s
Stack ARN:
arn:aws:cloudformation:us-east-1:123456789012:stack/AppStack/8f3b-...
Which IaC tool for the job?
What are you provisioning?
pick the ergonomics; the engine underneath is the same
Lambda + API + DynamoDB
SAM
transform shorthand, sam local, sam sync --watch
Complex / DRY infra in a real language
CDK
constructs, unit tests, synth → CloudFormation
Small, declarative, few resources
CloudFormation
raw YAML/JSON, zero build toolchain
Multi-cloud / non-AWS state
Terraform
its own state engine, outside this section
CDK and SAM both synthesize to CloudFormation, so change sets and rollback apply either way. The choice is ergonomics, not safety.

Picking one, and shipping it safely

How to choose. Reach for SAM when the workload is squarely serverless and you want a fast local test loop. Reach for the CDK when the infrastructure is complex or repetitive and you want a real language's loops, types, and unit tests, or when you need to encode your standards as constructs other teams can import. Stay on plain CloudFormation when a stack is small and declarative and you would rather not own a Node or Python toolchain: a raw template has no build step and reviews cleanly in Git. Whichever you pick, wire a policy-as-code check into synth, meaning rules about your infrastructure written as code that runs in the build. cdk-nag and cfn-guard both do this, and both catch insecure defaults before a deploy instead of after. All three tools provision through the same engine. The trade-off is abstraction and ergonomics, never the guarantees underneath.

Scale, cost, and quotas. CloudFormation costs nothing by itself; you pay for the resources it creates. Its limits still shape how you carve up a CDK app: 500 resources per stack, 200 parameters / outputs / mappings each, and a 1 MB template ceiling (bigger templates go up through S3). A busy CDK app hits the resource ceiling sooner than you would guess, because one L3 construct can quietly be a dozen resources. The bootstrap S3 bucket and ECR repository accrue cost too, as old asset versions pile up; prune them with cdk gc (still opt-in behind --unstable=gc) or an S3 lifecycle rule. Pin your aws-cdk-lib version so synth output stays reproducible, keep removalPolicy: RETAIN on anything holding data, and gate every deploy on cdk diff. When one stack outgrows the 500-resource ceiling, or the same stack has to land across many accounts and regions, you graduate to nested stacks and StackSets, which is precisely where the next lesson picks up.

The synthesized template is the truth about what CloudFormation will see. Your TypeScript is only the program that produced it. If you cannot open cdk.out/AppStack.template.json and say out loud what is about to change, you have no business hotswapping anything. Unit-test constructs the way you unit-test application code, and assert on the properties that hurt when they slip: encryption on, public access blocked, IAM actions scoped to the bucket you meant.

SAM is the sharper tool for a pure serverless app, where the whole stack is a function, a route, and a table. CDK earns its keep the moment you need loops, want to hand a construct to another team, or find yourself wiring six services together in a single deploy. Neither one changes the deployment contract. Both hand a template to CloudFormation and wait for it to answer.

Bootstrap once per environment, account, and region combination, and write down that you did it. Without the CDKToolkit stack, cdk deploy fails in ways that read like a permissions problem. Learn what that error actually looks like, so nobody on your team tries to fix it by widening an IAM policy.

Try this

Open a lab CDK app, synth it, and diff it against the stack already deployed. Nothing here deploys, so you can run all three commands against a real account without touching a resource.

terminal
npx cdk synth --quiet
npx cdk diff
aws cloudformation describe-stacks --stack-name LabCdkStack \
--query 'Stacks[0].{Status:StackStatus,Desc:Description}' --output table
output
# cdk synth writes cdk.out/LabCdkStack.template.json
Stack LabCdkStack
Resources
[~] AWS::Lambda::Function Handler
└─ [~] Timeout
├─ [-] 3
└─ [+] 10
-----------------------------
| DescribeStacks |
+----------+----------------+
| Status | UPDATE_COMPLETE|
+----------+----------------+

Takeaway

Remember: whatever you write in TypeScript, CloudFormation is what actually runs, so keep the change set and keep the execution role scoped, and treat --hotswap as a sandbox convenience with no route to production.

Next: write one assertion test that fails if a bucket in your stack is public or unencrypted, and run it in CI ahead of cdk deploy.

Quick check
01You use cdk deploy --hotswap (or cdk watch) in a dev loop to push a Lambda code change in a few seconds. Why should that flag never touch a production stack?
Incorrect — No. Hotswap patches a short list of resource types in place (Lambda code, Step Functions definitions, ECS services) and never deletes anything stateful.
Incorrect — Bootstrap runs once per account and region. Hotswap never touches it.
Correct — Hotswap trades CloudFormation's safety for speed. Harmless in a stack you would throw away, dangerous anywhere someone relies on the template matching reality.
Incorrect — Termination protection has nothing to do with it. Hotswap goes around CloudFormation by design.
02What separates an L2 construct from an L1 (Cfn) construct in the AWS Cloud Development Kit (CDK)?
Incorrect — That describes L1 (Cfn) constructs, where every property is yours to set by hand.
Correct — L2 is the curated layer, with defaults like SSE-S3 encryption and grant helpers. L1 is the raw mirror.
Incorrect — That is an L3 pattern construct, which composes many resources into one architecture.
Incorrect — Every CDK construct synthesizes to CloudFormation. No level of construct skips it.
03You are about to run cdk bootstrap for the first time in a shared production account, and security's requirement is blunt: a compromised continuous-integration job must not be able to create whatever it likes. Which approach is the MOST secure?
Incorrect — That default grant is the whole problem. It lets any deploy, including a hijacked one, do anything in the account.
Incorrect — CDK deploys need the CDKToolkit stack and its roles. Skipping it is not on the table.
Correct — Scoping the execution policy and narrowing the trusted accounts treats the bootstrap stack as production security surface rather than boilerplate.
Incorrect — Deleting that bucket breaks asset publishing and does nothing to narrow the execution role's permissions.

Related