# Trois comptes AWS, une seule base de code CDK : comment nous empêchons dev, staging et prod de se contaminer mutuellement

La plupart des articles AWS de ce blog ont jusqu'ici été des guides : ce que fait un service, comment le mettre en place, ce qu'il coûte. Celui-ci est différent. Il documente un dispositif réel que nous avons construit et que nous exploitons pour un client en 2026 : une plateforme multi-applications (trois apps Next.js, quatre fonctions Lambda, une API GraphQL, DynamoDB, Aurora Serverless v2, S3, Cognito) qui tourne dans **trois comptes AWS distincts**, un par environnement, tous déployés depuis une **seule base de code CDK**.

Nous ne nommons volontairement ni le client, ni le produit, ni aucun identifiant de compte. Ce que nous partageons, c'est la structure, les décisions, et les erreurs qui les ont façonnées. Si vous êtes une petite équipe qui a dépassé le stade « un seul compte avec un suffixe `-dev` sur tout », voici le dispositif que nous recommanderions.

## Pourquoi trois comptes et non un seul compte avec trois stacks

La plateforme a démarré en février 2026 dans un seul compte géré à la main. En avril, un audit de sécurité interne a produit une liste de constats qui remontaient tous à la même cause profonde : les ressources dev, staging et prod partageaient des rôles IAM, des clés KMS partagées, des autorisations wildcard partagées (politiques de table du style `platform-*`), et il n'y avait aucun moyen de dire « ce credential ne peut toucher que dev ».

Un compte AWS est la seule frontière de sécurité dure que fournit AWS. Tout le reste — tags, conventions de nommage, conditions IAM — est une frontière souple qu'un seul wildcard mal placé annule. La décision a donc été la suivante :

- **dev** — un compte greenfield, entièrement géré par CDK, démantèlement bon marché autorisé.
- **staging** — le compte historique, reprovisionné à partir du même code CDK après une migration contrôlée (mai 2026).
- **prod** — un troisième compte dédié, monté à partir de zéro en juin et lancé fin juillet.

Même région dans les trois. Des CIDR VPC non chevauchants (`10.50`, `10.51`, `10.52`) pour que le peering reste possible plus tard. Chaque compte a ses propres clés KMS, son propre pool Cognito, ses propres secrets, son propre CloudTrail.

<div class="article-figure">
<svg viewBox="0 0 900 300" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Une seule base de code CDK, trois environnements, trois comptes AWS : dev depuis la branche dev, staging depuis la branche staging, prod depuis la branche master">
<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 = source unique de vérité</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">Compte : dev</text>
<text x="460" y="76" text-anchor="middle" fill="#9aa3c7" font-size="11">branche 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">Compte : staging</text>
<text x="460" y="166" text-anchor="middle" fill="#9aa3c7" font-size="11">branche staging · RETAIN · sandbox IDV</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">Compte : prod</text>
<text x="460" y="256" text-anchor="middle" fill="#9aa3c7" font-size="11">branche master · RETAIN · Object Lock 7 ans · 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">Par compte, jamais partagé</text>
<text x="660" y="85" fill="#9aa3c7" font-size="12">• Clés KMS</text>
<text x="660" y="108" fill="#9aa3c7" font-size="12">• Pool d'utilisateurs Cognito</text>
<text x="660" y="131" fill="#9aa3c7" font-size="12">• Entrées Secrets Manager</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">• Rôle de déploiement GitHub OIDC</text>
<text x="660" y="223" fill="#9aa3c7" font-size="12">• Bootstrap CDK</text>
<text x="660" y="246" fill="#9aa3c7" font-size="12">• Balises d'allocation des coûts</text>
</g>
</svg>
</div>

## Une seule base de code, un seul `config.ts`

Tout l'intérêt d'avoir trois comptes est que le code est *identique* et que seule la configuration diffère. Nous l'imposons avec un seul fichier, `infra/lib/config.ts`, qui est le seul endroit où les environnements sont autorisés à différer. Chaque construct reçoit un objet `StageConfig` et ne se demande jamais directement « suis-je en prod ? ».

Le point d'entrée ressemble à peu près à ceci :

```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' },
});
```

Deux détails comptent ici. D'abord, le nom du stack porte le stage, donc `PlatformStack-dev` et `PlatformStack-prod` ne peuvent jamais être confondus. Ensuite, le compte est fixé dans `env` : CDK refuse de déployer si les identifiants que vous détenez se résolvent vers un compte différent de celui attendu par le stage. Vous ne pouvez pas déployer prod avec des identifiants dev par accident, et vous ne pouvez pas déployer dev dans le compte prod par accident.

