← All posts
aws cdk typescript infrastructure

Handling Environmental Differences using the AWS CDK

April 11, 2026

Once a CDK app targets more than one environment, things get messy fast. You start passing isProd booleans into stack constructors, then someone adds isStaging, and before long every stack has a growing list of environment flags that no one remembers to set consistently. Production resources end up with RemovalPolicy.DESTROY because someone copy-pasted from the dev stack and forgot to change it.

The CDK actually has a built-in answer for this: the Stage construct. A Stage groups stacks into a single deployable unit, and you can attach whatever metadata you want to it. If you pair that with an abstract BaseStack that reads its environment from the enclosing stage via Stage.of(this), your stacks become environment-aware without any of that constructor flag passing.

This post walks through the pattern. I’m assuming one AWS account per environment and region pair.

The stage classes

The idea is simple: create a Stage subclass for each deployment target. Each subclass is self-contained with its own environment name, account ID, and region hardcoded in the constructor.

lib/types.ts
export type EnvironmentName = "dev" | "prod";
lib/stages/dev-us-east-1.ts
import { Stage } from "aws-cdk-lib";
import { Construct } from "constructs";
import type { EnvironmentName } from "../types";
export class DevUSEast1Stage extends Stage {
public readonly environmentName: EnvironmentName = "dev";
public constructor(scope: Construct) {
super(scope, "DevUSEast1", {
env: { account: "111111111111", region: "us-east-1" },
});
// Add stacks here.
// new AppExampleStack(this, "AppExampleStack");
}
}
lib/stages/prod-us-west-2.ts
import { Stage } from "aws-cdk-lib";
import { Construct } from "constructs";
import type { EnvironmentName } from "../types";
export class ProdUSWest2Stage extends Stage {
public readonly environmentName: EnvironmentName = "prod";
public constructor(scope: Construct) {
super(scope, "ProdUSWest2", {
env: { account: "222222222222", region: "us-west-2" },
});
// new AppExampleStack(this, "AppExampleStack");
}
}
bin/app.ts
import { App } from "aws-cdk-lib";
import { DevUSEast1Stage } from "../lib/stages/dev-us-east-1";
import { ProdUSWest2Stage } from "../lib/stages/prod-us-west-2";
const app = new App();
new DevUSEast1Stage(app);
new ProdUSWest2Stage(app);

The app entrypoint is just two lines of instantiation. Each stage owns its account, region, and environment name, so there’s no configuration object to keep in sync. Adding a new deployment target means writing a new subclass and adding one line to bin/app.ts.

A note on stage ID naming

It’s worth picking a consistent naming convention for your stage IDs early on. Something like <Env><RegionPascalCase> works well:

These show up in cdk synth output, CI logs, and CloudFormation console views, so having something you can scan at a glance saves real time when you’re debugging a deployment at 2am. Avoid ambiguous IDs like just prod once you have multiple regions in play.

One thing to be careful about: don’t parse the stage ID string to derive behavior. It’s tempting to regex ProdUSWest2 and extract the environment name, but that breaks the moment someone tweaks the naming convention. Keep the ID as a label and use the typed properties on the stage for anything that drives logic.

BaseStack: making stacks environment-aware

This is where the pattern pays off. Instead of passing environment flags into every stack constructor, you create an abstract BaseStack that looks up its own stage using Stage.of(this) and exposes getters like isProd. Every concrete stack that extends BaseStack gets environment awareness for free.

lib/base-stack.ts
import { Stack, StackProps, Stage } from "aws-cdk-lib";
import { Construct } from "constructs";
import type { EnvironmentName } from "./types";
function getEnvironmentName(scope: Construct): EnvironmentName {
const stage = Stage.of(scope);
if (!stage) {
throw new Error(
"BaseStack must be defined within a Stage. Stage.of(this) returned undefined."
);
}
if (!("environmentName" in stage)) {
throw new Error(
"Containing Stage is missing an environmentName property."
);
}
return (stage as unknown as { environmentName: EnvironmentName }).environmentName;
}
export abstract class BaseStack extends Stack {
protected get isProd(): boolean {
return this.environmentName === "prod";
}
protected get isDev(): boolean {
return this.environmentName === "dev";
}
protected get environmentName(): EnvironmentName {
return getEnvironmentName(this);
}
protected valueForEnv<T>(envMap: Record<EnvironmentName, T>): T {
return envMap[this.environmentName];
}
public constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
}
}

The getEnvironmentName function walks up the construct tree via Stage.of(this) and checks that the enclosing stage has an environmentName property. If someone instantiates a BaseStack directly under an App (which can happen in tests), they get an immediate, clear error instead of a mysterious undefined somewhere downstream.

isProd and isDev are useful for boolean branching, but some values aren’t boolean. Log retention, alarm thresholds, instance sizes, and similar settings tend to have a different value per environment rather than an on/off toggle. That’s what valueForEnv is for. You pass it a map of environment name to value, and it returns the one that matches the current stage. The generic <T> means it works with any type.

