The previous lesson ended on a concrete limitation: red-mercadofresco.yaml describes six almost identical subnets written out by hand, one after another, because YAML has no loops. It has no functions either, no types, and no way to wrap "MercadoFresco's standard network" into something reusable across three environments. And there is no way to write a test that verifies no template opens port 22 to the world. The AWS Cloud Development Kit solves all four problems in a single move: by letting infrastructure be written in a real programming language.
What matters from the first minute is that the CDK does not replace CloudFormation. Everything in 09-01 — stacks, change sets, drift, DeletionPolicy, ROLLBACK_COMPLETE — is still exactly as true as it was. The CDK writes the templates for you.
Cost warning. The CDK is free; you pay for the resources it creates.
cdk bootstrapcreates an S3 bucket, an ECR repository and five IAM roles per account and region: pennies a month while they sit empty. The examples in this lesson deploy a VPC with NAT (around 0.045 USD/hour each) and an ALB (around 0.025 USD/hour): if you follow the exercises, destroy the stacks when you finish using the command in the cleanup section. Fictitious data.
Contents
- What the CDK is and how it relates to CloudFormation
- Why it pays off, and when plain CloudFormation is still better
- Installation,
cdk initand project structure - App, Stack, Construct and the three construct levels
- MercadoFresco's network in CDK
- A construct of your own:
MercadoFrescoVpc - The application and integration layers in Python
- Assets: the Lambda code inside the stack
- The lifecycle:
synth,diff,deploy,destroyandbootstrap - Context, explicit environments and one stack per environment
- Aspects: mandatory tagging and audit rules
- Infrastructure tests
- Stateful resources,
RemovalPolicyand the danger ofcdk destroy - CDK Pipelines: the pipeline that deploys itself
- Terraform, CDK for Terraform and the honest comparison
- Cost and cleanup
- Common mistakes and tips
- Exercises
- Conclusion
What the CDK is and how it relates to CloudFormation
The CDK is a library of classes — available in TypeScript, JavaScript, Python, Java, C# and Go — with which infrastructure is described as objects. When you run cdk synth, those objects are synthesised: they produce a CloudFormation template, which is then deployed as an ordinary stack.
flowchart LR
A[TypeScript<br/>or Python code] -->|cdk synth| B[CloudFormation<br/>template]
B --> C[cdk.out/<br/>cloud artefact]
C -->|cdk deploy| D[Change set<br/>in CloudFormation]
D --> E[Deployed stack:<br/>real resources]
E -->|cdk diff| A
Three practical consequences follow from that flow, and they are worth internalising before writing a single line:
- The deployment artefact is still a template. You can read it, version it, review it and deploy it without the CDK.
cdk.out/holds the exact result of synthesis, andcdk synth > plantilla.yamlleaves it in a file for you. - CloudFormation failures still happen. A
ROLLBACK_COMPLETEon a CDK stack is diagnosed and resolved exactly as in 09-01: by looking at the stack events. - The code does not run in AWS. It runs on your laptop or in CodeBuild, and its only output is JSON. Nothing of the CDK is "alive" in the account except what bootstrapping creates.
Why it pays off, and when plain CloudFormation is still better
What a programming language brings is not prettier syntax: it is five capabilities YAML does not have. Loops, for the six subnets. Real conditionals, evaluated at synthesis rather than at deployment, which makes the generated template simpler rather than more complex. Types, which turn a property mistake into a compile error in the editor instead of a CREATE_FAILED eight minutes later. Reuse, by wrapping patterns in named classes. And tests, runnable in the pipeline just like those of the application code.
But the CDK is not the right answer in every case, and that is worth saying plainly:
| Criterion | Plain CloudFormation | AWS CDK |
|---|---|---|
| Team with no programming culture | Better: anyone can read YAML | Worse: you need TypeScript or Python |
| Stable infrastructure that changes twice a year | Better: nothing to maintain | Worse: dependencies to update every quarter |
| External audit reviewing the infrastructure | Better: the artefact is what gets reviewed | Worse: you must audit code and generated template |
| Lots of repetition (subnets, environments, accounts) | Worse: copy and paste | Better: loops and constructs |
| A need for automated tests | Worse: cfn-guard and little else |
Better: real unit tests |
| Shared in-house abstractions across teams | Worse: modules with little traction | Better: a versioned library |
| Learning curve | Better: learnt in an afternoon | Worse: a week before you are productive |
| Debugging an odd failure | Worse and better at once: there is only one layer | Worse: two layers, code and template |
MercadoFresco picks CDK for two concrete reasons rooted in its situation: it has three environments that must be identical and a two-person team that already writes code every day. If the team were pure systems people and the infrastructure had gone untouched for two years, the right answer would be to stay with YAML.
Installation, cdk init and project structure
The CDK is installed with npm, even for Python projects: the command line tool is Node.
npm install -g aws-cdk # command line tool
cdk --version # 2.x
mkdir infra-cdk && cd infra-cdk
cdk init app --language typescript # or --language pythoncdk init generates a runnable project. What matters about its structure:
| File | What it is |
|---|---|
bin/infra-cdk.ts |
Entry point: instantiates the App and the stacks |
lib/*-stack.ts |
The stacks: this is where the infrastructure lives |
test/*.test.ts |
Tests with Jest, already configured |
cdk.json |
Configuration: run command, context and feature flags |
cdk.out/ |
Synthesis output. Not versioned |
package.json |
Dependencies: aws-cdk-lib and constructs |
One warning about cdk.json: the context section includes feature flags (@aws-cdk/aws-*:featureFlag) that change the default behaviour of constructs. They are generated with the CDK version that created the project and must not be edited by hand: changing them can change the ARN or the name of already deployed resources, replacement included.
In the mercadofresco-infra repository, the project lives in infra-cdk/ alongside the 09-01 templates, which are kept for as long as the migration lasts.
App, Stack, Construct and the three construct levels
Three concepts, and everything else follows from them:
- App: the complete CDK application, the root of the tree. An App contains stacks.
- Stack: the unit of deployment, mapping 1:1 to a CloudFormation stack. The 09-01 limits — 500 resources — still apply.
- Construct: any node in the tree. A resource, a group of resources or a whole stack are all constructs. They all take the same three arguments:
scope(the parent),id(unique among siblings) andprops.
The id is the equivalent of the logical name from 09-01, and it inherits its most important rule: changing the id of a deployed construct destroys and recreates the resource. The CDK composes logical identifiers by concatenating the tree path and adding a suffix, so moving a construct elsewhere also recreates it.
Constructs come in three levels of abstraction, and the same bucket illustrates the difference:
// LEVEL 1 (L1): a direct copy of the CloudFormation resource. Cfn prefix.
// It sets no defaults: if you do not declare it, it does not exist.
new s3.CfnBucket(this, 'FotosL1', {
bucketName: 'mercadofresco-catalogo-fotos',
bucketEncryption: { serverSideEncryptionConfiguration: [
{ serverSideEncryptionByDefault: { sseAlgorithm: 'aws:kms' } } ] },
versioningConfiguration: { status: 'Enabled' },
});
// LEVEL 2 (L2): API with sensible defaults, types and convenience methods.
const photos = new s3.Bucket(this, 'FotosL2', {
encryption: s3.BucketEncryption.KMS,
encryptionKey: dataKey,
versioned: true,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, // blocked by default anyway
});
photos.grantRead(thumbnailRole); // generates the minimum IAM policy required
// LEVEL 3 (L3): a complete pattern. Several coordinated resources in one call.
new patterns.ApplicationLoadBalancedFargateService(this, 'Tienda', { /* ... */ });| Level | What it is | When to use it |
|---|---|---|
L1 (Cfn*) |
Automatic translation of the CloudFormation schema | A resource or property the L2 does not support yet |
| L2 | Carefully designed API, safe defaults, grant* and metric* methods |
90 % of the time |
| L3 | Patterns combining several resources | Prototypes and standard architectures |
Two warnings. From the L2 you can always drop down to the L1 with resource.node.defaultChild as s3.CfnBucket to touch a property the API does not expose; it is the escape hatch that stops you getting stuck. And L3s are convenient and opaque: they create more resources than you expect and fine-tuning them costs more than writing it by hand, so MercadoFresco uses them to prototype and then drops down to L2.
Another piece that saves a great deal of time are the grant* methods: cola.grantConsumeMessages(rol) generates the correct IAM policy — including permissions on the KMS key — without anyone writing a line of JSON. In practice, it is the least privilege of 04-01 by default.
MercadoFresco's network in CDK
The 190 lines of YAML from 09-01 turn into this:
import * as cdk from 'aws-cdk-lib';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import { Construct } from 'constructs';
export class RedStack extends cdk.Stack {
public readonly vpc: ec2.Vpc;
constructor(scope: Construct, id: string, props: cdk.StackProps & { entorno: string }) {
super(scope, id, props);
this.vpc = new ec2.Vpc(this, 'Vpc', {
ipAddresses: ec2.IpAddresses.cidr('10.0.0.0/16'),
maxAzs: 2, // eu-west-1a and eu-west-1b
// One NAT per AZ in production; a single one elsewhere. The condition is evaluated HERE,
// at synthesis, so the generated template carries no Fn::If at all.
natGateways: props.entorno === 'produccion' ? 2 : 1,
subnetConfiguration: [
{ name: 'publica', subnetType: ec2.SubnetType.PUBLIC, cidrMask: 24 },
{ name: 'app', subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, cidrMask: 24 },
{ name: 'datos', subnetType: ec2.SubnetType.PRIVATE_ISOLATED, cidrMask: 24 },
],
gatewayEndpoints: {
S3: { service: ec2.GatewayVpcEndpointAwsService.S3 },
},
});
}
}Twenty-eight lines against a hundred and ninety, and the synthesised result is equivalent: the same VPC, six subnets (three configurations × two AZs), the internet gateway, the NATs, four route tables with their associations and the S3 endpoint. What has disappeared is not infrastructure, it is mechanical repetition.
Three details are worth pausing on:
subnetConfigurationis the loop. You declare three subnet types and the CDK creates one per AZ, calculating the CIDRs automatically from the VPC block andcidrMask. Adding a third AZ means changingmaxAzs: 2to3.SubnetTypeencodes intent, not implementation:PRIVATE_WITH_EGRESSmeans "private with egress through NAT" andPRIVATE_ISOLATED, "no internet egress". MercadoFresco's data subnets are exactly the latter, and the CDK gives them no route to0.0.0.0/0because the type forbids it.- The NAT conditional is resolved at synthesis. In 09-01 it took a
Mappings, aConditionand anFn::If; here it is a TypeScript ternary operator and the resulting template has no condition at all. That is the difference between evaluating at synthesis time and evaluating at deployment time, and it is why generated templates tend to be simpler than hand-written ones.
One honest caveat: that concision has a price. The ec2.Vpc L2 makes decisions for you — the CIDR split, the names, the number of route tables — and to know exactly what it created you have to look at the synthesised template. That is why cdk synth is not an occasional debugging command: it is part of the normal flow.
A construct of your own: MercadoFrescoVpc
Real reuse arrives when the pattern is wrapped in a class with MercadoFresco's decisions already made:
export interface MercadoFrescoVpcProps {
readonly entorno: 'desarrollo' | 'preproduccion' | 'produccion';
readonly cidr?: string;
}
export class MercadoFrescoVpc extends Construct {
public readonly vpc: ec2.Vpc;
public readonly sgTienda: ec2.SecurityGroup;
constructor(scope: Construct, id: string, props: MercadoFrescoVpcProps) {
super(scope, id);
const isProduction = props.entorno === 'produccion';
this.vpc = new ec2.Vpc(this, 'Vpc', {
ipAddresses: ec2.IpAddresses.cidr(props.cidr ?? '10.0.0.0/16'),
maxAzs: 2,
natGateways: isProduction ? 2 : 1,
subnetConfiguration: [
{ name: 'publica', subnetType: ec2.SubnetType.PUBLIC, cidrMask: 24 },
{ name: 'app', subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, cidrMask: 24 },
{ name: 'datos', subnetType: ec2.SubnetType.PRIVATE_ISOLATED, cidrMask: 24 },
],
gatewayEndpoints: { S3: { service: ec2.GatewayVpcEndpointAwsService.S3 } },
// Flow logs are mandatory in production (05-03) and expensive in development.
flowLogs: isProduction ? { registro: { trafficType: ec2.FlowLogTrafficType.ALL } } : {},
});
this.sgTienda = new ec2.SecurityGroup(this, 'SgTienda', {
vpc: this.vpc,
description: 'MercadoFresco shop instances',
allowAllOutbound: true,
});
}
}Using it across the three environments is one line per environment, and any improvement to the pattern reaches all three at once: if tomorrow the decision is to enable flow logs in pre-production too, it changes in one place. That is what did not exist in 09-01, because a Mappings with three columns is not an abstraction: it is a table.
MercadoFresco's convention is that in-house constructs expose what consumers need and nothing more — here, vpc and sgTienda — so the internals can change without breaking anyone. It is encapsulation applied to infrastructure, and it is exactly why a programming language pays off.
The application and integration layers in Python
The CDK is genuinely multi-language: the libraries are generated with jsii from the same TypeScript source, so the API is identical apart from naming style. Luis prefers Python for the application layer:
from aws_cdk import Stack, Duration, aws_ec2 as ec2, aws_autoscaling as asg
from aws_cdk import aws_elasticloadbalancingv2 as elb, aws_ssm as ssm
from constructs import Construct
class AplicacionStack(Stack):
def __init__(self, scope: Construct, id: str, *, vpc: ec2.IVpc, entorno: str, **kwargs):
super().__init__(scope, id, **kwargs)
sizes = {"desarrollo": ("t3.small", 1, 2),
"preproduccion": ("t3.medium", 2, 3),
"produccion": ("m6i.large", 2, 4)}
instance_type, minimum, maximum = sizes[entorno]
self.alb = elb.ApplicationLoadBalancer(
self, "Alb", vpc=vpc, internet_facing=True,
load_balancer_name=f"alb-mercadofresco-tienda-{entorno}")
group = asg.AutoScalingGroup(
self, "Tienda", vpc=vpc,
instance_type=ec2.InstanceType(instance_type),
machine_image=ec2.MachineImage.from_ssm_parameter(
f"/mercadofresco/{entorno}/ami-tienda"),
min_capacity=minimum, max_capacity=maximum,
vpc_subnets=ec2.SubnetSelection(subnet_group_name="app"),
health_check=asg.HealthCheck.elb(grace=Duration.seconds(120)))
# Scaling on request count: the Friday peak is 900 orders per hour
group.scale_on_request_count("PorPeticiones", target_requests_per_minute=600)
listener = self.alb.add_listener("Https", port=443, open=True)
listener.add_targets("Tienda", port=8080, targets=[group],
health_check=elb.HealthCheck(path="/salud",
interval=Duration.seconds(15)))
ssm.StringParameter(self, "DnsAlb",
parameter_name=f"/mercadofresco/{entorno}/alb/dns",
string_value=self.alb.load_balancer_dns_name)Things happen here that took dozens of lines in YAML. add_listener and add_targets create the listener, the target group, the ASG registration and the security group rules needed for the ALB to reach the instances: the SourceSecurityGroupId of 09-01 is inferred by the CDK from the object graph. And scale_on_request_count creates the scaling policy with its associated CloudWatch alarm.
The integration layer is just as compact, and shows the 07-05 pattern with the dead letter queue:
from aws_cdk import aws_sqs as sqs, aws_sns as sns, aws_sns_subscriptions as subs
failed = sqs.Queue(self, "PedidosFallidos",
queue_name=f"mercadofresco-pedidos-fallidos-{entorno}",
retention_period=Duration.days(14),
encryption=sqs.QueueEncryption.KMS, encryption_master_key=clave)
orders = sqs.Queue(self, "Pedidos",
queue_name=f"cola-mercadofresco-pedidos-{entorno}",
visibility_timeout=Duration.seconds(180),
encryption=sqs.QueueEncryption.KMS, encryption_master_key=clave,
dead_letter_queue=sqs.DeadLetterQueue(max_receive_count=5, queue=failed))
topic = sns.Topic(self, "PedidoConfirmado",
topic_name=f"mercadofresco-pedido-confirmado-{entorno}", master_key=clave)
topic.add_subscription(subs.SqsSubscription(orders, raw_message_delivery=True))add_subscription creates the subscription and the queue policy that lets SNS write to it, with the aws:SourceArn condition included. It is the twenty-line block from the 09-01 exercise solution, reduced to one call and impossible to forget.
Assets: the Lambda code inside the stack
There is one thing CloudFormation cannot do on its own and the CDK solves almost invisibly: uploading the code. A template can declare a Lambda function, but its code has to be in a bucket already, with a key that somebody had to fill in by hand or from a script. In 09-01 that stayed outside the template and the pipeline handled it.
The CDK's assets close that gap: during cdk deploy, the tool packages the given directory, computes its fingerprint, uploads it to the bootstrap bucket and substitutes the reference in the template.
from aws_cdk import aws_lambda as lambda_, aws_lambda_event_sources as sources
thumbnails = lambda_.Function(
self, "GenerarMiniaturas",
function_name=f"mercadofresco-generar-miniaturas-{entorno}",
runtime=lambda_.Runtime.PYTHON_3_12,
handler="index.handler",
code=lambda_.Code.from_asset("lambdas/miniaturas"), # the directory is packaged and uploaded
memory_size=1024,
timeout=Duration.seconds(30),
environment={"BUCKET_FOTOS": photos.bucket_name})
photos.grant_read_write(thumbnails) # IAM policy + permissions on the KMS key
reserve = lambda_.Function(self, "ReservarStock", ...)
reserve.add_event_source(sources.SqsEventSource(orders, batch_size=10))Three practical consequences. The first: the content fingerprint is part of the asset name, so changing the code changes the asset and cdk diff detects it; if it does not change, the deployment does not touch the function. The second: add_event_source creates the event source mapping and the consumption permissions on the queue, including the KMS key. And the third, which is a warning: assets pile up in the bootstrap bucket and nobody deletes them, so the lifecycle rule in the cleanup section stops being optional after six months.
A nuance about the division of responsibilities. That the CDK can deploy the code does not mean it should in production: MercadoFresco keeps the 08-05 separation — the shop artefact is built once and CodeDeploy deploys it — and uses assets only for the Lambdas, whose code is small and tightly coupled to their infrastructure. Mixing the lifecycle of application code with that of infrastructure puts back together exactly what module 8 pulled apart.
The lifecycle: synth, diff, deploy, destroy and bootstrap
cdk bootstrap aws://111122223333/eu-west-1 # once per account and region
cdk ls # lists the App's stacks
cdk synth MercadoFrescoRedProduccion # synthesises and prints the template
cdk diff MercadoFrescoRedProduccion # compares against what is deployed
cdk deploy MercadoFrescoRedProduccion # deploys (creates a change set)
cdk deploy --all --require-approval any-change
cdk destroy MercadoFrescoRedDesarrollo # deletes the stack| Command | What it actually does |
|---|---|
synth |
Runs your code and writes the artefact to cdk.out/. Does not touch AWS |
diff |
Synthesises and compares against the deployed stack; equivalent to a readable change set |
deploy |
Uploads the artefact to the bootstrap bucket and runs the deployment in CloudFormation |
destroy |
delete-stack, with the same DeletionPolicy rules as 09-01 |
watch |
Hot-redeploys on save; development only, never in production |
cdk bootstrap deserves an explanation, because it is the part that confuses people most. It creates a stack called CDKToolkit with: an S3 bucket for artefacts (large templates, Lambda code, assets), an ECR repository for container images, and five IAM roles — deployment, asset publishing, lookup, CloudFormation execution and images. Without it, cdk deploy fails with an explicit message.
There are three things to know about bootstrapping: it is per account and per region, so deploying into us-east-1 for a CloudFront certificate means bootstrapping that region too; the version matters, and an old version causes cryptic failures that are fixed by running it again; and the roles it creates are powerful by default, with AdministratorAccess on the CloudFormation execution role, which in a shared account is a decision to be taken consciously — --cloudformation-execution-policies lets you narrow it down.
cdk diff is the day-to-day equivalent of the 09-01 change set, and its output marks with [-], [+] and [~] what gets deleted, added and modified, also flagging the changes that force replacement. MercadoFresco's rule is the same as there: no deployment without having read the diff.
Context, explicit environments and one stack per environment
Here we take on head-first the problem that shows up in every incident: pre-production is not the same as production. The CDK's answer is to make the three stacks come out of the same code, with one configuration file per environment as the only difference.
// config/entornos.ts — the single source of differences between environments
export interface ConfigEntorno {
readonly nombre: 'desarrollo' | 'preproduccion' | 'produccion';
readonly cuenta: string;
readonly region: string;
readonly cidr: string;
readonly retencionRegistrosDias: number;
readonly protegerRecursos: boolean;
}
export const ENTORNOS: Record<string, ConfigEntorno> = {
desarrollo: { nombre: 'desarrollo', cuenta: '111122223333', region: 'eu-west-1',
cidr: '10.2.0.0/16', retencionRegistrosDias: 7, protegerRecursos: false },
preproduccion: { nombre: 'preproduccion', cuenta: '111122223333', region: 'eu-west-1',
cidr: '10.1.0.0/16', retencionRegistrosDias: 30, protegerRecursos: true },
produccion: { nombre: 'produccion', cuenta: '111122223333', region: 'eu-west-1',
cidr: '10.0.0.0/16', retencionRegistrosDias: 365, protegerRecursos: true },
};// bin/infra-cdk.ts — the entry point instantiates the stacks for all three environments
const app = new cdk.App();
for (const config of Object.values(ENTORNOS)) {
const suffix = config.nombre.charAt(0).toUpperCase() + config.nombre.slice(1);
const env = { account: config.cuenta, region: config.region }; // EXPLICIT ENVIRONMENT
const network = new RedStack(app, `MercadoFrescoRed${suffix}`, { env, config });
new AplicacionStack(app, `MercadoFrescoAplicacion${suffix}`, { env, config, vpc: network.vpc });
new IntegracionStack(app, `MercadoFrescoIntegracion${suffix}`, { env, config });
}That leaves nine stacks with predictable names: MercadoFrescoRedProduccion, MercadoFrescoAplicacionPreproduccion, and so on. And the difference between environments is no longer an archaeological mystery: it is a twenty-line file you can read in ten seconds. The first time it was generated, Marta found three divergences that had been sitting there for months: pre-production had a single NAT declared as if it were production, log retention was 7 days in all three environments and the development CIDR overlapped with pre-production's.
flowchart TB
App[CDK App<br/>bin/infra-cdk.ts] --> C[config/entornos.ts]
App --> D[Desarrollo]
App --> P[Preproduccion]
App --> R[Produccion]
D --> D1[MercadoFrescoRedDesarrollo]
D --> D2[MercadoFrescoIntegracionDesarrollo]
D --> D3[MercadoFrescoAplicacionDesarrollo]
R --> R1[MercadoFrescoRedProduccion]
R --> R2[MercadoFrescoIntegracionProduccion]
R --> R3[MercadoFrescoAplicacionProduccion]
R1 -->|vpc| R3
Two important concepts in this block:
Explicit environments. If you leave out env, the stack is environment-agnostic and cannot use real account information: Vpc.fromLookup, the number of AZs or existing certificates. The CDK then generates templates with two fictitious AZs and surprises at deployment time. Always declare env with literal account and region.
Context and cdk.context.json. Lookups (fromLookup) query the account during synthesis and cache the result in cdk.context.json, which is versioned: it guarantees synthesis is reproducible and that the pipeline does not depend on live queries. When reality changes, it is refreshed with cdk context --clear. The practical rule: prefer explicit references over lookups, because a lookup is a hidden dependency on the state of the account.
Passing values between stacks is more natural than in 09-01: passing network.vpc to AplicacionStack makes the CDK create the CloudFormation export and import automatically. It is convenient and carries the same trap as there — an export in use is a lock — with an aggravating factor: the CDK creates it without you seeing it. If two stacks are going to evolve separately, it is still better to publish to Parameter Store and read with StringParameter.valueForStringParameter.
Aspects: mandatory tagging and audit rules
An aspect is a visitor that walks the whole construct tree and acts on every node. It serves two purposes that were impossible in YAML: applying something to all resources at once, and auditing rules against what is about to be deployed.
MercadoFresco's mandatory tagging is trivial with Tags, which is internally an aspect:
cdk.Tags.of(app).add('Proyecto', 'mercadofresco');
cdk.Tags.of(network).add('Entorno', config.nombre);
cdk.Tags.of(network).add('Componente', 'red');
cdk.Tags.of(network).add('Propietario', 'marta');
cdk.Tags.of(network).add('CentroCoste', 'plataforma');And an audit rule of your own is a twenty-line class:
import { IAspect, Annotations } from 'aws-cdk-lib';
import { IConstruct } from 'constructs';
/** Forbids unencrypted buckets and security groups with port 22 open to the world. */
export class ReglasMercadoFresco implements IAspect {
public visit(node: IConstruct): void {
if (node instanceof s3.CfnBucket && !node.bucketEncryption) {
// addError makes 'cdk synth' FAIL. addWarning only warns.
Annotations.of(node).addError('Every bucket must declare encryption (04-02).');
}
if (node instanceof ec2.CfnSecurityGroupIngress &&
node.fromPort === 22 && node.cidrIp === '0.0.0.0/0') {
Annotations.of(node).addError('Opening port 22 to the world is forbidden.');
}
}
}
cdk.Aspects.of(app).add(new ReglasMercadoFresco());Three details make the difference between this working and this producing false negatives. Aspects visit L1 constructs, so you have to check CfnBucket and not Bucket: L2s create L1s underneath and that is where the real properties live. addError stops synthesis, which turns the rule into a genuine quality gate rather than a warning nobody reads. And aspects run after the tree is built but before synthesis, so they can also modify resources — adding encryption instead of failing — although MercadoFresco prefers to fail: infrastructure that fixes itself hides the mistake instead of correcting it.
This is what cfn-guard did in 09-01 with a separate language. Here it is the same language, with the same editor, the same debugger and the same tests.
Infrastructure tests
The 08-05 testing pyramid had an obvious gap: infrastructure was not tested. With the CDK it is, because a synthesised template is a JSON object you can assert against.
import { App } from 'aws-cdk-lib';
import { Template, Match } from 'aws-cdk-lib/assertions';
describe('RedStack for production', () => {
const app = new App();
const stack = new RedStack(app, 'Prueba', { env: { account: '111122223333',
region: 'eu-west-1' },
config: ENTORNOS.produccion });
const template = Template.fromStack(stack);
test('creates six subnets', () => {
template.resourceCountIs('AWS::EC2::Subnet', 6);
});
test('production has two NATs, one per AZ', () => {
template.resourceCountIs('AWS::EC2::NatGateway', 2);
});
test('the data subnets have no internet egress', () => {
// No route towards a NAT may hang off the data route table
template.hasResourceProperties('AWS::EC2::Subnet', Match.objectLike({
Tags: Match.arrayWith([Match.objectLike({ Value: Match.stringLikeRegexp('.*datos.*') })]),
}));
});
test('no security group opens port 22 to the world', () => {
const sgs = template.findResources('AWS::EC2::SecurityGroup');
for (const sg of Object.values(sgs)) {
const rules = sg.Properties?.SecurityGroupIngress ?? [];
expect(rules.filter((r: any) => r.FromPort === 22 && r.CidrIp === '0.0.0.0/0')).toHaveLength(0);
}
});
test('the template does not change by accident', () => {
expect(template.toJSON()).toMatchSnapshot(); // snapshot test
});
});There are two families of tests and they serve different purposes:
| Type | What it checks | Advantage | Risk |
|---|---|---|---|
Assertions (hasResourceProperties, resourceCountIs) |
Concrete invariants you care about | They express intent; they survive refactoring | They only catch what you wrote |
Snapshots (toMatchSnapshot) |
That the template does not change by accident | They catch any change, including library ones | They get updated with -u without looking, and stop being useful |
MercadoFresco uses both with a strict rule: assertions cover what must never fail — six subnets, two NATs in production, no open SSH, encryption on every bucket — and snapshots act as a safety net. Plus a review rule: an updated snapshot in a PR obliges you to explain the diff in the description. Without that rule, jest -u becomes a reflex and the test stops being worth anything.
The real gain shows when upgrading aws-cdk-lib: the snapshot shows exactly what changed in the generated templates before anything is deployed. On a minor library upgrade, MercadoFresco discovered this way a change of default behaviour in a queue's encryption that nobody would have noticed.
Stateful resources, RemovalPolicy and the danger of cdk destroy
The CDK's RemovalPolicy is the DeletionPolicy of 09-01, with one dangerous difference: L2 constructs choose a default value, and not always the one you expect.
table = dynamodb.Table(self, "Carritos",
table_name=f"mercadofresco-carritos-{entorno}",
partition_key=dynamodb.Attribute(name="idCarrito", type=dynamodb.AttributeType.STRING),
billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,
removal_policy=RemovalPolicy.RETAIN if config.proteger else RemovalPolicy.DESTROY,
point_in_time_recovery=True)
cluster = rds.DatabaseCluster(self, "Pedidos",
engine=rds.DatabaseClusterEngine.aurora_postgres(
version=rds.AuroraPostgresEngineVersion.VER_15_4),
removal_policy=RemovalPolicy.SNAPSHOT, # final snapshot before deletion
deletion_protection=True, # and deletion protection in RDS on top
storage_encrypted=True)| Resource | Default RemovalPolicy in the L2 |
What it means |
|---|---|---|
s3.Bucket |
RETAIN |
The bucket survives cdk destroy |
dynamodb.Table |
RETAIN |
The table survives |
rds.DatabaseCluster |
SNAPSHOT |
Final snapshot and deletion |
logs.LogGroup |
RETAIN |
The logs survive |
sqs.Queue, sns.Topic, ec2.Vpc |
DESTROY |
Deleted, no questions asked |
And here is the real danger, which at MercadoFresco is concrete because the three environments share account 111122223333: cdk destroy --all does not ask which environment each stack belongs to. An --all launched from the wrong directory, or a cdk destroy MercadoFrescoRedProduccion with an unfortunate autocompletion, touches production. Three measures, in order of effectiveness:
deletion_protectionandtermination_protectionon the production stacks, which make deletion fail in the service rather than in the goodwill of whoever types the command.cdk.StackacceptsterminationProtection: true.- Never
--allin a human terminal. Production deployments come out of the pipeline; locally you name the stack in full. - Separate accounts, which is the real solution and arrives in 09-04. Until then, the two above are conscious patches.
One final nuance: RemovalPolicy.RETAIN leaves the resource orphaned, and the next cdk deploy will try to create another one with the same physical name and fail. It is the flip side of safety, and the reason fixed physical names — table_name, bucket_name — carry a cost, exactly as in 09-01.
CDK Pipelines: the pipeline that deploys itself
One last piece of the module 8 asymmetry remains: the pipeline was created by hand. pipelines.CodePipeline solves that with a property called self-mutation: the pipeline is defined in the same repository as the infrastructure and, on every run, its first stage updates itself before deploying anything.
const pipeline = new pipelines.CodePipeline(this, 'Pipeline', {
pipelineName: 'pipeline-mercadofresco-infra',
synth: new pipelines.ShellStep('Synth', {
input: pipelines.CodePipelineSource.connection('mercadofresco/mercadofresco-infra', 'main', {
connectionArn: 'arn:aws:codeconnections:eu-west-1:111122223333:connection/conn-mercadofresco-github',
}),
commands: ['npm ci', 'npm run build', 'npm test', 'npx cdk synth'],
}),
});
// One stage per environment. Manual approval only before production.
pipeline.addStage(new EtapaMercadoFresco(this, 'Desarrollo', { config: ENTORNOS.desarrollo }));
pipeline.addStage(new EtapaMercadoFresco(this, 'Preproduccion', { config: ENTORNOS.preproduccion }));
pipeline.addStage(new EtapaMercadoFresco(this, 'Produccion', { config: ENTORNOS.produccion }), {
pre: [new pipelines.ManualApprovalStep('AprobacionMarta')],
post: [new pipelines.ShellStep('Humo', { commands: ['./pruebas/humo.sh produccion'] })],
});A Stage is a group of stacks deployed together — network, data, integration, application and observability for one environment — and the CDK infers the deployment order from the dependency graph between them. npm test runs the tests from the previous section, so a template that breaks a rule never gets deployed.
With this, MercadoFresco closes the module 8 circle: pipeline-mercadofresco-tienda deploys the application and pipeline-mercadofresco-infra deploys the infrastructure and itself. Adding a new stage is a PR, not a click in the console.
Two warnings. The first: a self-mutating pipeline is powerful and dangerous — whoever can merge into main can change the pipeline itself — so the branch protection of 08-01 and mandatory review stop being good practice and become a security control. The second: the synthesis stage needs lookup permissions, so a versioned cdk.context.json is not optional if you want reproducible synthesis.
Terraform, CDK for Terraform and the honest comparison
Terraform, from HashiCorp, is the most widely used multi-provider alternative: its own declarative language (HCL), state in a file that has to be stored and locked, and providers for AWS, Azure, GCP, Datadog or GitHub. CDK for Terraform (CDKTF) lets you write Terraform configuration with TypeScript or Python, just as the CDK does with CloudFormation.
| Criterion | CloudFormation | AWS CDK | Terraform |
|---|---|---|---|
| Language | YAML/JSON | TypeScript, Python, Java, C#, Go | HCL (or TS/Python with CDKTF) |
| State | Managed by AWS | Managed by AWS | Its own file: you host and lock it |
| Multi-provider | No | No | Yes, it is the whole point |
| Preview | Change set | cdk diff |
terraform plan, more detailed |
| New AWS resources | Occasionally delayed | The L1 straight away, the L2 later | Fast, sometimes earlier |
| Automatic rollback | Yes, the stack rolls back on its own | Yes, it is CloudFormation | No: it stops halfway and you fix it |
| Drift | Native detection | CloudFormation's | plan shows it on every run |
| Operational cost | None | None | Managing the state (or paying for HCP Terraform) |
| Module community | Sparse | Growing (Construct Hub) | Huge: the module registry |
The recommendation is simple and does not depend on taste. If everything is in AWS, CloudFormation or CDK, because managed state and automatic rollback are real advantages that come dear in Terraform. If there are several providers — AWS plus Cloudflare, plus GitHub, plus Datadog — Terraform wins, and the cost of maintaining the state is justified. MercadoFresco is entirely in AWS and already has the pipeline built on native services: switching would be work with no return.
Cost and cleanup
The CDK is free; bootstrapping leaves a bucket, an ECR repository and five roles whose cost is negligible while they sit empty. It is worth putting a lifecycle rule on the asset bucket, though, which grows with every deployment and does not clean itself.
cdk destroy MercadoFrescoAplicacionDesarrollo MercadoFrescoRedDesarrollo # in this order
cdk ls # check they are gone
aws ec2 describe-addresses --query 'Addresses[?AssociationId==null]' --output tableThe order matters for the same reason as in 09-01: consumers first, network afterwards. If a stack refuses to be deleted, the cause is usually an export in use or a bucket with objects, and it is diagnosed in the CloudFormation events, not in the CDK.
Common Mistakes and Tips
Mistake: changing the id of a deployed construct. Renaming 'Vpc' to 'VpcPrincipal' recreates the resource, exactly as in 09-01. Tip: ids are immutable in practice; if you really must reorganise, cdk diff warns you — read it — and overrideLogicalId lets you keep the previous identifier.
Mistake: deploying without cdk diff. The CDK makes deploying so easy that people skip the preview. Tip: cdk diff on every PR, posted as a comment, and --require-approval any-change in production.
Mistake: stacks without an explicit env. They produce templates with two fictitious AZs and fromLookup does not work. Tip: declare env with literal account and region on every stack.
Mistake: cdk destroy --all in the shared account. It is MercadoFresco's concrete risk until 09-04. Tip: terminationProtection: true on production stacks, always name the stack, and never --all outside a test environment.
Mistake: updating snapshots with -u without looking. It turns the test into decoration. Tip: require in review that every updated snapshot comes with an explanation of the diff.
Mistake: assuming the L2 does what you would do. ec2.Vpc makes dozens of decisions for you. Tip: cdk synth and read the template the first time you use a new construct; it is the only way to know what you actually asked for.
Tip: pin the aws-cdk-lib version and upgrade it deliberately. With snapshot tests, every upgrade shows you exactly what changes in the templates before you touch anything.
Tip: use the grant* methods instead of writing policies. They generate genuine least privilege, including the KMS permissions almost everyone forgets.
Tip: do not put business logic in infrastructure code. The CDK invites you to, being a full language. A stack that reads a database during synthesis is a stack that cannot be synthesised in the pipeline.
Exercises
Exercise 1: the queue construct with a DLQ
Write your own construct ColaMercadoFresco (TypeScript or Python) that wraps the 07-05 pattern: a main queue with its dead letter queue, encryption with alias/mercadofresco-datos, a configurable maxReceiveCount defaulting to 5, 14-day retention on the DLQ, and a CloudWatch alarm that fires when the DLQ has messages. It must expose the main queue, the DLQ and a concederConsumo(rol) method. Use it to create MercadoFresco's four queues. State which RemovalPolicy you put on each queue and why.
Exercise 2: the missing tests
Write four tests for RedStack and AplicacionStack that verify invariants MercadoFresco genuinely cares about: (a) that production has two NATs and development one; (b) that no data subnet has a route towards a NAT; (c) that the ALB only accepts HTTPS; (d) that every taggable resource carries the five mandatory tags. For each one, say whether you would solve it with an assertion or a snapshot and why, and what would be needed besides the test for the rule to always hold.
Exercise 3: migrating without recreating
MercadoFresco already has the mercadofresco-red-produccion stack deployed from the 09-01 YAML template, with the VPC and the six subnets in production. Marta wants to move that stack to CDK without recreating anything and without service downtime. Describe the complete strategy: what options exist, which one you pick and why, what commands you run, how you check the migration will change nothing, and what you do if cdk diff shows differences. Explain as well the concrete risk of the option you discard.
Solutions
Solution 1
export interface ColaMercadoFrescoProps {
readonly nombre: string;
readonly entorno: string;
readonly clave: kms.IKey;
readonly maxIntentos?: number;
readonly tiempoVisibilidad?: cdk.Duration;
readonly temaAlertas: sns.ITopic;
}
export class ColaMercadoFresco extends Construct {
public readonly cola: sqs.Queue;
public readonly fallidos: sqs.Queue;
constructor(scope: Construct, id: string, props: ColaMercadoFrescoProps) {
super(scope, id);
this.fallidos = new sqs.Queue(this, 'Fallidos', {
queueName: `mercadofresco-${props.nombre}-fallidos-${props.entorno}`,
retentionPeriod: cdk.Duration.days(14),
encryption: sqs.QueueEncryption.KMS,
encryptionMasterKey: props.clave,
removalPolicy: cdk.RemovalPolicy.RETAIN, // holds unprocessed messages: never deleted
});
this.cola = new sqs.Queue(this, 'Principal', {
queueName: `cola-mercadofresco-${props.nombre}-${props.entorno}`,
visibilityTimeout: props.tiempoVisibilidad ?? cdk.Duration.seconds(180),
encryption: sqs.QueueEncryption.KMS,
encryptionMasterKey: props.clave,
deadLetterQueue: { queue: this.fallidos, maxReceiveCount: props.maxIntentos ?? 5 },
removalPolicy: props.entorno === 'produccion'
? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY,
});
// Any message in the DLQ is an incident: threshold 0, one evaluation.
this.fallidos.metricApproximateNumberOfMessagesVisible()
.createAlarm(this, 'AlarmaDlq', {
alarmName: `mercadofresco-${props.nombre}-fallidos`,
threshold: 0, evaluationPeriods: 1,
comparisonOperator: cw.ComparisonOperator.GREATER_THAN_THRESHOLD,
treatMissingData: cw.TreatMissingData.NOT_BREACHING,
})
.addAlarmAction(new actions.SnsAction(props.temaAlertas));
}
public concederConsumo(rol: iam.IGrantable): void {
this.cola.grantConsumeMessages(rol); // includes the permissions on the KMS key
}
}for (const nombre of ['pedidos', 'correo', 'almacen', 'analitica']) {
new ColaMercadoFresco(this, `Cola${nombre}`, { nombre, entorno: config.nombre,
clave, temaAlertas: alertas });
}The removal policies. The DLQ is always RETAIN, in every environment: it holds messages that were not processed and that somebody will have to examine; deleting it when a stack is destroyed destroys the evidence of an incident. The main queue, RETAIN in production and DESTROY elsewhere: in production there may be in-flight messages whose loss would be lost orders, whereas in development a leftover that stops the stack being recreated costs more than it protects.
And one detail you notice after using it four times: the four-line loop replaces a hundred and twenty lines of YAML with twelve resources, and it guarantees the four queues have exactly the same configuration — encryption, retries, alarm — which is precisely what does not hold at MercadoFresco today.
Solution 2
test('(a) the number of NATs depends on the environment', () => {
const prod = Template.fromStack(new RedStack(new App(), 'P', { env, config: ENTORNOS.produccion }));
const dev = Template.fromStack(new RedStack(new App(), 'D', { env, config: ENTORNOS.desarrollo }));
prod.resourceCountIs('AWS::EC2::NatGateway', 2);
dev.resourceCountIs('AWS::EC2::NatGateway', 1);
});
test('(c) the ALB only accepts HTTPS', () => {
appTemplate.hasResourceProperties('AWS::ElasticLoadBalancingV2::Listener',
Match.objectLike({ Port: 443, Protocol: 'HTTPS' }));
const listeners = appTemplate.findResources('AWS::ElasticLoadBalancingV2::Listener');
expect(Object.values(listeners).filter((l: any) => l.Properties.Port === 80)).toHaveLength(0);
});(a) Assertion. It is a business invariant with two concrete values, and the test expresses it better than any snapshot could. It also survives refactoring: if the constructs are reorganised tomorrow, the test still holds.
(b) Assertion, but indirect. Checking that "no data subnet has a route to a NAT" means following the relationship between RouteTable, Route and SubnetRouteTableAssociation in the template, which is tedious. The pragmatic approach is to assert that no AWS::EC2::Route with a NatGatewayId belongs to a table associated with a datos subnet, and to lean on the fact that the CDK guarantees the property by construction when you use PRIVATE_ISOLATED. The test is worth having as a safety net against a change of subnet type.
(c) Assertion, with the negative part included. Checking that a 443 listener exists is not enough: what matters is that none exists on port 80 without a redirect. Tests that only verify presence let through precisely the errors of addition, which are the ones that actually happen.
(d) Neither assertion nor snapshot: an aspect. A test walking every resource checking tags is fragile, because many types do not accept them and you would have to maintain the list of exceptions. The right answer is an aspect that fails synthesis if a mandatory tag is missing, plus a test that the aspect is registered in the App. You test the mechanism, not each instance.
What is needed besides the tests. None of this is worth anything if the tests do not block: they have to run in npm test inside the CDK Pipelines synthesis stage, and the build must fail, not warn. It is the same conclusion as 08-02, applied to infrastructure: a check that does not block is documentation.
Solution 3
There are two options and they are not equivalent.
The first is to recreate the network in CDK and migrate: deploy a new VPC alongside the old one, move the instances, the database and the ALB, and delete the old one. It is conceptually clean and it is the one to discard, because it means moving Aurora and the ALB between VPCs: endpoint changes, a cutover window, risk to customer data and, above all, zero benefit — the resulting network is identical. All the risk, none of the gain.
The second, the chosen one, is to have the CDK adopt the existing stack. The key point is that a CloudFormation stack does not know whether its template was written by a person or by the CDK: it only compares logical identifiers. If the synthesised template is equivalent, the update touches no resource.
The procedure. You write the CDK stack with the same stack name, mercadofresco-red-produccion, and force the logical identifiers to match those in the YAML template, using overrideLogicalId resource by resource:
const cfnVpc = this.vpc.node.defaultChild as ec2.CfnVPC;
cfnVpc.overrideLogicalId('Vpc'); // the exact logical name from red-mercadofresco.yamlThe check, which is the heart of the exercise:
cdk synth mercadofresco-red-produccion > /tmp/cdk.yaml
aws cloudformation get-template --stack-name mercadofresco-red-produccion \
--template-stage Processed --query TemplateBody > /tmp/actual.json
cdk diff mercadofresco-red-produccion # the verdict: it has to come out emptyAn empty cdk diff is the acceptance condition. As long as it shows anything, nothing gets deployed.
If it does show differences, there are three cases. If they are only CDK metadata (CDKMetadata, the analytics version) they are accepted: they affect no resource. If they are cosmetic properties — a description, a tag — it is decided case by case and documented. And if there is any [~] with replacement or any [-], you stop: it means the generated template is not equivalent and the code has to be adjusted until it is. A [-] on a production subnet is exactly the disaster this migration exists to avoid.
The safety net, in two steps. Before anything else, --enable-termination-protection on the stack and DeletionPolicy: Retain on the critical resources, so that not even a serious mistake destroys anything. And the full rehearsal first in development, then in pre-production and only then in production, which is the same promotion order as 08-04 applied to infrastructure.
Conclusion
The same infrastructure, in a programming language. The 190 lines of YAML for the network have become 28 lines of TypeScript that synthesise an equivalent template, and the six almost identical subnets have vanished inside a three-entry subnetConfiguration that the CDK multiplies per AZ. What has not changed is what lies underneath: the CDK does not replace CloudFormation, it writes the templates for you, and the change sets, drift, ROLLBACK_COMPLETE and deletion policies of 09-01 still govern what actually happens.
You have the three construct levels — the L1 that copies CloudFormation, the L2 with safe defaults and grant* methods that generate least privilege, and the convenient, opaque L3 — with the node.defaultChild escape hatch to drop a level when the API falls short. You have your own construct, MercadoFrescoVpc, which is the difference between a three-column table and a real abstraction: an improvement to the pattern reaches all three environments at once. And you have the complete lifecycle, with cdk bootstrap explained — the bucket, the ECR repository and the five roles, per account and region, with administrator permissions that must be narrowed down consciously — and with the rule inherited from 09-01: no deployment without having read the diff.
The three environments now come out of the same code, and the difference between pre-production and production has stopped being archaeology: it is config/entornos.ts, twenty lines readable in ten seconds, which the first time it was written exposed three divergences that had been sitting there for months. With explicit environments on every stack — no env means no lookups and yes fictitious AZs — and a versioned cdk.context.json so synthesis is reproducible in the pipeline.
And infrastructure finally enters the 08-05 testing pyramid. Aspects apply mandatory tagging in one go and audit rules — unencrypted buckets, port 22 open — with addError, which stops synthesis instead of warning; always visiting L1 constructs, which is where the real properties live. Assertions cover what must never fail and snapshots act as a safety net, with the review rule that keeps them alive: an updated snapshot obliges you to explain the diff. Plus CDK Pipelines, which closes the module 8 asymmetry by deploying the infrastructure and updating itself — and which turns the branch protection of 08-01 into a security control, because whoever merges into main changes the pipeline.
One risk remains, flagged three times and unresolved: cdk destroy pointed at production. Termination protection and the discipline of always naming the stack are conscious patches, not solutions, because the underlying problem is that the three environments share account 111122223333. While that stays true, a mistyped command, a forgotten environment variable or an --all in the wrong directory can reach the shop in production. And the blast radius is not the only issue: quotas are shared, a development deployment can exhaust production's elastic IP limit, the bill does not really separate and no IAM policy fully isolates someone who already has broad permissions in the account.
In 09-03, "AWS Elastic Beanstalk", we take a step back to look at the managed alternative: what a platform as a service does for you, when it would have been the right decision for MercadoFresco — and why it no longer is. After that, 09-04, "AWS Organizations", attacks the underlying problem: genuinely separating environments into different accounts, with service control policies, consolidated billing and a baseline that deploys itself into every new account.
AWS Course
Module 1: Introduction to AWS
- What Is AWS?
- Setting Up Your AWS Account
- AWS Global Infrastructure
- The AWS Management Console
- AWS CLI and SDKs
Module 2: Core AWS Services
Module 3: Networking and Content Delivery
Module 4: Security and Identity
- AWS Identity and Access Management (IAM)
- AWS Key Management Service (KMS)
- Secrets Manager and Parameter Store
- AWS Shield
- AWS WAF
Module 5: Monitoring and Management
Module 6: Databases
Module 7: Application Integration
- Amazon SQS
- Amazon SNS
- Amazon EventBridge
- AWS Step Functions
- Integration Patterns: Idempotency, Retries and Dead-Letter Queues
