“If it’s not in code, it doesn’t exist.”
That’s the rule I enforce on every team I lead. Not because I’m dogmatic. Because I’ve been burned. In 2019, a team I inherited had a production environment that nobody could reproduce. The database had been configured through the console with settings that weren’t documented. The security groups had been modified by three different people over six months. When we needed to spin up a disaster recovery environment, we spent two weeks reverse-engineering what production actually looked like. Two weeks.
Never again.
AWS CDK (Cloud Development Kit) v2 lets us define every piece of our Kids Learn infrastructure — VPCs, databases, Lambda functions, security policies, monitoring dashboards — in TypeScript. Real code. With types. With tests. With code review. With version history. When I say “deploy to staging,” it’s a single command that creates an exact replica of production. When I say “what changed last month,” it’s a git log.
This is Part 2 of the AWS Full-Stack Mastery series. If you haven’t read Part 1, start there for the overall architecture and AWS account setup. In this post, we go deep on CDK.
Why CDK Over Terraform, CloudFormation, or Pulumi
Let me address this upfront because every infrastructure-as-code discussion becomes a tool war.
CloudFormation is the foundation. CDK generates CloudFormation templates under the hood. But writing 3,000 lines of YAML to define a VPC with subnets, NAT gateways, route tables, and endpoints is not engineering — it’s suffering. CloudFormation doesn’t have loops, conditionals beyond basic Fn::If, or abstractions beyond nested stacks. You end up copy-pasting blocks and praying you didn’t miss a parameter.
Terraform is excellent. HCL is a well-designed configuration language. The provider ecosystem is massive. If your team uses multiple cloud providers, Terraform is the right choice. But for an AWS-only stack, CDK integrates more deeply. CDK constructs can compose AWS services in ways that Terraform modules can’t — like automatically configuring IAM permissions when you connect a Lambda function to an S3 bucket. CDK understands the AWS service model. Terraform treats everything as generic key-value resource configuration.
Pulumi is the closest competitor — real programming languages for infrastructure. But CDK has the AWS-native advantage: first-class L2 constructs with sensible defaults, CDK Pipelines for CI/CD, and the CDK construct library that is maintained by AWS teams who own the underlying services.
Our choice: CDK v2 with TypeScript. TypeScript because our application code is TypeScript (Next.js), so the entire team can read and contribute to infrastructure code without learning a new language.
CDK Fundamentals — The Mental Model
CDK has three layers of abstraction. Understanding them is the difference between writing clean infrastructure code and creating an unmaintainable mess.
Constructs: The Building Blocks
┌────────────────────────────────────────────────────────────┐
│ L3 Constructs (Patterns) │
│ Complete solutions: LambdaRestApi, ApplicationLoadBalanced │
│ FargateService, etc. Compose multiple L2 constructs. │
├────────────────────────────────────────────────────────────┤
│ L2 Constructs (Intent-based) │
│ Sensible defaults: s3.Bucket, lambda.Function, │
│ rds.DatabaseCluster. Handle IAM, security groups, │
│ encryption automatically. │
├────────────────────────────────────────────────────────────┤
│ L1 Constructs (Cfn*) │
│ 1:1 CloudFormation mapping: CfnBucket, CfnFunction. │
│ Every property exposed. No defaults. Full control. │
└────────────────────────────────────────────────────────────┘
Rule of thumb: Start with L2 constructs. They handle 80% of cases with well-chosen defaults. Drop to L1 when you need a property that L2 doesn’t expose. Use L3 patterns for common architectures, but be ready to decompose them when your needs diverge from the pattern.
Stacks: The Deployment Units
A Stack is the unit of deployment. One stack = one CloudFormation stack. Each stack is deployed atomically — either all resources deploy successfully or none do.
When to create a new stack:
- Resources that have different lifecycles (networking changes rarely; Lambda code changes daily)
- Resources that need different IAM permissions to deploy
- Resources that need different rollback strategies
- Resources approaching the 500-resource CloudFormation limit
When NOT to split into separate stacks:
- Resources that reference each other frequently (this creates cross-stack references that are painful to manage)
- Resources that must be deployed atomically (e.g., a Lambda function and its API Gateway route)
Apps and Stages: The Composition Layer
// An App contains Stages. A Stage contains Stacks.
// This is how you model environments.
App
├── PipelineStack (CI/CD)
├── DevStage
│ ├── NetworkStack
│ ├── DatabaseStack
│ └── ComputeStack
├── StagingStage
│ ├── NetworkStack
│ ├── DatabaseStack
│ └── ComputeStack
└── ProductionStage
├── NetworkStack
├── DatabaseStack
└── ComputeStack
The Kids Learn CDK Project
Let’s build it. From scratch. Every file, every decision explained.
Project Initialization
mkdir kids-learn-aws && cd kids-learn-aws
cdk init app --language typescript
# The generated structure:
# ├── bin/
# │ └── kids-learn-aws.ts # Entry point
# ├── lib/
# │ └── kids-learn-aws-stack.ts # Default stack (we'll replace this)
# ├── test/
# │ └── kids-learn-aws.test.ts
# ├── cdk.json
# ├── jest.config.js
# ├── package.json
# └── tsconfig.json
Environment Configuration
Instead of hardcoding account IDs and regions in the code, we use configuration files:
// lib/config/environments.ts
export interface EnvironmentConfig {
account: string;
region: string;
envName: string;
// Network
vpcCidr: string;
maxAzs: number;
natGateways: number;
// Database
auroraMinCapacity: number;
auroraMaxCapacity: number;
// Compute
lambdaMemoryMB: number;
fargateDesiredCount: number;
fargateCpu: number;
fargateMemory: number;
// Feature flags
enableMultiAz: boolean;
enableDeletionProtection: boolean;
enableWaf: boolean;
}
export const environments: Record<string, EnvironmentConfig> = {
dev: {
account: process.env.CDK_DEV_ACCOUNT || '111111111111',
region: 'ap-southeast-1',
envName: 'dev',
vpcCidr: '10.0.0.0/16',
maxAzs: 2,
natGateways: 1, // Save costs in dev
auroraMinCapacity: 0.5,
auroraMaxCapacity: 4,
lambdaMemoryMB: 512,
fargateDesiredCount: 1,
fargateCpu: 256,
fargateMemory: 512,
enableMultiAz: false,
enableDeletionProtection: false,
enableWaf: false,
},
staging: {
account: process.env.CDK_STAGING_ACCOUNT || '222222222222',
region: 'ap-southeast-1',
envName: 'staging',
vpcCidr: '10.1.0.0/16',
maxAzs: 2,
natGateways: 1,
auroraMinCapacity: 0.5,
auroraMaxCapacity: 8,
lambdaMemoryMB: 512,
fargateDesiredCount: 1,
fargateCpu: 256,
fargateMemory: 512,
enableMultiAz: true,
enableDeletionProtection: false,
enableWaf: true,
},
production: {
account: process.env.CDK_PROD_ACCOUNT || '333333333333',
region: 'ap-southeast-1',
envName: 'production',
vpcCidr: '10.2.0.0/16',
maxAzs: 3,
natGateways: 2, // HA for NAT in prod
auroraMinCapacity: 2,
auroraMaxCapacity: 32,
lambdaMemoryMB: 1024,
fargateDesiredCount: 2,
fargateCpu: 512,
fargateMemory: 1024,
enableMultiAz: true,
enableDeletionProtection: true,
enableWaf: true,
},
};
The Network Stack
This is the foundation everything else depends on. Get this wrong and you’ll be refactoring under pressure later.
// lib/stacks/network-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import { Construct } from 'constructs';
import { EnvironmentConfig } from '../config/environments';
export interface NetworkStackProps extends cdk.StackProps {
config: EnvironmentConfig;
}
export class NetworkStack extends cdk.Stack {
public readonly vpc: ec2.Vpc;
public readonly bastionSecurityGroup: ec2.SecurityGroup;
constructor(scope: Construct, id: string, props: NetworkStackProps) {
super(scope, id, props);
const { config } = props;
// =========================================
// VPC with Public, Private, and Isolated subnets
// =========================================
this.vpc = new ec2.Vpc(this, 'KidsLearnVpc', {
ipAddresses: ec2.IpAddresses.cidr(config.vpcCidr),
maxAzs: config.maxAzs,
natGateways: config.natGateways,
subnetConfiguration: [
{
name: 'Public',
subnetType: ec2.SubnetType.PUBLIC,
cidrMask: 24,
mapPublicIpOnLaunch: false,
},
{
name: 'Private',
subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
cidrMask: 24,
},
{
name: 'Isolated',
subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
cidrMask: 24,
},
],
// Flow logs for security monitoring
flowLogs: {
'FlowLog': {
destination: ec2.FlowLogDestination.toCloudWatchLogs(),
trafficType: ec2.FlowLogTrafficType.REJECT,
},
},
});
// =========================================
// VPC Endpoints — Reduce NAT Gateway costs
// =========================================
// Gateway endpoints (free)
this.vpc.addGatewayEndpoint('S3Endpoint', {
service: ec2.GatewayVpcEndpointAwsService.S3,
});
this.vpc.addGatewayEndpoint('DynamoDBEndpoint', {
service: ec2.GatewayVpcEndpointAwsService.DYNAMODB,
});
// Interface endpoints (cost: ~$7/month each, but save on NAT data transfer)
const interfaceEndpoints = [
{ id: 'SecretsManager', service: ec2.InterfaceVpcEndpointAwsService.SECRETS_MANAGER },
{ id: 'CloudWatch', service: ec2.InterfaceVpcEndpointAwsService.CLOUDWATCH_MONITORING },
{ id: 'CloudWatchLogs', service: ec2.InterfaceVpcEndpointAwsService.CLOUDWATCH_LOGS },
{ id: 'ECR', service: ec2.InterfaceVpcEndpointAwsService.ECR },
{ id: 'ECRDocker', service: ec2.InterfaceVpcEndpointAwsService.ECR_DOCKER },
{ id: 'STS', service: ec2.InterfaceVpcEndpointAwsService.STS },
];
for (const endpoint of interfaceEndpoints) {
this.vpc.addInterfaceEndpoint(`${endpoint.id}Endpoint`, {
service: endpoint.service,
privateDnsEnabled: true,
subnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
});
}
// =========================================
// Bastion Host Security Group (for DB access)
// =========================================
this.bastionSecurityGroup = new ec2.SecurityGroup(this, 'BastionSG', {
vpc: this.vpc,
description: 'Security group for bastion host access',
allowAllOutbound: true,
});
// =========================================
// Tags
// =========================================
cdk.Tags.of(this).add('Project', 'KidsLearn');
cdk.Tags.of(this).add('Environment', config.envName);
cdk.Tags.of(this).add('Stack', 'Network');
// =========================================
// Outputs
// =========================================
new cdk.CfnOutput(this, 'VpcId', {
value: this.vpc.vpcId,
description: 'VPC ID',
exportName: `${config.envName}-VpcId`,
});
}
}
The Database Stack
Aurora Serverless v2 with pgvector is the heart of Kids Learn. Here’s the CDK definition:
// lib/stacks/database-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as rds from 'aws-cdk-lib/aws-rds';
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as elasticache from 'aws-cdk-lib/aws-elasticache';
import { Construct } from 'constructs';
import { EnvironmentConfig } from '../config/environments';
export interface DatabaseStackProps extends cdk.StackProps {
config: EnvironmentConfig;
vpc: ec2.Vpc;
}
export class DatabaseStack extends cdk.Stack {
public readonly auroraCluster: rds.DatabaseCluster;
public readonly dbSecret: secretsmanager.ISecret;
public readonly sessionEventsTable: dynamodb.Table;
public readonly redisCluster: elasticache.CfnReplicationGroup;
public readonly dbSecurityGroup: ec2.SecurityGroup;
public readonly redisSecurityGroup: ec2.SecurityGroup;
constructor(scope: Construct, id: string, props: DatabaseStackProps) {
super(scope, id, props);
const { config, vpc } = props;
// =========================================
// Security Groups
// =========================================
this.dbSecurityGroup = new ec2.SecurityGroup(this, 'AuroraSG', {
vpc,
description: 'Security group for Aurora PostgreSQL',
allowAllOutbound: false,
});
this.redisSecurityGroup = new ec2.SecurityGroup(this, 'RedisSG', {
vpc,
description: 'Security group for ElastiCache Redis',
allowAllOutbound: false,
});
// =========================================
// Aurora Serverless v2 (PostgreSQL 16 + pgvector)
// =========================================
this.auroraCluster = new rds.DatabaseCluster(this, 'KidsLearnDB', {
engine: rds.DatabaseClusterEngine.auroraPostgres({
version: rds.AuroraPostgresEngineVersion.VER_16_4,
}),
// Credentials stored in Secrets Manager with auto-rotation
credentials: rds.Credentials.fromGeneratedSecret('kidslearn_admin', {
secretName: `${config.envName}/kidslearn/db-credentials`,
}),
// Serverless v2 configuration
serverlessV2MinCapacity: config.auroraMinCapacity,
serverlessV2MaxCapacity: config.auroraMaxCapacity,
writer: rds.ClusterInstance.serverlessV2('Writer', {
publiclyAccessible: false,
}),
// Read replica in production
readers: config.enableMultiAz ? [
rds.ClusterInstance.serverlessV2('Reader', {
scaleWithWriter: true,
}),
] : [],
vpc,
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_ISOLATED },
securityGroups: [this.dbSecurityGroup],
// Database settings
defaultDatabaseName: 'kidslearn',
// Backup & protection
backup: {
retention: cdk.Duration.days(config.enableDeletionProtection ? 30 : 7),
},
deletionProtection: config.enableDeletionProtection,
removalPolicy: config.enableDeletionProtection
? cdk.RemovalPolicy.RETAIN
: cdk.RemovalPolicy.DESTROY,
// Encryption
storageEncrypted: true,
// Enhanced monitoring
monitoringInterval: cdk.Duration.seconds(60),
// Parameter group for pgvector
parameterGroup: new rds.ParameterGroup(this, 'AuroraParams', {
engine: rds.DatabaseClusterEngine.auroraPostgres({
version: rds.AuroraPostgresEngineVersion.VER_16_4,
}),
parameters: {
'shared_preload_libraries': 'pg_stat_statements,pgaudit',
'log_statement': 'ddl',
'log_min_duration_statement': '1000', // Log queries > 1s
},
}),
});
this.dbSecret = this.auroraCluster.secret!;
// =========================================
// RDS Proxy (Connection pooling for Lambda)
// =========================================
const rdsProxy = this.auroraCluster.addProxy('KidsLearnProxy', {
secrets: [this.dbSecret],
vpc,
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
securityGroups: [this.dbSecurityGroup],
requireTLS: true,
maxConnectionsPercent: 90,
maxIdleConnectionsPercent: 50,
});
// =========================================
// DynamoDB — Session Events
// =========================================
this.sessionEventsTable = new dynamodb.Table(this, 'SessionEvents', {
tableName: `${config.envName}-kidslearn-session-events`,
partitionKey: { name: 'childId', type: dynamodb.AttributeType.STRING },
sortKey: { name: 'timestamp', type: dynamodb.AttributeType.STRING },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
// Enable TTL for auto-cleanup of old events
timeToLiveAttribute: 'ttl',
// Point-in-time recovery
pointInTimeRecovery: true,
// Encryption
encryption: dynamodb.TableEncryption.AWS_MANAGED,
removalPolicy: config.enableDeletionProtection
? cdk.RemovalPolicy.RETAIN
: cdk.RemovalPolicy.DESTROY,
});
// GSI for querying by lesson
this.sessionEventsTable.addGlobalSecondaryIndex({
indexName: 'LessonIndex',
partitionKey: { name: 'lessonId', type: dynamodb.AttributeType.STRING },
sortKey: { name: 'timestamp', type: dynamodb.AttributeType.STRING },
projectionType: dynamodb.ProjectionType.ALL,
});
// GSI for querying by event type
this.sessionEventsTable.addGlobalSecondaryIndex({
indexName: 'EventTypeIndex',
partitionKey: { name: 'eventType', type: dynamodb.AttributeType.STRING },
sortKey: { name: 'timestamp', type: dynamodb.AttributeType.STRING },
projectionType: dynamodb.ProjectionType.KEYS_ONLY,
});
// =========================================
// ElastiCache Redis
// =========================================
const redisSubnetGroup = new elasticache.CfnSubnetGroup(this, 'RedisSubnetGroup', {
description: 'Subnet group for ElastiCache Redis',
subnetIds: vpc.selectSubnets({
subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS
}).subnetIds,
cacheSubnetGroupName: `${config.envName}-kidslearn-redis`,
});
this.redisCluster = new elasticache.CfnReplicationGroup(this, 'RedisCluster', {
replicationGroupDescription: 'Kids Learn Redis cache',
engine: 'redis',
engineVersion: '7.1',
cacheNodeType: config.envName === 'production'
? 'cache.r7g.large'
: 'cache.t4g.micro',
numNodeGroups: 1,
replicasPerNodeGroup: config.enableMultiAz ? 1 : 0,
automaticFailoverEnabled: config.enableMultiAz,
multiAzEnabled: config.enableMultiAz,
cacheSubnetGroupName: redisSubnetGroup.cacheSubnetGroupName,
securityGroupIds: [this.redisSecurityGroup.securityGroupId],
atRestEncryptionEnabled: true,
transitEncryptionEnabled: true,
snapshotRetentionLimit: config.enableDeletionProtection ? 7 : 1,
});
this.redisCluster.addDependency(redisSubnetGroup);
// =========================================
// Tags
// =========================================
cdk.Tags.of(this).add('Project', 'KidsLearn');
cdk.Tags.of(this).add('Environment', config.envName);
cdk.Tags.of(this).add('Stack', 'Database');
}
}
Custom Constructs — Reusable Building Blocks
This is where CDK shines compared to other IaC tools. We create reusable constructs that enforce our standards:
// lib/constructs/secure-lambda.ts
import * as cdk from 'aws-cdk-lib';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as logs from 'aws-cdk-lib/aws-logs';
import { Construct } from 'constructs';
export interface SecureLambdaProps {
functionName: string;
description: string;
handler: string;
codePath: string;
memorySize?: number;
timeout?: cdk.Duration;
vpc: ec2.IVpc;
environment?: Record<string, string>;
reservedConcurrentExecutions?: number;
}
/**
* A Lambda function with Kids Learn security standards baked in:
* - Runs in VPC private subnets
* - X-Ray tracing enabled
* - Structured logging
* - Least-privilege IAM role
* - Log retention policy
*/
export class SecureLambda extends Construct {
public readonly function: lambda.Function;
constructor(scope: Construct, id: string, props: SecureLambdaProps) {
super(scope, id);
this.function = new lambda.Function(this, 'Function', {
functionName: props.functionName,
description: props.description,
runtime: lambda.Runtime.NODEJS_20_X,
handler: props.handler,
code: lambda.Code.fromAsset(props.codePath),
memorySize: props.memorySize || 512,
timeout: props.timeout || cdk.Duration.seconds(30),
// Security: run in VPC private subnets
vpc: props.vpc,
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
// Observability
tracing: lambda.Tracing.ACTIVE, // X-Ray
insightsVersion: lambda.LambdaInsightsVersion.VERSION_1_0_229_0,
// Log retention (don't keep logs forever)
logRetention: logs.RetentionDays.ONE_MONTH,
// Environment variables
environment: {
NODE_ENV: 'production',
LOG_LEVEL: 'info',
POWERTOOLS_SERVICE_NAME: props.functionName,
...props.environment,
},
// Reserved concurrency (prevent runaway costs)
reservedConcurrentExecutions: props.reservedConcurrentExecutions,
// Architecture
architecture: lambda.Architecture.ARM_64, // Graviton — cheaper + faster
});
// Remove overly permissive policies
// CDK adds some by default; we want explicit control
this.function.role?.addManagedPolicy(
iam.ManagedPolicy.fromAwsManagedPolicyName(
'service-role/AWSLambdaVPCAccessExecutionRole'
)
);
}
/**
* Grant read access to a Secrets Manager secret
*/
grantSecretRead(secret: cdk.aws_secretsmanager.ISecret): void {
secret.grantRead(this.function);
}
/**
* Grant read/write access to a DynamoDB table
*/
grantTableReadWrite(table: cdk.aws_dynamodb.Table): void {
table.grantReadWriteData(this.function);
}
}
Composing Stacks into Stages
// lib/stages/application-stage.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { EnvironmentConfig } from '../config/environments';
import { NetworkStack } from '../stacks/network-stack';
import { DatabaseStack } from '../stacks/database-stack';
import { ComputeStack } from '../stacks/compute-stack';
import { FrontendStack } from '../stacks/frontend-stack';
import { SecurityStack } from '../stacks/security-stack';
import { MonitoringStack } from '../stacks/monitoring-stack';
export interface ApplicationStageProps extends cdk.StageProps {
config: EnvironmentConfig;
}
export class ApplicationStage extends cdk.Stage {
constructor(scope: Construct, id: string, props: ApplicationStageProps) {
super(scope, id, props);
const { config } = props;
// Stack 1: Network (foundation — rarely changes)
const network = new NetworkStack(this, 'Network', {
config,
env: { account: config.account, region: config.region },
});
// Stack 2: Security (Cognito, WAF — changes occasionally)
const security = new SecurityStack(this, 'Security', {
config,
vpc: network.vpc,
env: { account: config.account, region: config.region },
});
// Stack 3: Database (Aurora, DynamoDB, Redis — changes rarely)
const database = new DatabaseStack(this, 'Database', {
config,
vpc: network.vpc,
env: { account: config.account, region: config.region },
});
// Stack 4: Compute (Lambda, Fargate — changes frequently)
const compute = new ComputeStack(this, 'Compute', {
config,
vpc: network.vpc,
database,
security,
env: { account: config.account, region: config.region },
});
// Stack 5: Frontend (Amplify, CloudFront — changes with deploys)
const frontend = new FrontendStack(this, 'Frontend', {
config,
env: { account: config.account, region: config.region },
});
// Stack 6: Monitoring (CloudWatch, alarms — changes occasionally)
const monitoring = new MonitoringStack(this, 'Monitoring', {
config,
compute,
database,
env: { account: config.account, region: config.region },
});
// Dependencies
database.addDependency(network);
security.addDependency(network);
compute.addDependency(database);
compute.addDependency(security);
monitoring.addDependency(compute);
}
}
Testing Your Infrastructure
This is the capability that makes CDK irresponsible to ignore. You can write unit tests for your infrastructure.
Snapshot Tests
// test/stacks/network-stack.test.ts
import * as cdk from 'aws-cdk-lib';
import { Template } from 'aws-cdk-lib/assertions';
import { NetworkStack } from '../../lib/stacks/network-stack';
import { environments } from '../../lib/config/environments';
describe('NetworkStack', () => {
const app = new cdk.App();
const stack = new NetworkStack(app, 'TestNetwork', {
config: environments.dev,
env: { account: '111111111111', region: 'ap-southeast-1' },
});
const template = Template.fromStack(stack);
test('creates a VPC with correct CIDR', () => {
template.hasResourceProperties('AWS::EC2::VPC', {
CidrBlock: '10.0.0.0/16',
});
});
test('creates public, private, and isolated subnets', () => {
// 2 AZs × 3 subnet types = 6 subnets
template.resourceCountIs('AWS::EC2::Subnet', 6);
});
test('creates NAT gateway for private subnet internet access', () => {
template.resourceCountIs('AWS::EC2::NatGateway', 1);
});
test('creates S3 and DynamoDB gateway endpoints', () => {
template.resourceCountIs('AWS::EC2::VPCEndpoint', 8); // 2 gateway + 6 interface
});
test('enables VPC flow logs', () => {
template.hasResourceProperties('AWS::EC2::FlowLog', {
TrafficType: 'REJECT',
});
});
});
Assertion Tests for Security Compliance
// test/stacks/database-stack.test.ts
import * as cdk from 'aws-cdk-lib';
import { Template, Match } from 'aws-cdk-lib/assertions';
import { NetworkStack } from '../../lib/stacks/network-stack';
import { DatabaseStack } from '../../lib/stacks/database-stack';
import { environments } from '../../lib/config/environments';
describe('DatabaseStack - Production', () => {
const app = new cdk.App();
const network = new NetworkStack(app, 'TestNetwork', {
config: environments.production,
env: { account: '333333333333', region: 'ap-southeast-1' },
});
const stack = new DatabaseStack(app, 'TestDatabase', {
config: environments.production,
vpc: network.vpc,
env: { account: '333333333333', region: 'ap-southeast-1' },
});
const template = Template.fromStack(stack);
test('Aurora cluster has deletion protection in production', () => {
template.hasResourceProperties('AWS::RDS::DBCluster', {
DeletionProtection: true,
});
});
test('Aurora storage is encrypted', () => {
template.hasResourceProperties('AWS::RDS::DBCluster', {
StorageEncrypted: true,
});
});
test('Aurora is not publicly accessible', () => {
template.hasResourceProperties('AWS::RDS::DBInstance', {
PubliclyAccessible: false,
});
});
test('DynamoDB has point-in-time recovery enabled', () => {
template.hasResourceProperties('AWS::DynamoDB::Table', {
PointInTimeRecoverySpecification: {
PointInTimeRecoveryEnabled: true,
},
});
});
test('Redis has encryption at rest and in transit', () => {
template.hasResourceProperties('AWS::ElastiCache::ReplicationGroup', {
AtRestEncryptionEnabled: true,
TransitEncryptionEnabled: true,
});
});
});
Running Tests
# Run all infrastructure tests
npx jest
# Run with coverage
npx jest --coverage
# Run a specific test file
npx jest test/stacks/database-stack.test.ts
Deploying — From Dev to Production
First Deployment (Bootstrap)
Before deploying to any account, you must bootstrap the CDK toolkit:
# Bootstrap dev account
cdk bootstrap aws://111111111111/ap-southeast-1 \
--profile kidslearn-dev \
--cloudformation-execution-policies arn:aws:iam::aws:policy/AdministratorAccess
# Bootstrap staging account (trusting dev account for cross-account deploys)
cdk bootstrap aws://222222222222/ap-southeast-1 \
--profile kidslearn-staging \
--trust 111111111111 \
--cloudformation-execution-policies arn:aws:iam::aws:policy/AdministratorAccess
# Bootstrap production account
cdk bootstrap aws://333333333333/ap-southeast-1 \
--profile kidslearn-prod \
--trust 111111111111 \
--cloudformation-execution-policies arn:aws:iam::aws:policy/AdministratorAccess
Manual Deployment (Dev)
# Preview what will change
cdk diff --context env=dev
# Deploy all stacks in dev
cdk deploy --all --context env=dev --profile kidslearn-dev
# Deploy a specific stack
cdk deploy KidsLearn-Dev-Network --context env=dev --profile kidslearn-dev
CDK Pipelines (Automated CI/CD)
In Part 7, we’ll build the full CI/CD pipeline. Here’s a preview:
// lib/stacks/pipeline-stack.ts
import * as cdk from 'aws-cdk-lib';
import { CodePipeline, CodePipelineSource, ShellStep } from 'aws-cdk-lib/pipelines';
import { Construct } from 'constructs';
import { ApplicationStage } from '../stages/application-stage';
import { environments } from '../config/environments';
export class PipelineStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const pipeline = new CodePipeline(this, 'Pipeline', {
pipelineName: 'KidsLearn-Infrastructure',
synth: new ShellStep('Synth', {
input: CodePipelineSource.gitHub('kidslearn/infrastructure', 'main', {
authentication: cdk.SecretValue.secretsManager('github-token'),
}),
commands: [
'npm ci',
'npm run build',
'npx cdk synth',
],
}),
});
// Dev stage — deploys automatically on push
pipeline.addStage(new ApplicationStage(this, 'Dev', {
config: environments.dev,
env: { account: environments.dev.account, region: environments.dev.region },
}));
// Staging stage — deploys after dev succeeds
const staging = pipeline.addStage(new ApplicationStage(this, 'Staging', {
config: environments.staging,
env: { account: environments.staging.account, region: environments.staging.region },
}));
// Add integration tests after staging deployment
staging.addPost(new ShellStep('Integration Tests', {
commands: [
'npm run test:integration',
],
}));
// Production stage — requires manual approval
pipeline.addStage(new ApplicationStage(this, 'Production', {
config: environments.production,
env: { account: environments.production.account, region: environments.production.region },
}), {
pre: [
new cdk.pipelines.ManualApprovalStep('PromoteToProduction', {
comment: 'Review staging deployment before promoting to production',
}),
],
});
}
}
CDK Best Practices — Lessons Learned
After deploying Kids Learn and several other production services with CDK, here are the practices I enforce on my team:
1. Never Use cdk deploy in Production
All production deployments go through the CI/CD pipeline. No exceptions. cdk deploy is for development only.
2. Pin Your CDK Version
// package.json
{
"dependencies": {
"aws-cdk-lib": "2.170.0",
"constructs": "10.4.2"
}
}
Don’t use ^ or ~ ranges. CDK updates can change default behaviors. Pin versions and update deliberately.
3. Use cdk diff Before Every Deploy
cdk diff --all --context env=staging
Read the diff. Understand every change. Especially watch for resource replacements — that’s CDK telling you it will delete and recreate a resource. For stateful resources (databases, S3 buckets), replacement means data loss.
4. Tag Everything
// Apply at the app level — every resource gets these tags
cdk.Tags.of(app).add('Project', 'KidsLearn');
cdk.Tags.of(app).add('ManagedBy', 'CDK');
cdk.Tags.of(app).add('Repository', 'kidslearn/infrastructure');
Tags enable cost allocation, operational visibility, and automated governance. No resource should exist without knowing what project it belongs to.
5. Use CDK Aspects for Cross-Cutting Concerns
// lib/aspects/compliance-aspect.ts
import * as cdk from 'aws-cdk-lib';
import * as s3 from 'aws-cdk-lib/aws-s3';
import { IConstruct } from 'constructs';
/**
* Ensures all S3 buckets follow our security standards
*/
export class S3ComplianceAspect implements cdk.IAspect {
public visit(node: IConstruct): void {
if (node instanceof s3.CfnBucket) {
// Ensure encryption is enabled
if (!node.bucketEncryption) {
cdk.Annotations.of(node).addError(
'S3 bucket must have encryption enabled'
);
}
// Ensure versioning is enabled
if (!node.versioningConfiguration) {
cdk.Annotations.of(node).addWarning(
'S3 bucket should have versioning enabled'
);
}
// Ensure public access is blocked
if (!node.publicAccessBlockConfiguration) {
cdk.Annotations.of(node).addError(
'S3 bucket must block public access'
);
}
}
}
}
The Bottom Line
CDK transforms infrastructure from a manual, error-prone process into a disciplined engineering practice. Every VPC, every security group, every database configuration is code. Code that’s tested, reviewed, and versioned.
The investment in learning CDK pays for itself the first time you need to:
- Create a staging environment identical to production (one command)
- Roll back an infrastructure change that broke something (git revert + deploy)
- Audit what changed and who changed it (git log)
- Onboard a new team member who can read the infrastructure in TypeScript
In Part 3, we’ll use these CDK patterns to deploy our Next.js frontend with Amplify, S3, and CloudFront — complete with custom domains, SSL certificates, and cache invalidation.
See you in Part 3.
This is Part 2 of a 10-part series: AWS Full-Stack Mastery for Technical Leads.
Series outline:
- Why AWS & Getting Started — Decision framework, Well-Architected, account setup, cost analysis (Part 1)
- Infrastructure as Code (CDK) — CDK v2, constructs, multi-stack, VPC design (this post)
- Frontend (Amplify + CloudFront) — Next.js SSR, S3, CDN, custom domains (Part 3)
- Backend (API Gateway + Lambda + Fargate) — REST APIs, serverless compute, containers (Part 4)
- Database (Aurora + DynamoDB + ElastiCache) — PostgreSQL, NoSQL, caching (Part 5)
- AI/ML (Bedrock + SageMaker) — Content generation, custom models, RAG (Part 6)
- DevOps (CodePipeline + CodeBuild) — CI/CD, Docker, blue/green deploys (Part 7)
- Security (IAM + Cognito + WAF) — Least privilege, auth, COPPA compliance (Part 8)
- Observability (CloudWatch + X-Ray) — Metrics, tracing, cost management (Part 9)
- Production (Multi-Region + DR) — Scaling, disaster recovery, load testing (Part 10)
References
- AWS CDK v2 Developer Guide — Comprehensive documentation for CDK v2 with TypeScript.
- AWS CDK Best Practices — Official guide covering project structure, testing, and deployment patterns.
- CDK Patterns — Community-maintained collection of reusable CDK patterns.
- AWS CDK API Reference — Full API documentation for all CDK constructs.
- CDK Pipelines Developer Guide — CI/CD pipeline as code with CDK.
- AWS VPC Documentation — Networking fundamentals for VPC design.
- Aurora Serverless v2 CDK Constructs — RDS module documentation including Aurora support.
- CloudFormation Resource Limits — Stack resource limits that influence stack splitting decisions.
- AWS CDK Testing — Guide to writing unit tests for CDK constructs.
- Construct Hub — Registry of reusable CDK constructs from AWS and the community.