## La matrice de durcissement

C'est la partie qu'on afficherait au mur. Plutôt que de disséminer `if (stage === 'prod')` dans toute la base de code, chaque stage renvoie un bloc de configuration typé, et les différences se lisent comme un tableau :

| Paramètre | dev | staging | prod |
|---|---|---|---|
| Politique de suppression | DESTROY | RETAIN | RETAIN |
| Point-in-time recovery DynamoDB | désactivé | activé | activé |
| KMS géré par le client (DynamoDB) | activé | activé | activé |
| DynamoDB Streams sur les tables sensibles | désactivé | activé | activé |
| Cycle de vie du coffre de documents (S3) | 30 jours | 30 jours | 7 ans + Object Lock |
| Cognito Advanced Security | audit | audit | audit (enforce est une décision liée au lancement) |
| Journalisation complète des requêtes AppSync | activé | activé | désactivé |
| Traçage X-Ray | activé | activé | activé |
| WAF sur CloudFront + AppSync | activé | activé | activé |
| AWS Config | désactivé | activé | activé |
| CloudTrail + GuardDuty | activé | activé | activé |
| Capacité Aurora Serverless v2 | 0.5–4 ACU | 0.5–4 ACU | 2–16 ACU |
| Rétention des sauvegardes Aurora | 7 jours | 7 jours | 30 jours |
| Concurrence réservée de la Lambda de redirection | 10 | 50 | 200 |
| Vérification d'identité tierce | désactivé (mock) | sandbox | production |

En lisant ce tableau, on comprend immédiatement ce que « dev » signifie pour nous : bon marché, jetable, mais avec la même *forme* que prod. Le chiffrement KMS est activé partout, car l'activer plus tard signifierait recréer les tables. Le WAF est activé partout, car une règle WAF qui n'existe qu'en prod est une règle que personne n'a testée.

Le bloc dev porte aussi une ligne dont nous sommes fiers : une allowlist d'e-mails fixée à une seule adresse puits qui ne correspond jamais. Dev contient des copies de véritables enregistrements utilisateurs, donc le transport d'e-mails abandonne chaque message sortant. C'est une garantie produit exprimée dans la configuration d'infrastructure, pas un commentaire dans un README.

## Une branche vers un compte, pas un ordinateur portable vers un compte

Chaque compte est alimenté par exactement une branche Git : `dev` → compte dev, `staging` → compte staging, `master` → compte prod. La promotion vers prod est un merge fast-forward, jamais un rebase, de sorte que le SHA de commit testé sur dev est le même SHA qui est livré en prod.

Côté CI/CD, nous utilisons le fournisseur OIDC de GitHub au lieu de clés d'accès à longue durée de vie. Chaque compte dispose de deux rôles IAM :

- `github-actions-cdk-diff` — lecture seule, assumable uniquement depuis les exécutions de pull request. Il exécute `cdk diff` afin que les relecteurs voient les changements d'infrastructure avant la fusion.
- `github-actions-cdk-deploy` — assumable uniquement depuis les push sur la branche de ce compte. Il ne détient pas lui-même de larges pouvoirs IAM ; il délègue aux rôles de bootstrap CDK. Élargir ce que CDK peut déployer passe par `cdk bootstrap`, pas par la modification d'une policy.

La trust policy fixe le dépôt et la ref exacte, de sorte qu'un fork ou une branche de fonctionnalité ne peut pas assumer le rôle de déploiement.

Nous avons aussi appris, à nos dépens, que **`cdk deploy` depuis un ordinateur portable est un bug, pas une fonctionnalité**. Un déploiement local sans les bons flags de contexte a un jour synthétisé un template auquel manquaient les services App Runner, et CloudFormation les a consciencieusement supprimés. Cet incident a eu son propre article ; en résumé, la protection contre la terminaison, un aspect RETAIN sur les types de ressources critiques, et une bannière bien visible quand CDK s'exécute en dehors de la CI font désormais partie de la base de code.

<div class="article-figure">
<svg viewBox="0 0 900 330" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Comment un événement Git atteint un compte AWS : les pull requests assument le rôle cdk-diff en lecture seule, les pushes sur dev, staging ou master assument le rôle cdk-deploy de ce compte, qui délègue aux rôles de bootstrap CDK. Aucune clé d'accès de longue durée.">
<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">lecture seule · 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 uniquement · délègue au bootstrap CDK</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">Compte: 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 uniquement · délègue au bootstrap CDK</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">Compte: 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 uniquement · délègue au bootstrap CDK</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">Compte: prod</text>
<text x="450" y="312" text-anchor="middle" fill="#9aa3c7" font-size="12">Trust policy = ce dépôt + cette ref exacte. Zéro clé d'accès de longue durée.</text>
</g>
</svg>
</div>

