Skip to content
7 min read·Lesson 3 of 8

AWS CDK: Apps, Stacks, and Constructs

Creating your first CDK application — the App / Stack / Construct hierarchy, bootstrapping, and the core deployment workflow.

The AWS CDK turns TypeScript code into CloudFormation templates. Understanding the three-tier hierarchy — App, Stack, Construct — unlocks everything else.

The Hierarchy

  • App — the root node; a single CDK application. Contains one or more Stacks.
  • Stack — maps 1:1 to a CloudFormation stack; the unit of deployment.
  • Construct — everything inside a stack; can be a single resource or a nested tree.
// bin/app.ts — entry point
import * as cdk from 'aws-cdk-lib'
import { WebStack } from '../lib/web-stack'

const app = new cdk.App()

new WebStack(app, 'WebStack-Dev', {
  env: { account: '123456789012', region: 'us-east-1' },
  stage: 'dev',
})

new WebStack(app, 'WebStack-Prod', {
  env: { account: '234567890123', region: 'eu-west-1' },
  stage: 'prod',
})

A First Stack

// lib/web-stack.ts
import * as cdk from 'aws-cdk-lib'
import * as s3 from 'aws-cdk-lib/aws-s3'
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront'
import * as origins from 'aws-cdk-lib/aws-cloudfront-origins'
import { Construct } from 'constructs'

interface WebStackProps extends cdk.StackProps {
  stage: 'dev' | 'staging' | 'prod'
}

export class WebStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: WebStackProps) {
    super(scope, id, props)

    const bucket = new s3.Bucket(this, 'StaticAssets', {
      versioned: props.stage === 'prod',
      removalPolicy:
        props.stage === 'prod' ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY,
      autoDeleteObjects: props.stage !== 'prod',
    })

    const distribution = new cloudfront.Distribution(this, 'CDN', {
      defaultBehavior: {
        origin: origins.S3BucketOrigin.withOriginAccessControl(bucket),
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
      },
      httpVersion: cloudfront.HttpVersion.HTTP2_AND_3,
    })

    new cdk.CfnOutput(this, 'BucketName', { value: bucket.bucketName })
    new cdk.CfnOutput(this, 'DistributionUrl', {
      value: `https://${distribution.distributionDomainName}`,
    })
  }
}

Project Structure

cdk init app --language typescript scaffolds the standard layout:

my-infra/
├── bin/
│   └── app.ts               # App entry point
├── lib/
│   ├── web-stack.ts          # Stack definitions
│   └── constructs/           # Custom reusable constructs
├── test/
│   └── web-stack.test.ts     # Jest tests
├── cdk.json                  # CDK configuration
├── package.json
└── tsconfig.json

cdk.json

{
  "app": "npx ts-node --prefer-ts-exts bin/app.ts",
  "watch": {
    "include": ["**"],
    "exclude": ["README.md", "cdk*.json", "**/*.d.ts", "**/*.js", "node_modules"]
  },
  "context": {
    "@aws-cdk/aws-lambda:recognizeLayerVersion": true,
    "@aws-cdk/core:stackRelativeExports": "true"
  }
}

The context section holds CDK feature flags. Modern CDK initialised projects have @aws-cdk/core:newStyleStackSynthesis enabled by default — required for CDK Pipelines.

Bootstrapping

CDK needs a one-time per-account, per-region setup step: it creates an S3 bucket for staging assets (Lambda code, Docker images) and IAM roles the deployment uses:

cdk bootstrap aws://123456789012/us-east-1 --profile my-profile

For multi-account deployments (dev → staging → prod pipeline), bootstrap each account. The CDKToolkit stack is what gets deployed.

Daily CDK Workflow

# List all stacks in the app
cdk ls

# Synthesise to CloudFormation (no deploy)
cdk synth

# See what would change
cdk diff

# Deploy
cdk deploy
cdk deploy WebStack-Dev
cdk deploy --all

# Destroy
cdk destroy WebStack-Dev

# Watch mode (hot-reload-like for Lambdas)
cdk watch

