# The CloudFormation 500-resource limit, and how we split a stack without losing data

`cdk deploy` failed with a message we'd never seen: *Template format error: Number of resources, 503, is greater than maximum allowed, 500*. Nothing in the diff was big. We had added one Lambda function with a log group and a role, and that was enough to push a stack we'd been building for eight months over a ceiling we didn't know was there.

The limit is real, it's per stack, and it's not negotiable through a quota increase. This article is how we found out what was in the stack, where we cut it, and how we moved a database, three DynamoDB tables and a KMS key to a new stack without deleting any of them.

## How you get to 500 without noticing

Our CDK code declared maybe 60 things: a VPC, an Aurora cluster, three App Runner services, a dozen Lambdas, a few DynamoDB tables, a CloudFront distribution, a WAF, some secrets. CloudFormation saw 503. The difference is everything CDK generates for you:

```bash
npx cdk synth platform-prod --quiet
grep -h '"Type": "AWS::' cdk.out/platform-prod.template.json | sort | uniq -c | sort -rn | head
```

| Resource type | Count | Where it came from |
|---|---|---|
| `AWS::IAM::Policy` | 71 | every `grant*()` call, one policy per role per permission group |
| `AWS::IAM::Role` | 48 | one per Lambda, per App Runner service, per custom resource |
| `AWS::Lambda::Function` | 31 | our 12, plus CDK's custom-resource providers for log retention, bucket deployment, cluster secrets rotation |
| `AWS::Logs::LogGroup` | 29 | one per function, explicitly, because we set retention |
| `AWS::EC2::*` | 58 | the VPC: subnets, route tables, routes, associations, NAT, endpoints, security groups, ingress rules |
| `AWS::Lambda::Permission` | 24 | every event source and every API Gateway route |
| `Custom::*` | 19 | log retention, S3 deployments, the cluster password rotation |
| Everything else | 223 | |

A VPC with three availability zones is fifty-plus resources on its own. Each Lambda is a function, a role, one to three policies, a log group, a log retention custom resource and a permission: seven resources per function you thought was one. IAM alone was a quarter of the stack. None of this is wasteful; it's the correct amount of infrastructure. It's just far more than the mental count.

<div class="article-figure">
<svg viewBox="0 0 900 250" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Horizontal bar chart of the 503 resources in the single stack by type: IAM policies 71, EC2 networking 58, IAM roles 48, Lambda functions 31, log groups 29, Lambda permissions 24, custom resources 19, everything else 223. A red line marks the 500 limit.">
<g font-family="Inter,system-ui,sans-serif" font-size="13">
<text x="20" y="24" fill="#f1f3ff" font-size="14" font-weight="700">503 resources in one stack, by type</text>
<g fill="#9aa3c7">
<text x="20" y="58">IAM policies</text><rect x="190" y="46" width="213" height="16" rx="3" fill="#7b8cff"/><text x="412" y="58" fill="#f1f3ff">71</text>
<text x="20" y="84">EC2 networking</text><rect x="190" y="72" width="174" height="16" rx="3" fill="#7b8cff"/><text x="373" y="84" fill="#f1f3ff">58</text>
<text x="20" y="110">IAM roles</text><rect x="190" y="98" width="144" height="16" rx="3" fill="#7b8cff"/><text x="343" y="110" fill="#f1f3ff">48</text>
<text x="20" y="136">Lambda functions</text><rect x="190" y="124" width="93" height="16" rx="3" fill="#4fffb0"/><text x="292" y="136" fill="#f1f3ff">31 · only 12 are ours</text>
<text x="20" y="162">Log groups</text><rect x="190" y="150" width="87" height="16" rx="3" fill="#7b8cff"/><text x="286" y="162" fill="#f1f3ff">29</text>
<text x="20" y="188">Lambda permissions</text><rect x="190" y="176" width="72" height="16" rx="3" fill="#7b8cff"/><text x="271" y="188" fill="#f1f3ff">24</text>
<text x="20" y="214">Custom resources</text><rect x="190" y="202" width="57" height="16" rx="3" fill="#ffd166"/><text x="256" y="214" fill="#f1f3ff">19 · CDK helpers</text>
<text x="20" y="240">Everything else</text><rect x="190" y="228" width="669" height="16" rx="3" fill="#2a3150"/><text x="700" y="240" fill="#f1f3ff">223</text>
</g>
</g>
</svg>
</div>

## Where to cut

