# The day CloudFormation deleted our App Runner services: a post-mortem and the four safety nets we added

On 30 April 2026, a single `cdk deploy` run from a laptop deleted every App Runner service in our dev environment. No data was lost, nobody outside the team noticed, and the services were back within the hour. It was still the most instructive incident of the year, because the tool did exactly what it was told to do and the person running it did nothing that looked wrong.

This is the post-mortem, written for engineers who use AWS CDK and CloudFormation and have never seen this failure mode. The platform is a client system we build and operate: three Next.js apps on App Runner, Lambda-backed GraphQL, DynamoDB, Aurora, all in one CDK stack per environment. We're not naming the client; the mechanics are what matter.

## What happened, step by step

Our CDK stack had a context flag, `hostingReady`, introduced during the initial bring-up. The first deploy of a new environment has to create the ECR repositories *before* any App Runner service can reference an image in them. So the stack was written as: if `hostingReady` is false, synthesize everything except the App Runner services; if it's true, include them.

The GitHub Actions workflow always passed `-c hostingReady=true`. The flag defaulted to **false**, which was the "safe" value for a first deploy and the wrong value for every deploy after it.

An engineer ran `cdk deploy PlatformStack-dev` locally to push a small change. No context flag. CDK synthesized a template without the three App Runner services. CloudFormation compared the new template with the deployed stack, saw three resources that were no longer declared, and did what a declarative tool is supposed to do: it deleted them.

<div class="article-figure">
<svg viewBox="0 0 900 260" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The chain of events: local cdk deploy without the context flag, template synthesized without App Runner services, CloudFormation sees three removed resources, services deleted">
<defs><marker id="arr2" 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="#ff6b8a"/></marker></defs>
<g font-family="Inter,system-ui,sans-serif">
<rect x="20" y="70" width="190" height="110" rx="12" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/>
<text x="115" y="100" text-anchor="middle" fill="#f1f3ff" font-size="14" font-weight="700">1. Laptop</text>
<text x="115" y="125" text-anchor="middle" fill="#9aa3c7" font-size="12">cdk deploy PlatformStack-dev</text>
<text x="115" y="145" text-anchor="middle" fill="#ff6b8a" font-size="12">no -c hostingReady=true</text>
<text x="115" y="165" text-anchor="middle" fill="#9aa3c7" font-size="12">flag defaults to false</text>
<line x1="210" y1="125" x2="245" y2="125" stroke="#ff6b8a" stroke-width="1.5" marker-end="url(#arr2)"/>
<rect x="250" y="70" width="190" height="110" rx="12" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/>
<text x="345" y="100" text-anchor="middle" fill="#f1f3ff" font-size="14" font-weight="700">2. Synth</text>
<text x="345" y="125" text-anchor="middle" fill="#9aa3c7" font-size="12">template is valid</text>
<text x="345" y="145" text-anchor="middle" fill="#ff6b8a" font-size="12">3 App Runner services missing</text>
<text x="345" y="165" text-anchor="middle" fill="#9aa3c7" font-size="12">no error, no warning</text>
<line x1="440" y1="125" x2="475" y2="125" stroke="#ff6b8a" stroke-width="1.5" marker-end="url(#arr2)"/>
<rect x="480" y="70" width="190" height="110" rx="12" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/>
<text x="575" y="100" text-anchor="middle" fill="#f1f3ff" font-size="14" font-weight="700">3. CloudFormation</text>
<text x="575" y="125" text-anchor="middle" fill="#9aa3c7" font-size="12">diff: 3 resources removed</text>
<text x="575" y="145" text-anchor="middle" fill="#9aa3c7" font-size="12">DeletionPolicy: Delete</text>
<text x="575" y="165" text-anchor="middle" fill="#9aa3c7" font-size="12">no termination protection</text>
<line x1="670" y1="125" x2="705" y2="125" stroke="#ff6b8a" stroke-width="1.5" marker-end="url(#arr2)"/>
<rect x="710" y="70" width="170" height="110" rx="12" fill="#2a1520" stroke="#ff6b8a" stroke-width="1.5"/>
<text x="795" y="100" text-anchor="middle" fill="#f1f3ff" font-size="14" font-weight="700">4. Result</text>
<text x="795" y="125" text-anchor="middle" fill="#ff6b8a" font-size="12">services deleted</text>
<text x="795" y="145" text-anchor="middle" fill="#9aa3c7" font-size="12">apps offline ~1 hour</text>
<text x="795" y="165" text-anchor="middle" fill="#9aa3c7" font-size="12">images + data intact</text>
<text x="450" y="230" text-anchor="middle" fill="#9aa3c7" font-size="13">Every step behaved correctly. The design was wrong.</text>
</g>
</svg>
</div>

