# Three AWS accounts, one CDK codebase: how we keep dev, staging and prod from contaminating each other

Most of the AWS articles on this blog so far have been guides: what a service does, how to set it up, what it costs. This one is different. It documents a real setup we built and operate for a client in 2026: a multi-app platform (three Next.js apps, four Lambda functions, a GraphQL API, DynamoDB, Aurora Serverless v2, S3, Cognito) that runs in **three separate AWS accounts**, one per environment, all deployed from a **single CDK codebase**.

We're deliberately not naming the client, the product or any account IDs. What we're sharing is the structure, the decisions, and the mistakes that shaped them. If you're a small team that has outgrown "one account with a `-dev` suffix on everything", this is the setup we'd recommend.

## Why three accounts and not one account with three stacks

The platform started in February 2026 in one account that had been managed by hand. By April, an internal security audit produced a list of findings that all came back to the same root cause: dev, staging and prod resources shared IAM roles, shared KMS keys, shared wildcard grants (`platform-*` style table policies), and there was no way to say "this credential can only touch dev".

An AWS account is the only hard security boundary AWS gives you. Everything else — tags, naming conventions, IAM conditions — is a soft boundary that one wrong wildcard undoes. So the decision was:

- **dev** — a greenfield account, fully CDK-managed, cheap teardown allowed.
- **staging** — the legacy account, re-provisioned from the same CDK code after a controlled migration (May 2026).
- **prod** — a third, dedicated account, brought up from zero in June and launched at the end of July.

Same region in all three. Non-overlapping VPC CIDRs (`10.50`, `10.51`, `10.52`) so peering stays possible later. Each account has its own KMS keys, its own Cognito pool, its own secrets, its own CloudTrail.

<div class="article-figure">
<svg viewBox="0 0 900 300" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="One CDK codebase, three stages, three AWS accounts: dev from the dev branch, staging from the staging branch, prod from the master branch">
<defs><marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#7b8cff"/></marker></defs>
<rect x="20" y="110" width="220" height="80" rx="12" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/>
<text x="130" y="143" text-anchor="middle" fill="#f1f3ff" font-family="Inter,system-ui,sans-serif" font-size="16" font-weight="700">infra/ (CDK, TypeScript)</text>
<text x="130" y="168" text-anchor="middle" fill="#9aa3c7" font-family="Inter,system-ui,sans-serif" font-size="13">config.ts = single source of truth</text>
<g font-family="Inter,system-ui,sans-serif">
<line x1="240" y1="150" x2="330" y2="60" stroke="#7b8cff" stroke-width="1.5" marker-end="url(#arr)"/>
<line x1="240" y1="150" x2="330" y2="150" stroke="#7b8cff" stroke-width="1.5" marker-end="url(#arr)"/>
<line x1="240" y1="150" x2="330" y2="240" stroke="#7b8cff" stroke-width="1.5" marker-end="url(#arr)"/>
<text x="285" y="95" fill="#9aa3c7" font-size="12" text-anchor="middle">-c stage=dev</text>
<text x="285" y="142" fill="#9aa3c7" font-size="12" text-anchor="middle">-c stage=staging</text>
<text x="285" y="205" fill="#9aa3c7" font-size="12" text-anchor="middle">-c stage=prod</text>
<rect x="335" y="25" width="250" height="70" rx="10" fill="#0f2a22" stroke="#4fffb0" stroke-width="1.5"/>
<text x="460" y="52" text-anchor="middle" fill="#f1f3ff" font-size="15" font-weight="700">Account: dev</text>
<text x="460" y="76" text-anchor="middle" fill="#9aa3c7" font-size="11">branch dev · DESTROY · MFA off · PITR off</text>
<rect x="335" y="115" width="250" height="70" rx="10" fill="#1f2340" stroke="#ffd166" stroke-width="1.5"/>
<text x="460" y="142" text-anchor="middle" fill="#f1f3ff" font-size="15" font-weight="700">Account: staging</text>
<text x="460" y="166" text-anchor="middle" fill="#9aa3c7" font-size="11">branch staging · RETAIN · IDV sandbox</text>
<rect x="335" y="205" width="250" height="70" rx="10" fill="#2a1520" stroke="#ff6b8a" stroke-width="1.5"/>
<text x="460" y="232" text-anchor="middle" fill="#f1f3ff" font-size="15" font-weight="700">Account: prod</text>
<text x="460" y="256" text-anchor="middle" fill="#9aa3c7" font-size="11">branch master · RETAIN · Object Lock 7y · Config on</text>
<rect x="640" y="25" width="240" height="250" rx="12" fill="#151b2e" stroke="#2a3150" stroke-width="1.5"/>
<text x="760" y="55" text-anchor="middle" fill="#f1f3ff" font-size="14" font-weight="700">Per account, never shared</text>
<text x="660" y="85" fill="#9aa3c7" font-size="12">• KMS keys</text>
<text x="660" y="108" fill="#9aa3c7" font-size="12">• Cognito user pool</text>
<text x="660" y="131" fill="#9aa3c7" font-size="12">• Secrets Manager entries</text>
<text x="660" y="154" fill="#9aa3c7" font-size="12">• VPC (10.50 / 10.51 / 10.52)</text>
<text x="660" y="177" fill="#9aa3c7" font-size="12">• CloudTrail + GuardDuty</text>
<text x="660" y="200" fill="#9aa3c7" font-size="12">• GitHub OIDC deploy role</text>
<text x="660" y="223" fill="#9aa3c7" font-size="12">• CDK bootstrap</text>
<text x="660" y="246" fill="#9aa3c7" font-size="12">• Cost allocation tags</text>
</g>
</svg>
</div>