The constraint that decides the split isn't the count, it's the blast radius and the rate of change. Things that change every deploy (Lambdas, App Runner image tags) should not share a stack with things that must never be touched by accident (the database). We had written this down as a principle [when we set up three accounts](/en/blog/aws-three-accounts-one-cdk-codebase) and then put everything in one stack anyway, because one stack is easier until it isn't.

The boundary we drew, in deploy order:

| Stack | Contents | Resources | Changes |
|---|---|---|---|
| `network` | VPC, subnets, NAT, endpoints, base security groups | ~70 | almost never |
| `data` | Aurora cluster, DynamoDB tables, KMS keys, secrets, backup vault | ~60 | rarely, and carefully |
| `app` | Lambdas, App Runner services, queues, event rules, roles | ~300 | every deploy |
| `edge` | CloudFront, WAF, certificates, Route 53 records | ~50 | monthly |

Four stacks instead of one, the largest at 300 with room to grow. The `data` stack is the one that gets `termination protection` and the stricter deploy role; the `app` stack is the one CI touches every day.

We considered nested stacks and rejected them. A CDK `NestedStack` lifts the 500 limit (each nested stack has its own), but a nested stack is deployed as part of its parent, so the blast radius doesn't shrink at all: a bad change to a Lambda still runs a change set that includes the database. Separate stacks was the point.

## Moving stateless resources: just move them

Lambdas, roles, event rules, App Runner services with no state: cut the construct from one file, paste it in the other, deploy both. CloudFormation deletes the resource from the old stack and creates it in the new one. Two things to check before you do that:

- **Physical names.** A Lambda with an explicit `functionName` can't exist twice, so the delete must happen before the create, which means deploying the old stack first. Resources without explicit names get a new generated name and can coexist briefly. We dropped explicit names on everything that didn't need one, which was most things.
- **Things that point at the resource by ARN from outside.** The App Runner service had a custom domain, so recreating it would have meant a new `*.awsapprunner.com` hostname, a DNS change and a certificate validation. We left all three App Runner services in the `app` stack, in place, which is where they belonged anyway.

The Lambdas got new ARNs. Nothing outside the stack referred to them by ARN except an EventBridge rule in the same stack, so nothing noticed.

## Moving stateful resources: the four-step import

The database, the tables and the KMS key can't be recreated. They had to change stacks while staying exactly where they were. CloudFormation supports this through resource import, and CDK wraps it as `cdk import`. The sequence, per resource:

<div class="article-figure">
<svg viewBox="0 0 900 230" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Four steps to move a stateful resource between stacks. Step 1: set RemovalPolicy RETAIN in the old stack and deploy. Step 2: delete the construct from the old stack and deploy; CloudFormation forgets the resource but does not delete it. Step 3: add the construct to the new stack with the same physical name and run cdk import; CloudFormation adopts the existing resource. Step 4: deploy the new stack normally; the diff must be empty.">
<defs><marker id="arrI" 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="#4fffb0"/></marker></defs>
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<rect x="15" y="40" width="200" height="90" rx="10" fill="#151b2e" stroke="#ffd166" stroke-width="1.5"/><text x="115" y="64" text-anchor="middle" fill="#ffd166" font-weight="700">1 · RETAIN</text><text x="115" y="84" text-anchor="middle" fill="#f1f3ff">old stack: removalPolicy</text><text x="115" y="100" text-anchor="middle" fill="#f1f3ff">= RETAIN, deploy</text><text x="115" y="120" text-anchor="middle" fill="#9aa3c7">nothing changes yet</text>
<line x1="217" y1="85" x2="240" y2="85" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrI)"/>
<rect x="243" y="40" width="200" height="90" rx="10" fill="#151b2e" stroke="#ff6b8a" stroke-width="1.5"/><text x="343" y="64" text-anchor="middle" fill="#ff6b8a" font-weight="700">2 · orphan</text><text x="343" y="84" text-anchor="middle" fill="#f1f3ff">delete the construct,</text><text x="343" y="100" text-anchor="middle" fill="#f1f3ff">deploy old stack</text><text x="343" y="120" text-anchor="middle" fill="#9aa3c7">resource stays alive, unmanaged</text>
<line x1="445" y1="85" x2="468" y2="85" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrI)"/>
<rect x="471" y="40" width="200" height="90" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="571" y="64" text-anchor="middle" fill="#4fffb0" font-weight="700">3 · cdk import</text><text x="571" y="84" text-anchor="middle" fill="#f1f3ff">same construct, same</text><text x="571" y="100" text-anchor="middle" fill="#f1f3ff">physical name, new stack</text><text x="571" y="120" text-anchor="middle" fill="#9aa3c7">CloudFormation adopts it</text>
<line x1="673" y1="85" x2="696" y2="85" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrI)"/>
<rect x="699" y="40" width="185" height="90" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="791" y="64" text-anchor="middle" fill="#7b8cff" font-weight="700">4 · verify</text><text x="791" y="84" text-anchor="middle" fill="#f1f3ff">cdk diff must be</text><text x="791" y="100" text-anchor="middle" fill="#f1f3ff">empty, then deploy</text><text x="791" y="120" text-anchor="middle" fill="#9aa3c7">managed again</text>
<text x="450" y="170" text-anchor="middle" fill="#9aa3c7">Between steps 2 and 3 the resource exists but no stack owns it. Do the two steps back to back, in one sitting, with a snapshot taken first.</text>
<text x="450" y="196" text-anchor="middle" fill="#ff6b8a">If step 1 is skipped, step 2 deletes the database. That is the whole reason step 1 exists.</text>
</g>
</svg>
</div>

