Session 006 — CDK: Stacks, environments and multi-account patterns
Estimated duration: 60 minutes
Prerequisites: session-005 — CDK: Constructs L1, L2, L3
Objective
By the end, you will be able to create multiple stacks in a CDK App with distinct environments (dev/staging/prod in different accounts), use Stack.account and Stack.region without hardcoding, and understand when to use one stack per account vs nested stacks.
Context
[CONSENSUS] The multi-account structure is the recommended pattern by AWS for mature organizations — one account per environment (dev, staging, prod), ideally under an AWS Organization. CDK has native support for this model via environments and Stages. Understanding how CDK resolves account and region is critical to avoid two opposite mistakes: hardcoding values that break in other accounts, and "environment-agnostic" stacks that lose important capabilities.
[FACT] In CDK, an environment is simply the pair { account: string, region: string }. It is the deploy target of a stack. A stack without an explicit environment is called "environment-agnostic" and has important limitations.
Key concepts
1. Environments — account + region without hardcoding
Every stack can (and in production, should) have an associated environment:
// bin/meu-app.ts
const app = new cdk.App();
// ❌ Hardcode — não faça isso
new MeuStack(app, 'MeuStack', {
env: { account: '123456789012', region: 'us-east-1' },
});
// ✅ Environment dinâmico — usa o perfil/credenciais ativas no momento do synth
new MeuStack(app, 'MeuStack', {
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION,
},
});
// ✅ Multi-conta explícita — o mais correto para pipelines
new MeuStack(app, 'DevStack', {
env: { account: '111122223333', region: 'us-east-1' },
});
new MeuStack(app, 'ProdStack', {
env: { account: '444455556666', region: 'us-east-1' },
});
CDK_DEFAULT_ACCOUNT vs CDK_DEFAULT_REGION:
[FACT] The CDK CLI automatically populates CDK_DEFAULT_ACCOUNT and CDK_DEFAULT_REGION based on the active AWS credentials (SSO profile, environment variables, instance profile — the same chain as the CLI). If you use --profile prod, CDK resolves these variables to the account associated with the prod profile.
# Deploy em dev com perfil dev
cdk deploy --profile dev
# Deploy em prod com perfil prod
cdk deploy --profile prod
# O mesmo código, environments diferentes — sem nenhuma mudança no código
2. Environment-agnostic stacks — when and why to avoid
A stack without env is synthesized without a specific account/region. The resulting template uses CloudFormation pseudo-parameters (AWS::AccountId, AWS::Region).
// Stack sem environment — environment-agnostic
new MeuStack(app, 'MeuStack');
// Gera: { "Account": { "Ref": "AWS::AccountId" } } no template
What you lose with environment-agnostic:
❌ Context lookups não funcionam
ec2.Vpc.fromLookup(...) → falha: precisa saber a conta/região para consultar
HostedZone.fromLookup(...) → idem
❌ Service Principals podem estar errados
Em regiões opt-in (ex: ap-east-1) o formato do service principal é diferente
O CDK resolve isso automaticamente quando o environment está fixado
❌ Algumas validações do CDK são desabilitadas
Verificações que dependem de dados da conta (limites, AMIs, etc.)
✅ O que ainda funciona
Deploy em qualquer conta/região via cdk deploy --profile X
Ideal para constructs reutilizáveis publicados como bibliotecas
[FACT] For application stacks in production, always pin the environment. For reusable constructs published as NPM packages, keep them environment-agnostic — whoever instantiates them defines the environment.
3. Stack.account and Stack.region — references without hardcoding inside the stack
Inside a stack or construct, you access account and region via this.account and this.region (or Stack.of(this).account):
export class MeuStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: cdk.StackProps) {
super(scope, id, props);
// ✅ Resolve em synth time se o environment está fixado
const bucketName = `meu-app-${this.account}-${this.region}`;
// ✅ Usando Sub para montar ARNs sem hardcode
const paramPath = `/meu-app/${this.stackName}/config`;
// ✅ Stack.of() — acessa o stack a partir de qualquer construct filho
const account = cdk.Stack.of(this).account;
const region = cdk.Stack.of(this).region;
// ✅ Tokens para valores resolvidos em deploy time (não em synth)
// Quando o environment NÃO está fixado, this.account retorna um Token
// (string como "${Token[AWS.AccountId.0]}") — não dá para usar em lógica condicional
const isResolved = !cdk.Token.isUnresolved(this.account);
}
}
Token vs concrete value — frequent pitfall:
// ❌ Não funciona quando environment-agnostic
// this.account retorna um Token, não '123456789012'
if (this.account === '123456789012') { // sempre false com Token
// lógica específica de conta
}
// ✅ Use props customizadas para lógica condicional
interface MeuStackProps extends cdk.StackProps {
isProd: boolean;
}
export class MeuStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: MeuStackProps) {
super(scope, id, props);
const removalPolicy = props.isProd
? cdk.RemovalPolicy.RETAIN
: cdk.RemovalPolicy.DESTROY;
}
}
4. Stage — grouping stacks for multi-environment
Stage is the CDK concept for grouping stacks that represent the same system in a specific environment. It is the promotion unit in pipelines (dev → staging → prod).
// lib/application-stage.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { InfraStack } from './infra-stack';
import { AppStack } from './app-stack';
export class ApplicationStage extends cdk.Stage {
constructor(scope: Construct, id: string, props: cdk.StageProps) {
super(scope, id, props);
// Todos os stacks deste sistema, juntos
const infra = new InfraStack(this, 'Infra');
const app = new AppStack(this, 'App', {
vpc: infra.vpc,
cluster: infra.cluster,
});
}
}
// bin/meu-app.ts
const app = new cdk.App();
// Stage de dev — conta de desenvolvimento
new ApplicationStage(app, 'Dev', {
env: { account: '111122223333', region: 'us-east-1' },
});
// Stage de staging — conta separada
new ApplicationStage(app, 'Staging', {
env: { account: '222233334444', region: 'us-east-1' },
});
// Stage de prod — conta de produção
new ApplicationStage(app, 'Prod', {
env: { account: '333344445555', region: 'us-east-1' },
});
What the Stage generates:
cdk ls
Dev/Infra → stack DevInfra na conta 111122223333
Dev/App → stack DevApp na conta 111122223333
Staging/Infra → stack StagingInfra na conta 222233334444
Staging/App → stack StagingApp na conta 222233334444
Prod/Infra → stack ProdInfra na conta 333344445555
Prod/App → stack ProdApp na conta 333344445555
Deploying a complete Stage:
# Deploya todos os stacks do stage Dev
cdk deploy "Dev/*" --profile dev
# Deploya apenas um stack específico do stage
cdk deploy Dev/Infra --profile dev
# Diff de todo o stage Prod
cdk diff "Prod/*" --profile prod
5. When to use stack per account vs nested stacks
This is an architecture decision with real trade-offs.
Stack per account (recommended pattern):
App
├── Dev/
│ ├── InfraStack → VPC, subnets, SGs
│ └── AppStack → ECS, Lambda, RDS
├── Staging/
│ ├── InfraStack
│ └── AppStack
└── Prod/
├── InfraStack
└── AppStack
Advantages:
- Real isolation between environments (separate accounts = IAM boundaries, billing, CloudTrail)
- Failures in dev don't affect prod
- Model aligned with AWS Organizations and SCPs
Multiple stacks in the same account (by lifecycle):
App (mesma conta dev)
├── NetworkStack → VPC, subnets (muda raramente)
├── DataStack → RDS, DynamoDB (muda ocasionalmente)
└── AppStack → Lambda, ECS, API GW (muda frequentemente)
Advantages:
- Partial deploy — only the AppStack changes day to day
- Reduces risk: a change in app code doesn't touch network infrastructure
- CloudFormation has a 500 resource limit per stack — splitting solves this
[CONSENSUS] The main criterion for splitting into stacks is not size — it's lifecycle and ownership. Resources that change together belong in the same stack. Resources with different owners belong in different stacks. Resources that should never be affected by an application deploy belong in a separate stack.
Stack per account vs nested stacks:
[FACT] Nested stacks (via NestedStack) are CloudFormation child stacks of another stack. They are useful for overcoming the 500 resource limit, but create coupling — the parent stack is responsible for the lifecycle of its children. In CDK, the recommended alternative is to use multiple independent stacks (with cross-stack references) instead of nested stacks.
// ❌ Nested stack — evite salvo para superar limite de recursos
import { NestedStack } from 'aws-cdk-lib';
class MinhaNestedStack extends NestedStack { ... }
// ✅ Stacks independentes com referência cross-stack
class InfraStack extends cdk.Stack {
public readonly vpc: ec2.Vpc; // expõe o recurso
constructor(...) {
this.vpc = new ec2.Vpc(this, 'Vpc');
}
}
class AppStack extends cdk.Stack {
constructor(scope, id, props: { vpc: ec2.Vpc } & cdk.StackProps) {
// consome o recurso de outro stack
new ecs.Cluster(this, 'Cluster', { vpc: props.vpc });
}
}
6. Cross-stack references in CDK
When a stack references a resource from another stack in CDK, the framework automatically creates a CloudFormation Export/Import under the hood — but you don't need to manage this manually.
// bin/meu-app.ts
const app = new cdk.App();
const infra = new InfraStack(app, 'InfraStack', {
env: { account: '111122223333', region: 'us-east-1' },
});
// Passa o objeto diretamente — o CDK gerencia o Export/Import automaticamente
const appStack = new AppStack(app, 'AppStack', {
env: { account: '111122223333', region: 'us-east-1' },
vpc: infra.vpc, // referência direta ao objeto
cluster: infra.cluster,
});
What CDK generates under the hood:
InfraStack Outputs:
ExportsOutputRefVpcXXXXXX: vpc-0abc123 (Export: InfraStack:ExportsOutputRefVpcXXXXXX)
AppStack Resources:
Cluster:
Properties:
VpcId: !ImportValue InfraStack:ExportsOutputRefVpcXXXXXX
[FACT] Cross-stack references in CDK only work within the same account and region. To reference resources cross-account, you need to use SSM Parameter Store, Secrets Manager, or pass values as concrete props (string/ARN) instead of CDK objects.
Cross-account — correct pattern:
// InfraStack (conta A) — exporta via SSM
const vpc = new ec2.Vpc(this, 'Vpc');
new ssm.StringParameter(this, 'VpcIdParam', {
parameterName: '/shared/vpc-id',
stringValue: vpc.vpcId,
});
// AppStack (conta B) — importa via lookup
const vpcId = ssm.StringParameter.valueForStringParameter(this, '/shared/vpc-id');
const vpc = ec2.Vpc.fromLookup(this, 'SharedVpc', { vpcId });
7. Architectural diagram — complete multi-account CDK App
CDK App (repositório único)
│
├── bin/app.ts
│ ├── new ApplicationStage(app, 'Dev', { env: devEnv })
│ ├── new ApplicationStage(app, 'Staging', { env: stagingEnv })
│ └── new ApplicationStage(app, 'Prod', { env: prodEnv })
│
├── lib/
│ ├── application-stage.ts ← Stage: agrupa os stacks
│ ├── network-stack.ts ← VPC, subnets (muda raramente)
│ ├── data-stack.ts ← RDS, DynamoDB (muda ocasionalmente)
│ └── app-stack.ts ← ECS, Lambda (muda frequentemente)
│
└── Bootstrap necessário em cada conta/região:
cdk bootstrap aws://111122223333/us-east-1 (dev)
cdk bootstrap aws://222233334444/us-east-1 (staging)
cdk bootstrap aws://333344445555/us-east-1 (prod)
Deploy:
cdk deploy "Dev/*" --profile dev
cdk deploy "Staging/*" --profile staging
cdk deploy "Prod/*" --profile prod
Practical example
Complete CDK app with three stacks and two environments:
// lib/network-stack.ts
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: 2,
natGateways: 1,
subnetConfiguration: [
{ name: 'public', subnetType: ec2.SubnetType.PUBLIC, cidrMask: 24 },
{ name: 'private', subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, cidrMask: 24 },
],
});
// Exporta o VPC ID via SSM para consumo cross-account
new ssm.StringParameter(this, 'VpcIdParam', {
parameterName: `/${this.stackName}/vpc-id`,
stringValue: this.vpc.vpcId,
});
}
}
// lib/app-stack.ts
interface AppStackProps extends cdk.StackProps {
vpc: ec2.Vpc;
isProd: boolean;
}
export class AppStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: AppStackProps) {
super(scope, id, props);
const cluster = new ecs.Cluster(this, 'Cluster', { vpc: props.vpc });
new s3.Bucket(this, 'AssetsBucket', {
versioned: props.isProd,
removalPolicy: props.isProd
? cdk.RemovalPolicy.RETAIN
: cdk.RemovalPolicy.DESTROY,
autoDeleteObjects: !props.isProd,
});
}
}
// lib/application-stage.ts
export class ApplicationStage extends cdk.Stage {
constructor(scope: Construct, id: string, props: cdk.StageProps & { isProd: boolean }) {
super(scope, id, props);
const network = new NetworkStack(this, 'Network');
new AppStack(this, 'App', {
vpc: network.vpc,
isProd: props.isProd,
});
}
}
// bin/app.ts
const app = new cdk.App();
new ApplicationStage(app, 'Dev', {
env: { account: '111122223333', region: 'us-east-1' },
isProd: false,
});
new ApplicationStage(app, 'Prod', {
env: { account: '333344445555', region: 'us-east-1' },
isProd: true,
});
# Listar todos os stacks gerados
cdk ls
# Dev/Network
# Dev/App
# Prod/Network
# Prod/App
# Deploy de dev (bootstrap já feito)
cdk deploy "Dev/*" --profile dev --require-approval never
# Diff de prod antes de promover
cdk diff "Prod/*" --profile prod
# Deploy de prod com aprovação de mudanças IAM
cdk deploy "Prod/*" --profile prod
Common pitfalls
1. Token.isUnresolved — using this.account in conditional logic
this.account returns a Token when the environment is not pinned, or in certain phases of synth. Using it in comparisons (if (this.account === '123...')) always fails silently. Use typed props for conditional configurations per environment — never compare this.account directly in business logic.
2. Cross-stack reference between accounts — CDK object doesn't cross accounts
Passing a CDK object (vpc, cluster, bucket) as a prop only works within the same account and region. CDK creates a CloudFormation Export/Import, which is regional. For cross-account, you need to pass strings (IDs, ARNs) and use from* methods to import the resource (ec2.Vpc.fromVpcAttributes, s3.Bucket.fromBucketName, etc.).
3. Stage with env vs stacks with env — which one prevails
The Stage's env is inherited by all child stacks that don't define their own env. If a child stack defines a different env, the stack goes to a different account/region than the Stage — which can be intentional but is frequently a bug. Maintain consistency: either the Stage defines the env and stacks inherit, or each stack defines its own.
Reflection exercise
You are designing the CDK structure for a platform with the following components: shared VPC (used by all services), RDS database (rarely updated, with critical data), API service (Lambda + API Gateway, deployed multiple times a day), and monitoring dashboard (CloudWatch dashboards + alarms).
You have three environments: dev (single account), staging (single account) and prod (single account, with multiple regions: us-east-1 and eu-west-1).
Design the stack and stage structure for this platform. For each decision — how many stacks, how to group them, how to pass references between them — justify based on lifecycle, blast radius risk, and operational requirements. What changes in the structure for multi-region prod?
Resources for further study
Environments:
- Environments for the AWS CDK — covers environment-agnostic vs environment-bound, CDK_DEFAULT_ACCOUNT/REGION and how the CLI resolves the variables.
Stages:
- Introduction to AWS CDK stages — how to create and use Stages to group stacks by environment.
Best practices:
- Best practices for developing and deploying cloud infrastructure with the AWS CDK — the "Constructs vs Stacks" section explains the lifecycle criterion for separating stacks, and "Environments" covers the recommended multi-account structure.