# Le jour où CloudFormation a supprimé nos services App Runner : un post-mortem et les quatre filets de sécurité que nous avons ajoutés

Le 30 avril 2026, une simple exécution de `cdk deploy` depuis un ordinateur portable a supprimé tous les services App Runner de notre environnement de dev. Aucune donnée n'a été perdue, personne en dehors de l'équipe ne s'en est aperçu, et les services étaient de nouveau opérationnels en moins d'une heure. Ce fut malgré tout l'incident le plus instructif de l'année, car l'outil a fait exactement ce qu'on lui demandait et la personne qui l'exécutait n'a rien fait qui paraissait incorrect.

Voici le post-mortem, écrit pour les ingénieurs qui utilisent AWS CDK et CloudFormation et qui n'ont jamais rencontré ce mode de défaillance. La plateforme est un système client que nous construisons et exploitons : trois applications Next.js sur App Runner, un GraphQL adossé à Lambda, DynamoDB, Aurora, le tout dans une seule pile CDK par environnement. Nous ne nommons pas le client ; ce qui compte, c'est le mécanisme.

## Ce qui s'est passé, étape par étape

Notre pile CDK comportait un indicateur de contexte, `hostingReady`, introduit lors de la mise en service initiale. Le premier déploiement d'un nouvel environnement doit créer les dépôts ECR *avant* qu'un service App Runner puisse référencer une image qui s'y trouve. La pile était donc écrite ainsi : si `hostingReady` vaut false, tout est synthétisé sauf les services App Runner ; si c'est true, ils sont inclus.

Le workflow GitHub Actions passait toujours `-c hostingReady=true`. La valeur par défaut de l'indicateur était **false**, ce qui était la valeur « sûre » pour un premier déploiement et la mauvaise valeur pour tous les déploiements suivants.

Un ingénieur a exécuté `cdk deploy PlatformStack-dev` en local pour pousser une petite modification. Aucun indicateur de contexte. CDK a synthétisé un template sans les trois services App Runner. CloudFormation a comparé le nouveau template avec la pile déployée, a vu trois ressources qui n'étaient plus déclarées, et a fait ce qu'un outil déclaratif est censé faire : il les a supprimées.

<div class="article-figure">
<svg viewBox="0 0 900 260" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="La chaîne d'événements : cdk deploy local sans l'indicateur de contexte, template synthétisé sans les services App Runner, CloudFormation voit trois ressources supprimées, services supprimés">
<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. Portable</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">défaut : 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 valide</text>
<text x="345" y="145" text-anchor="middle" fill="#ff6b8a" font-size="12">3 services App Runner absents</text>
<text x="345" y="165" text-anchor="middle" fill="#9aa3c7" font-size="12">pas d'erreur ni d'alerte</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 ressources en moins</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">aucune protection suppression</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. Résultat</text>
<text x="795" y="125" text-anchor="middle" fill="#ff6b8a" font-size="12">services supprimés</text>
<text x="795" y="145" text-anchor="middle" fill="#9aa3c7" font-size="12">apps hors ligne ~1h</text>
<text x="795" y="165" text-anchor="middle" fill="#9aa3c7" font-size="12">images + données intactes</text>
<text x="450" y="230" text-anchor="middle" fill="#9aa3c7" font-size="13">Chaque étape s'est comportée correctement. La conception était erronée.</text>
</g>
</svg>
</div>

La récupération a été simple : relancer le déploiement avec l'indicateur, attendre qu'App Runner récupère les images et passe les contrôles de santé. Les images de conteneurs étaient toujours dans ECR, les bases de données n'avaient pas été touchées, Cognito n'avait pas été touché. Le rayon de l'impact se résumait à « les applications ont été indisponibles pendant environ une heure en dev ».

## Pourquoi il s'agit d'une défaillance de conception, pas d'une erreur humaine

La conclusion tentante est : « l'ingénieur aurait dû passer l'indicateur ». Nous avons rejeté cette idée, pour trois raisons.

Premièrement, CDK n'indique pas à votre application quelle commande est en cours d'exécution. Au moment où votre TypeScript s'exécute, `cdk synth`, `cdk diff` et `cdk deploy` sont indiscernables. On ne peut pas écrire « si c'est un deploy, refuser ». Toute défense doit agir au niveau du template ou au niveau de CloudFormation.

Deuxièmement, la valeur par défaut d'un indicateur est une décision de conception. Un indicateur dont la valeur par défaut est destructrice est une arme chargée posée sur la table. Le cas de mise en service ponctuelle aurait dû être l'option à activer explicitement, pas le cas courant.

Troisièmement, le comportement de suppression de CloudFormation est correct et ne changera pas. Une infrastructure déclarative signifie que « le template fait foi ». Si une ressource est absente du template, elle disparaît. La seule question est de savoir si vous avez indiqué à CloudFormation que certaines ressources sont trop importantes pour être supprimées sans résistance.

## Les quatre filets de sécurité

Nous avons livré les quatre en l'espace d'une semaine. Ce sont des couches indépendantes ; chacune d'elles aurait, à elle seule, empêché l'incident, et ensemble elles couvrent des cas que les autres ne couvrent pas.