cdk diff before every deploy is good discipline — it shows IAM changes, resource replacements, and security group modifications that might surprise you.

Tokens and CloudFormation References

CDK properties like bucket.bucketName are not resolved at synth time — they become CloudFormation references (!Ref, !GetAtt). They are Tokens that look like strings but carry a reference:

const bucket = new s3.Bucket(this, 'B')
bucket.bucketName              // Token<string> looks like "${Token[S3Bucket.BucketName]}"

// When you use it in another resource, CDK knows to emit a Ref or GetAtt
new ssm.StringParameter(this, 'P', {
  parameterName: '/config/bucket',
  stringValue: bucket.bucketName,    // → CloudFormation Ref
})

This is why you cannot console.log(bucket.bucketName) and expect a real name before deploy — it's a token. There is no way to resolve it locally without an actual deploy.

Cross-Stack References

// Stack A defines and exports
export class NetworkStack extends cdk.Stack {
  public readonly vpc: ec2.Vpc
  constructor(scope: Construct, id: string, props: cdk.StackProps) {
    super(scope, id, props)
    this.vpc = new ec2.Vpc(this, 'Vpc', { maxAzs: 3 })
  }
}

// Stack B imports
export class AppStack extends cdk.Stack {
  constructor(scope: Construct, id: string, vpc: ec2.Vpc, props: cdk.StackProps) {
    super(scope, id, props)
    new ecs.Cluster(this, 'Cluster', { vpc })
  }
}

// bin/app.ts wires them
const net = new NetworkStack(app, 'Network', { env })
new AppStack(app, 'App', net.vpc, { env })

CDK emits CloudFormation Exports and Imports automatically. Be aware: cross-stack references create deployment order dependencies, and removing them requires two deployments (first stop using, then remove the export).

Context and Environment Variables

// cdk.json "context":
// { "instanceType": "t3.micro" }

// In stack:
const instanceType = this.node.tryGetContext('instanceType') ?? 't3.medium'

// Runtime CDK environment variables
// CDK_DEFAULT_ACCOUNT, CDK_DEFAULT_REGION

Pass context at deploy time: cdk deploy --context stage=prod.

What Gets Synthesised

cdk synth writes to the cdk.out/ directory:

  • One JSON CloudFormation template per stack
  • Lambda asset bundles (zip), Docker asset manifests
  • The manifest.json describing all assets and their targets

You can inspect cdk.out/WebStack-Dev.template.json to see exactly what will be deployed. This is also what CI validates.

Dependencies

npm install aws-cdk-lib constructs

Just two packages. aws-cdk-lib is the monopackage (since CDK v2) that contains all service libraries. CDK v1 had hundreds of separate @aws-cdk/aws-* packages — avoid if you see legacy code.

One More Concept: Aspects

Aspects traverse the entire construct tree and can modify or validate every resource. Common uses:

  • Tag every resource with your org tags
  • Enforce encryption-at-rest on all buckets
  • Add termination protection to all stacks in prod
class ApplyOrgTags implements cdk.IAspect {
  visit(node: IConstruct): void {
    if (cdk.CfnResource.isCfnResource(node)) {
      cdk.Tags.of(node).add('cost-center', 'cc-1234')
      cdk.Tags.of(node).add('managed-by', 'cdk')
    }
  }
}

cdk.Aspects.of(app).add(new ApplyOrgTags())

With App / Stack / Construct, bootstrapping, and the daily CLI workflow internalised, you can build real infrastructure. The next lesson focuses on construct patterns that make that infrastructure reusable and maintainable.

Key Takeaways

  • A CDK App contains Stacks; a Stack contains Constructs; Constructs represent AWS resources.
  • cdk synth produces CloudFormation; cdk deploy sends it to AWS.
  • Bootstrapping (cdk bootstrap) creates the supporting S3 bucket and IAM roles needed for deployment.
  • StackProps carries environment (account + region); always supply it explicitly.
  • The CDK CLI is cdk — watch, diff, destroy, ls are daily commands.

Test your knowledge

Try exam-style practice questions to reinforce what you've learned.

Practice Questions →