Step one is the one people skip. Without `RemovalPolicy.RETAIN` deployed *first*, removing the construct in step two deletes the resource, and for an Aurora cluster that's a final snapshot at best. We had already been through [one deletion incident](/en/blog/cloudformation-deleted-our-app-runner-services) that year and were not interested in a second, so we did step one, deployed, and then checked in the console that `DeletionPolicy: Retain` was on the resource in the template before doing anything else.

Step three needs the construct in the new stack to produce exactly the properties of the existing resource. For DynamoDB that's the table name, the key schema and the billing mode; for Aurora it's the cluster identifier, engine and a few more; for KMS it's the key ID. `cdk import` prompts for the identifiers it can't infer, then runs an import change set. If a property doesn't match, the import fails cleanly and nothing is changed, which is the good kind of failure.

Step four is the proof. `cdk diff` on the new stack after the import should be empty. Ours wasn't, the first time, for the Aurora cluster: we'd declared `deletionProtection: true` in the new stack, and the real cluster had it off, because the old stack had never set it. The diff showed it, we deployed it, and the cluster ended up better protected than before.

## Cross-stack references, and the trap in them

Once resources live in different stacks, the `app` stack needs the VPC from `network` and the table names from `data`. CDK's default is to pass the object across and let it generate a CloudFormation export/import pair. It works, and then it locks you in: an exported value can't change while another stack imports it, so a change to the VPC that alters an exported subnet ID fails until you've removed every consumer. We hit this on day two.

We switched to SSM parameters for everything that crosses a stack boundary:

```ts
// data stack
new ssm.StringParameter(this, 'OrdersTableName', {
  parameterName: `/platform/${env}/orders-table-name`,
  stringValue: ordersTable.tableName,
});

// app stack
const ordersTableName = ssm.StringParameter.valueForStringParameter(this, `/platform/${env}/orders-table-name`);
const ordersTable = dynamodb.Table.fromTableName(this, 'OrdersTable', ordersTableName);
```

The consumer resolves the parameter at deploy time; there's no export, so nothing is locked. The cost is that CDK no longer knows the dependency, so you deploy stacks in order yourself. Our deploy script lists them: `network data app edge`. For the VPC, `Vpc.fromLookup` by tag does the same job with the lookup cached in `cdk.context.json`.

## The whole move, timed

| Step | Time | Downtime |
|---|---|---|
| Inventory and drawing the boundary | 2 hours | none |
| Code split into four stacks | 3 hours | none |
| Stateless resources moved (Lambdas, rules, roles) | 20 minutes of deploys | ~1 minute for event-driven functions |
| RETAIN deployed on 5 stateful resources | 5 minutes | none |
| Orphan and import, 5 resources | 40 minutes | none |
| Cross-stack refs moved to SSM | 1 hour | none |
| Verifying empty diffs on all four stacks | 15 minutes | none |

One afternoon and one morning, done in staging first and then in production the next day with a runbook. The one minute of downtime was for the Lambdas that consume queues: between delete and create, messages waited. They were processed when the new functions came up.

## What we'd tell our past selves

Split before 300, not at 500. Count resources with `grep` after every meaningful change, and put the count in CI as a warning at 350. Keep stateful things in a stack that changes as rarely as possible, from the first deploy. And never remove a stateful construct from a stack without seeing `Retain` in the deployed template first.

If you're staring at the 500 error right now and the stack has a database in it, [talk to us](/contact) before you run the next deploy.