<div class="article-figure">
<svg viewBox="0 0 900 330" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Quatre couches de défense indépendantes autour des ressources critiques : des flags de contexte sûrs par défaut, la termination protection sur le stack, un aspect RETAIN sur les types de ressources critiques et des déploiements uniquement depuis la CI avec une bannière d'avertissement pour les exécutions locales.">
<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. Déploiements CI uniquement + bannière hors CI</text>
<text x="874" y="36" text-anchor="end" fill="#9aa3c7" font-size="12">procédural · empêche le mauvais laptop de déployer</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. Flags sûrs par défaut</text>
<text x="829" y="81" text-anchor="end" fill="#9aa3c7" font-size="12">un flag oublié ne supprime jamais une ressource</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">bloque cdk destroy et 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. Aspect RETAIN</text>
<text x="739" y="171" text-anchor="end" fill="#9aa3c7" font-size="12">une ressource retirée du template continue de tourner</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. Des indicateurs sûrs par défaut

`hostingReady` a désormais pour valeur par défaut **true**. Le cas de mise en service, où il faut créer les dépôts ECR avant les services, devient `-c skipHosting=true`, que personne ne tape par accident. Chaque indicateur de contexte de la pile a été revu avec la même question : « si quelqu'un l'oublie, dans quel sens penche l'erreur ? » Un indicateur oublié ne doit jamais supprimer une ressource.

### 2. Protection contre la suppression de la pile

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

Cela bloque `cdk destroy` et le bouton « Delete stack » de la console jusqu'à ce qu'un opérateur le désactive explicitement. Cela n'aurait pas empêché l'incident d'avril à lui seul, car la pile avait été mise à jour, pas supprimée, mais cela ferme la porte voisine. Cela ne coûte rien.

### 3. Un aspect RETAIN sur les types de ressources critiques

C'est celle qui répond directement à l'incident. Un Aspect CDK parcourt chaque construct de l'arbre après la synthèse et fixe la `DeletionPolicy` CloudFormation à `Retain` pour les types de ressources que nous considérons comme critiques :

```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());
```

Avec `Retain`, lorsqu'un template cesse de déclarer une ressource, CloudFormation la retire de la comptabilité de la pile mais laisse la ressource AWS réelle continuer à fonctionner. Dans le scénario d'avril, les services auraient continué à servir le trafic, et le déploiement correct suivant aurait nécessité un import plutôt qu'une création. C'est une contrariété, pas une panne.

Deux choix de conception méritent d'être signalés. L'aspect respecte les décisions explicites : si un construct a défini `DESTROY` intentionnellement (un bucket de dev avec `autoDeleteObjects`, par exemple), l'aspect n'y touche pas, car passer outre casserait la synthèse ou annulerait silencieusement un choix délibéré. Et l'aspect s'applique à chaque étape, y compris en dev, car le risque d'incident est le même partout où vivent de vrais utilisateurs ou de vraies données de test.

Le compromis : un `cdk destroy` délibéré laisse désormais des ressources orphelines derrière lui. Le nettoyage consiste en une suppression manuelle par ressource. Nous l'acceptons ; c'est le prix explicite de la sécurité.

### 4. Les déploiements se font depuis la CI, et la CLI vous le rappelle

La dernière couche est procédurale. Les trois environnements se déploient depuis GitHub Actions via des rôles assumés par OIDC, chacun déclenché par sa propre branche. Un `cdk synth` ou `cdk diff` local est acceptable et encouragé. Un `cdk deploy` local ne l'est pas, et comme CDK ne peut pas le bloquer, l'application affiche une bannière chaque fois qu'elle s'exécute en dehors de la 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.
```

La bannière est supprimée par `CI=true` ou par une variable de confirmation explicite. Ce n'est pas un contrôle technique, et nous ne prétendons pas que ça l'est. C'est un rappel au moment précis où un rappel est utile, et il fait référence à l'incident par sa date pour que personne n'ait à demander pourquoi.

## Ce que nous vous conseillerions de vérifier dès aujourd'hui

Vous n'avez pas besoin d'avoir vécu cet incident pour en tirer profit. Trois questions à vous poser cette semaine à propos de vos propres piles CDK :

1. **Lequel de vos indicateurs de contexte ou de vos variables d'environnement supprime une ressource s'il est oublié ?** Inversez leurs valeurs par défaut.
2. **Quelle est la `DeletionPolicy` de vos bases de données, user pools et files d'attente ?** Exécutez `cdk synth` et faites un grep sur le template. Si la réponse est `Delete` ou absente, un aspect d'une seule ligne suffit à corriger cela.
3. **Un ordinateur portable peut-il déployer en production ?** Si oui, qu'est-ce qui empêche un mauvais `AWS_PROFILE` ? L'épinglage du compte dans `env` et un chemin de déploiement réservé à la CI coûtent tous les deux peu.

Une leçon supplémentaire tirée d'un second incident, plus modeste, en août : des alias CloudFront et un certificat attachés manuellement ont été effacés par le déploiement CDK suivant, parce que CloudFormation remplace toute la configuration de la distribution. Même cause profonde sous un autre déguisement : **tout ce qui n'est pas dans le template n'existe pas**. Les politiques Retain protègent les ressources ; elles ne protègent pas les propriétés. La seule défense pour les propriétés consiste à les inscrire dans le code.

Si vous souhaitez un second regard sur votre configuration CDK avant qu'elle ne vous enseigne elle-même cette leçon, [contactez-nous](/contact).