With these in place, any stack that extends BaseStack can call this.isProd, this.environmentName, or this.valueForEnv(...) without knowing or caring how the environment was wired up. The region is already available via CDK’s built-in this.region on Stack, so there’s no need to duplicate it.

Putting it to use

Here’s what this looks like in a real stack. The AppExampleStack below uses this.isProd for boolean decisions like deletion protection, and this.valueForEnv(...) for values that differ by environment but aren’t simple on/off toggles.

lib/app-example-stack.ts
import {
Duration,
RemovalPolicy,
} from "aws-cdk-lib";
import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch";
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
import * as logs from "aws-cdk-lib/aws-logs";
import * as s3 from "aws-cdk-lib/aws-s3";
import { Construct } from "constructs";
import { BaseStack } from "./base-stack";
export class AppExampleStack extends BaseStack {
public constructor(scope: Construct, id: string) {
super(scope, id);
const removalPolicy = this.valueForEnv({
dev: RemovalPolicy.DESTROY,
prod: RemovalPolicy.RETAIN,
});
const artifactsBucket = new s3.Bucket(this, "ArtifactsBucket", {
encryption: s3.BucketEncryption.S3_MANAGED,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
enforceSSL: true,
versioned: true,
removalPolicy,
autoDeleteObjects: !this.isProd,
});
const appLogGroup = new logs.LogGroup(this, "AppLogGroup", {
retention: this.valueForEnv({
dev: logs.RetentionDays.ONE_WEEK,
prod: logs.RetentionDays.ONE_YEAR,
}),
removalPolicy,
});
const ordersTable = new dynamodb.Table(this, "OrdersTable", {
partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
sortKey: { name: "sk", type: dynamodb.AttributeType.STRING },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
pointInTimeRecovery: this.isProd,
deletionProtection: this.isProd,
removalPolicy,
});
new cloudwatch.Alarm(this, "OrdersWriteThrottleAlarm", {
metric: ordersTable.metricThrottledRequests({
period: Duration.minutes(1),
}),
threshold: this.valueForEnv({ dev: 5, prod: 1 }),
evaluationPeriods: this.valueForEnv({ dev: 1, prod: 3 }),
datapointsToAlarm: this.valueForEnv({ dev: 1, prod: 2 }),
actionsEnabled: this.isProd,
alarmDescription: `High write throttling in ${this.environmentName}/${this.region}`,
});
// Avoid unused variable warnings in minimal examples
void artifactsBucket;
void appLogGroup;
}
}

Then wire it into a stage constructor:

lib/stages/dev-us-east-1.ts
import { Stage } from "aws-cdk-lib";
import { Construct } from "constructs";
import type { EnvironmentName } from "../types";
import { AppExampleStack } from "../app-example-stack";
export class DevUSEast1Stage extends Stage {
public readonly environmentName: EnvironmentName = "dev";
public constructor(scope: Construct) {
super(scope, "DevUSEast1", {
env: { account: "111111111111", region: "us-east-1" },
});
new AppExampleStack(this, "AppExampleStack");
}
}

The nice thing here is that none of these safety decisions are made by the person deploying. They’re baked into the stack based on which stage it belongs to. Notice how removalPolicy is computed once with valueForEnv and reused across resources, and the alarm thresholds read as a straightforward mapping instead of nested ternaries. When you add a third environment like staging, you add one more entry to each map and TypeScript tells you everywhere you missed one.

Things to watch out for

Context lookups behave differently per target

If your stacks use context lookups like Vpc.fromLookup or StringParameter.valueFromLookup, the results will differ by account and region. This is expected, but it means you should run cdk synth per stage in CI regularly to catch drift early. The CDK docs on context values and lookups go into more detail.

Tests might not have a containing stage

If you instantiate a stack directly under new App() in a unit test, Stage.of(this) returns undefined. You have two options: wrap your test stack in a test stage, or rely on the explicit error in BaseStack to fail fast and tell you exactly what’s wrong.

Don’t parse stage IDs for behavior

I mentioned this above, but it’s worth repeating because I’ve seen it in production codebases. Regex-parsing ProdUSWest2 to extract the environment name works until someone adds ProdAPSoutheast1 and the regex doesn’t account for it. Typed properties on the stage are the source of truth, always.

Test your production retention settings

When isProd controls RemovalPolicy, deletionProtection, and alarm actions, those branches are safety-critical. Write snapshot or assertion tests that verify production resources have RETAIN policies and deletion protection enabled. It’s the kind of thing that only matters once, and once is enough.

Final take

The pattern here is straightforward: a Stage per deployment target, a BaseStack that reads from it, and environment branching that lives in the stack code instead of the deployment pipeline. The real win is that your production safeguards stop depending on someone remembering to pass the right flags. They’re structural, not procedural, and that’s a much better place for safety-critical behavior to live.