## One codebase, one `config.ts`

The whole point of three accounts is that the code is *identical* and only the configuration differs. We enforce that with a single file, `infra/lib/config.ts`, which is the only place where environments are allowed to differ. Every construct receives a `StageConfig` object and never asks "am I in prod?" directly.

The entry point looks roughly like this:

```ts
const stage = app.node.tryGetContext('stage') ?? 'dev';
const config = getStageConfig(stage);  // throws on unknown stage

new PlatformStack(app, `PlatformStack-${config.stage}`, {
  config,
  env: { account: config.account, region: config.region },
  terminationProtection: true,
  tags: { Project: 'platform', Stage: config.stage, ManagedBy: 'cdk' },
});
```

Two details matter here. First, the stack name carries the stage, so `PlatformStack-dev` and `PlatformStack-prod` can never be confused. Second, the account is pinned in `env`: CDK refuses to deploy if the credentials you're holding resolve to a different account than the one the stage expects. You cannot deploy prod with dev credentials by accident, and you cannot deploy dev into the prod account by accident.

## The hardening matrix

This is the part we'd put on a wall. Instead of scattering `if (stage === 'prod')` across the codebase, each stage returns a typed config block, and the differences read like a table:

| Setting | dev | staging | prod |
|---|---|---|---|
| Removal policy | DESTROY | RETAIN | RETAIN |
| DynamoDB point-in-time recovery | off | on | on |
| DynamoDB customer-managed KMS | on | on | on |
| DynamoDB streams on sensitive tables | off | on | on |
| Document vault (S3) lifecycle | 30 days | 30 days | 7 years + Object Lock |
| Cognito advanced security | audit | audit | audit (enforce is a launch decision) |
| AppSync full request logging | on | on | off |
| X-Ray tracing | on | on | on |
| WAF on CloudFront + AppSync | on | on | on |
| AWS Config | off | on | on |
| CloudTrail + GuardDuty | on | on | on |
| Aurora Serverless v2 capacity | 0.5–4 ACU | 0.5–4 ACU | 2–16 ACU |
| Aurora backup retention | 7 days | 7 days | 30 days |
| Redirect Lambda reserved concurrency | 10 | 50 | 200 |
| Third-party identity verification | off (mock) | sandbox | production |

Reading that table you can tell immediately what "dev" means for us: cheap, disposable, but with the same *shape* as prod. KMS encryption is on everywhere, because turning it on later means re-creating tables. WAF is on everywhere, because a WAF rule that only exists in prod is a rule nobody has tested.

The dev block also carries one line we're proud of: an email allowlist set to a single unmatchable sink address. Dev holds copies of real user records, so the email transport drops every outgoing message. That's a product guarantee expressed in infrastructure config, not a comment in a README.

## Branch to account, not laptop to account

Each account is fed by exactly one Git branch: `dev` → dev account, `staging` → staging account, `master` → prod account. Promotion to prod is a fast-forward merge, never a rebase, so the commit SHA that was tested on dev is the same SHA that ships to prod.

The CI/CD side uses GitHub's OIDC provider instead of long-lived access keys. Each account has two IAM roles:

- `github-actions-cdk-diff` — read-only, assumable only from pull-request runs. It runs `cdk diff` so reviewers see infrastructure changes before merge.
- `github-actions-cdk-deploy` — assumable only from pushes to that account's branch. It doesn't hold broad IAM powers itself; it delegates to the CDK bootstrap roles. Widening what CDK can deploy goes through `cdk bootstrap`, not through editing a policy.