## Les tags permettent de savoir ce que ça coûte

Chaque ressource, dans chaque compte, porte trois tags : `Project`, `Stage`, `ManagedBy`. Avec les tags d'allocation des coûts activés dans Billing, la facture mensuelle se répartit proprement par environnement. Les chiffres que nous pouvons partager sans rompre la confidentialité :

- Maintenir Aurora Serverless v2 à un plancher de 0.5 ACU plutôt que l'auto-pause coûte environ 30 dollars par mois et par environnement. Nous le payons aussi sur dev, car l'auto-pause imposait un cold start de 15–30 secondes sur le parcours d'inscription.
- Le compte dev est de loin le moins cher des trois, principalement grâce aux politiques DESTROY, à l'absence d'AWS Config, et aux instances App Runner de taille minimale.
- Le plancher fixe de prod est dominé par deux instances App Runner chaudes pour l'application publique, le plancher Aurora, et le WAF.

Sans les tags, « combien nous coûte staging » est une question dont la réponse prend un après-midi. Avec eux, c'est un filtre dans Cost Explorer.

## Ce qui a mal tourné en chemin

Un compte-rendu honnête doit inclure les bosses.

**La limite de 500 ressources de CloudFormation.** Un stack par compte était la conception d'origine. En août, le stack prod se synthétisait à exactement 500 ressources et dev à 499. Ajouter une seule Lambda cassait `cdk synth`. Nous avons scindé l'observabilité (politiques de protection des données de logs, metric filters, alarms, SNS) dans son propre stack, puis déplacé plus tard une application entière dans un autre. La leçon : concevoir dès le premier jour pour plusieurs stacks par compte, avec des exports explicites, et ne pas laisser un seul stack dépasser environ 350 ressources.

**Le drift meurt au prochain déploiement.** Des alias CloudFront et un certificat ACM avaient été attachés à la main à une distribution en juillet. Le déploiement CDK d'août a remplacé l'intégralité du `DistributionConfig` et les a supprimés silencieusement. Tout ce qui n'est pas dans le code n'existe pas. Nous conservons désormais les alias et les ARN de certificat dans la config du stage.

**L'environnement au moment du build n'est pas l'environnement au moment de l'exécution.** Next.js intègre les variables `NEXT_PUBLIC_*` au moment du build. Les variables d'environnement à l'exécution sur le conteneur ne peuvent plus les modifier. Notre script de déploiement lit désormais la configuration de build à partir du template synthétisé et la transmet à CodeBuild à chaque build, de sorte qu'une variable publique modifiée atteint le navigateur dès le premier déploiement plutôt qu'au second.

**La limite de débit qui a bloqué toute l'équipe.** La règle de débit par IP du WAF était fixée à 10 000 requêtes par 5 minutes. L'équipe QA du client se trouve derrière une seule adresse NAT de bureau. Une session de test intensive a déclenché la règle, et chaque requête depuis le bureau a reçu un 403 pendant cinq minutes. Elle est désormais à 30 000, et c'est dans `config.ts`, pas dans la console.

## Le referions-nous ?

Oui, et plus tôt. Le coût de trois comptes est réel mais faible : trois bootstraps CDK, trois rôles OIDC, trois jeux de secrets à renseigner, et un seul fichier de config qui doit rester honnête. Le coût de *ne pas* les avoir est la liste de constats d'audit par laquelle nous avons commencé, plus l'inquiétude permanente qu'un script dev avec une autorisation wildcard puisse toucher des données de production.

Si vous mettez cela en place pour la première fois, l'ordre que nous recommandons est : écrire d'abord `config.ts` avec la matrice de durcissement, même si la moitié des paramètres ne sont pas encore implémentés ; fixer les comptes dans `env` et activer la protection contre la terminaison avant le premier déploiement ; câbler OIDC avant de donner à quiconque des identifiants de déploiement ; et scinder en plusieurs stacks avant d'en avoir besoin.

Besoin d'aide pour structurer vos propres comptes AWS et votre code CDK de cette manière ? [Parlez-nous](/contact) — nous l'avons fait une fois à la dure pour que vous n'ayez pas à le faire.