Recovery was straightforward: re-run the deploy with the flag, wait for App Runner to pull the images and pass health checks. The container images were still in ECR, the databases were untouched, Cognito was untouched. The blast radius was "the apps were down for about an hour in dev".

## Why this is a design failure, not a human error

The tempting conclusion is "the engineer should have passed the flag". We rejected that, for three reasons.

First, CDK does not tell your app which command is running. By the time your TypeScript executes, `cdk synth`, `cdk diff` and `cdk deploy` look identical. You cannot write "if this is a deploy, refuse". Any defence has to work at the template level or at the CloudFormation level.

Second, the default value of a flag is a design decision. A flag that defaults to the destructive value is a loaded gun on the table. The one-time bring-up case should have been the opt-in, not the everyday case.

Third, CloudFormation's deletion behaviour is correct and will not change. Declarative infrastructure means "the template is the truth". If the template lacks a resource, the resource goes. The only question is whether you've told CloudFormation that certain resources are too important to delete without a fight.

## The four safety nets

We shipped all four within a week. They are independent layers; any one of them alone would have prevented the incident, and together they cover cases the others miss.

<div class="article-figure">
<svg viewBox="0 0 900 330" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Four independent layers of defence around the critical resources: safe-by-default context flags, termination protection on the stack, a RETAIN aspect on critical resource types, and CI-only deploys with a warning banner for local runs.">
<g font-family="Inter,system-ui,sans-serif">
<rect x="10" y="10" width="880" height="310" rx="16" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/>
<text x="26" y="36" fill="#7b8cff" font-size="14" font-weight="700">4. CI-only deploys + banner outside CI</text>
<text x="874" y="36" text-anchor="end" fill="#9aa3c7" font-size="12">procedural · stops the wrong laptop from deploying</text>
<rect x="55" y="55" width="790" height="220" rx="16" fill="#181d33" stroke="#ffd166" stroke-width="1.5"/>
<text x="71" y="81" fill="#ffd166" font-size="14" font-weight="700">1. Safe-by-default flags</text>
<text x="829" y="81" text-anchor="end" fill="#9aa3c7" font-size="12">a forgotten flag never removes a resource</text>
<rect x="100" y="100" width="700" height="130" rx="16" fill="#1a2038" stroke="#4fffb0" stroke-width="1.5"/>
<text x="116" y="126" fill="#4fffb0" font-size="14" font-weight="700">2. Termination protection</text>
<text x="784" y="126" text-anchor="end" fill="#9aa3c7" font-size="12">blocks cdk destroy and Delete stack</text>
<rect x="145" y="145" width="610" height="40" rx="16" fill="#1d233d" stroke="#ff6b8a" stroke-width="1.5"/>
<text x="161" y="171" fill="#ff6b8a" font-size="14" font-weight="700">3. RETAIN aspect</text>
<text x="739" y="171" text-anchor="end" fill="#9aa3c7" font-size="12">a resource dropped from the template keeps running</text>
<rect x="200" y="200" width="500" height="70" rx="12" fill="#2a1520" stroke="#ff6b8a" stroke-width="1.5"/>
<text x="450" y="242" text-anchor="middle" fill="#f1f3ff" font-size="14" font-weight="700">App Runner · Cognito · DynamoDB · RDS · S3 · SQS · Secrets</text>
</g>
</svg>
</div>

### 1. Safe-by-default flags

`hostingReady` now defaults to **true**. The bring-up case, where you need to create ECR repositories before services, is `-c skipHosting=true`, which nobody types by accident. Every context flag in the stack was reviewed with the same question: "if someone forgets this, which direction does the mistake go?" A forgotten flag must never remove a resource.