The trust policy pins the repository and the exact ref, so a fork or a feature branch cannot assume the deploy role.

We also learned, expensively, that **`cdk deploy` from a laptop is a bug, not a feature**. A local deploy without the right context flags once synthesized a template that was missing the App Runner services, and CloudFormation dutifully deleted them. That incident got its own article; the short version is that termination protection, a RETAIN aspect on critical resource types, and a loud banner when CDK runs outside CI are now part of the codebase.

<div class="article-figure">
<svg viewBox="0 0 900 330" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="How a Git event reaches an AWS account: pull requests assume the read-only cdk-diff role, pushes to dev, staging or master assume that account's cdk-deploy role, which delegates to the CDK bootstrap roles. No long-lived access keys.">
<defs><marker id="arrA" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#7b8cff"/></marker></defs>
<g font-family="Inter,system-ui,sans-serif">
<rect x="20" y="20" width="170" height="50" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/>
<text x="105" y="50" text-anchor="middle" fill="#f1f3ff" font-size="14" font-weight="700">Pull request</text>
<line x1="190" y1="45" x2="295" y2="45" stroke="#7b8cff" stroke-width="1.5" marker-end="url(#arrA)"/>
<text x="242" y="38" text-anchor="middle" fill="#9aa3c7" font-size="11">GitHub OIDC</text>
<rect x="300" y="20" width="300" height="50" rx="10" fill="#151b2e" stroke="#2a3150" stroke-width="1.5"/>
<text x="450" y="41" text-anchor="middle" fill="#f1f3ff" font-size="13" font-weight="700">github-actions-cdk-diff</text>
<text x="450" y="60" text-anchor="middle" fill="#9aa3c7" font-size="11">read-only · cdk diff</text>
<rect x="20" y="100" width="170" height="50" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/>
<text x="105" y="130" text-anchor="middle" fill="#f1f3ff" font-size="14" font-weight="700">push dev</text>
<line x1="190" y1="125" x2="295" y2="125" stroke="#7b8cff" stroke-width="1.5" marker-end="url(#arrA)"/>
<text x="242" y="118" text-anchor="middle" fill="#9aa3c7" font-size="11">GitHub OIDC</text>
<rect x="300" y="100" width="300" height="50" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/>
<text x="450" y="121" text-anchor="middle" fill="#f1f3ff" font-size="13" font-weight="700">github-actions-cdk-deploy</text>
<text x="450" y="140" text-anchor="middle" fill="#9aa3c7" font-size="11">push only · delegates to CDK bootstrap</text>
<line x1="600" y1="125" x2="685" y2="125" stroke="#7b8cff" stroke-width="1.5" marker-end="url(#arrA)"/>
<text x="642" y="118" text-anchor="middle" fill="#9aa3c7" font-size="11">cdk deploy</text>
<rect x="690" y="100" width="190" height="50" rx="10" fill="#0f2a22" stroke="#4fffb0" stroke-width="1.5"/>
<text x="785" y="130" text-anchor="middle" fill="#f1f3ff" font-size="14" font-weight="700">Account: dev</text>
<rect x="20" y="170" width="170" height="50" rx="10" fill="#151b2e" stroke="#ffd166" stroke-width="1.5"/>
<text x="105" y="200" text-anchor="middle" fill="#f1f3ff" font-size="14" font-weight="700">push staging</text>
<line x1="190" y1="195" x2="295" y2="195" stroke="#7b8cff" stroke-width="1.5" marker-end="url(#arrA)"/>
<text x="242" y="188" text-anchor="middle" fill="#9aa3c7" font-size="11">GitHub OIDC</text>
<rect x="300" y="170" width="300" height="50" rx="10" fill="#151b2e" stroke="#ffd166" stroke-width="1.5"/>
<text x="450" y="191" text-anchor="middle" fill="#f1f3ff" font-size="13" font-weight="700">github-actions-cdk-deploy</text>
<text x="450" y="210" text-anchor="middle" fill="#9aa3c7" font-size="11">push only · delegates to CDK bootstrap</text>
<line x1="600" y1="195" x2="685" y2="195" stroke="#7b8cff" stroke-width="1.5" marker-end="url(#arrA)"/>
<text x="642" y="188" text-anchor="middle" fill="#9aa3c7" font-size="11">cdk deploy</text>
<rect x="690" y="170" width="190" height="50" rx="10" fill="#1f2340" stroke="#ffd166" stroke-width="1.5"/>
<text x="785" y="200" text-anchor="middle" fill="#f1f3ff" font-size="14" font-weight="700">Account: staging</text>
<rect x="20" y="240" width="170" height="50" rx="10" fill="#151b2e" stroke="#ff6b8a" stroke-width="1.5"/>
<text x="105" y="270" text-anchor="middle" fill="#f1f3ff" font-size="14" font-weight="700">push master</text>
<line x1="190" y1="265" x2="295" y2="265" stroke="#7b8cff" stroke-width="1.5" marker-end="url(#arrA)"/>
<text x="242" y="258" text-anchor="middle" fill="#9aa3c7" font-size="11">GitHub OIDC</text>
<rect x="300" y="240" width="300" height="50" rx="10" fill="#151b2e" stroke="#ff6b8a" stroke-width="1.5"/>
<text x="450" y="261" text-anchor="middle" fill="#f1f3ff" font-size="13" font-weight="700">github-actions-cdk-deploy</text>
<text x="450" y="280" text-anchor="middle" fill="#9aa3c7" font-size="11">push only · delegates to CDK bootstrap</text>
<line x1="600" y1="265" x2="685" y2="265" stroke="#7b8cff" stroke-width="1.5" marker-end="url(#arrA)"/>
<text x="642" y="258" text-anchor="middle" fill="#9aa3c7" font-size="11">cdk deploy</text>
<rect x="690" y="240" width="190" height="50" rx="10" fill="#2a1520" stroke="#ff6b8a" stroke-width="1.5"/>
<text x="785" y="270" text-anchor="middle" fill="#f1f3ff" font-size="14" font-weight="700">Account: prod</text>
<text x="450" y="312" text-anchor="middle" fill="#9aa3c7" font-size="12">Trust policy = this repo + this exact ref. Zero long-lived access keys anywhere.</text>
</g>
</svg>
</div>

## Tags are how you find out what it costs

Every resource in every account carries three tags: `Project`, `Stage`, `ManagedBy`. With cost allocation tags activated in Billing, the monthly bill splits cleanly per environment. The numbers we can share without breaking confidentiality:

- Keeping Aurora Serverless v2 at a 0.5 ACU floor instead of auto-pause costs roughly 30 dollars a month per environment. We pay it on dev too, because auto-pause put a 15–30 second cold start on the signup path.
- The dev account is the cheapest of the three by a wide margin, mostly because of DESTROY policies, no AWS Config, and minimum-size App Runner instances.
- Prod's fixed floor is dominated by two warm App Runner instances for the public-facing app, the Aurora floor, and WAF.

Without the tags, "how much does staging cost us" is a question that takes an afternoon to answer. With them, it's a filter in Cost Explorer.

## What went wrong along the way

An honest write-up has to include the bruises.

**The CloudFormation 500-resource limit.** One stack per account was the original design. By August the prod stack synthesized at exactly 500 resources and dev at 499. Adding one Lambda broke `cdk synth`. We split observability (log data-protection policies, metric filters, alarms, SNS) into its own stack and later moved a whole app into another. The lesson: design for multiple stacks per account from day one, with explicit exports, and don't let a single stack grow past about 350 resources.

**Drift dies on the next deploy.** CloudFront aliases and an ACM certificate were attached by hand to a distribution in July. The August CDK deploy replaced the whole `DistributionConfig` and silently removed them. Anything not in code doesn't exist. We now keep aliases and certificate ARNs in the stage config.

**Build-time environment is not runtime environment.** Next.js inlines `NEXT_PUBLIC_*` variables at build time. Runtime env vars on the container cannot change them. Our deploy script now reads the build-time config out of the synthesized template and passes it to CodeBuild per build, so a changed public variable reaches the browser on the first deploy instead of the second.

**The rate limit that locked out the whole team.** WAF's per-IP rate rule was set to 10,000 requests per 5 minutes. The client's QA team sits behind one office NAT address. A heavy testing session tripped the rule and every request from the office got a 403 for five minutes. It's now 30,000, and it's in `config.ts`, not in the console.

## Would we do it again?

Yes, and earlier. The cost of three accounts is real but small: three CDK bootstraps, three OIDC roles, three sets of secrets to populate, and one config file that must stay honest. The cost of *not* having them is the audit finding list we started with, plus the permanent worry that a dev script with a wildcard grant might touch production data.

If you're setting this up for the first time, the order we'd recommend is: write `config.ts` with the hardening matrix first, even if half the settings aren't implemented yet; pin accounts in `env` and turn on termination protection before the first deploy; wire OIDC before you give anyone deploy credentials; and split into multiple stacks before you need to.

Want help structuring your own AWS accounts and CDK code this way? [Talk to us](/contact) — we've done it once the hard way so you don't have to.