### 2. Termination protection on the stack

```ts
new PlatformStack(app, `PlatformStack-${config.stage}`, {
  config,
  env: { account: config.account, region: config.region },
  terminationProtection: true,
});
```

This stops `cdk destroy` and the console's "Delete stack" button until an operator explicitly flips it off. It would not have prevented the April incident on its own, because the stack was updated, not deleted, but it closes the neighbouring door. It costs nothing.

### 3. A RETAIN aspect on critical resource types

This is the one that directly addresses the incident. A CDK Aspect walks every construct in the tree after synthesis and pins the CloudFormation `DeletionPolicy` to `Retain` for resource types we consider critical:

```ts
const PROTECTED_TYPES = new Set([
  'AWS::AppRunner::Service',
  'AWS::Cognito::UserPool',
  'AWS::DynamoDB::Table',
  'AWS::RDS::DBCluster',
  'AWS::SQS::Queue',
  'AWS::S3::Bucket',
  'AWS::SecretsManager::Secret',
]);

export class ProtectCriticalResources implements IAspect {
  visit(node: IConstruct): void {
    if (!CfnResource.isCfnResource(node)) return;
    if (!PROTECTED_TYPES.has(node.cfnResourceType)) return;
    const current = node.cfnOptions.deletionPolicy;
    if (current && current !== CfnDeletionPolicy.RETAIN) return; // respect explicit choices
    node.applyRemovalPolicy(RemovalPolicy.RETAIN);
  }
}

Aspects.of(stack).add(new ProtectCriticalResources());
```

With `Retain`, when a template stops declaring a resource, CloudFormation removes it from the stack's bookkeeping but leaves the actual AWS resource running. In the April scenario the services would have kept serving traffic, and the next correct deploy would have needed an import instead of a create. That's an annoyance, not an outage.

Two design choices worth noting. The aspect respects explicit decisions: if a construct set `DESTROY` on purpose (a dev bucket with `autoDeleteObjects`, for example), the aspect leaves it alone, because overriding it would either break synth or silently undo a deliberate choice. And the aspect applies in every stage, including dev, because the incident risk is the same wherever real users or real test data live.

The trade-off: a deliberate `cdk destroy` now leaves orphaned resources behind. Cleanup is a manual delete per resource. We accept that; it's the explicit price of safety.

### 4. Deploys happen from CI, and the CLI tells you so

The last layer is procedural. All three environments deploy from GitHub Actions via OIDC-assumed roles, each triggered by its own branch. A local `cdk synth` or `cdk diff` is fine and encouraged. A local `cdk deploy` is not, and since CDK can't block it, the app prints a banner whenever it's running outside CI:

```
⚠️  CDK is running outside of CI.
   `cdk synth` and `cdk diff` are safe to run locally.
   `cdk deploy` from a laptop is the cause of the 2026-04-30 hosting
   incident — push to the dev branch and let GitHub Actions deploy.
```

The banner is suppressed by `CI=true` or by an explicit acknowledgement variable. It's not a technical control, and we don't pretend it is. It's a reminder at the exact moment when a reminder is useful, and it references the incident by date so nobody has to ask why.

## What we'd tell you to check today

You don't need to have this incident to benefit from it. Three questions to ask about your own CDK stacks this week:

1. **Which of your context flags or environment variables, if forgotten, removes a resource?** Flip their defaults.
2. **What's the `DeletionPolicy` on your databases, user pools and queues?** Run `cdk synth` and grep the template. If the answer is `Delete` or absent, that's a one-line aspect away from being fixed.
3. **Can a laptop deploy to production?** If yes, what stops a wrong `AWS_PROFILE`? Account pinning in `env` and a CI-only deploy path are both cheap.

One more lesson we learned from a second, smaller incident in August: CloudFront aliases and a certificate that had been attached by hand were wiped by the next CDK deploy, because CloudFormation replaces the whole distribution config. Same root cause in a different costume: **anything that's not in the template doesn't exist**. Retain policies protect resources; they don't protect properties. The only defence for properties is to put them in code.

If you'd like a second pair of eyes on your CDK setup before it teaches you this lesson itself, [get in touch](/contact).
