# Des environnements de preview par pull request sur AWS, sans Vercel

La revue de code a un plafond. Un relecteur peut lire un diff et raisonner dessus, mais il ne peut pas cliquer dessus. Pour un produit avec une interface, le commentaire de revue le plus utile est « je l'ai ouvert, j'ai essayé le parcours, et la deuxième étape perd l'état du formulaire », et vous n'obtenez ce commentaire que si le relecteur a une URL. Vercel a bâti une entreprise sur ce constat. Si votre application est un front-end statique, utilisez Vercel et arrêtez de lire.

La nôtre ne l'est pas. La plateforme que nous faisons tourner pour un client américain, ce sont trois applications Next.js plus une couche d'API, des Lambdas, des tables DynamoDB et une base Aurora, le tout défini en CDK et déployé sur AWS. Une preview front-end seule pointée vers le backend de dev partagé est un mensonge : elle montre au relecteur la nouvelle interface qui parle à l'ancienne API. Nous voulions tout le stack par pull request, et nous voulions que ça coûte environ un dollar. Voici ce que nous avons construit.

## Ce qui est par pull request, et ce qui ne l'est pas

L'astuce, c'est de décider ce qui doit être dupliqué. Tout dupliquer est lent à créer et cher à conserver. Ne rien dupliquer, c'est Vercel. La ligne que nous avons tracée :

<div class="article-figure">
<svg viewBox="0 0 900 300" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Deux colonnes. Par pull request : un stack CloudFormation contenant un service App Runner à taille minimale, les fonctions Lambda, les tables DynamoDB et un schéma de base de données. Partagé dans le compte de dev : le VPC et la NAT gateway, le cluster Aurora Serverless v2, le dépôt ECR, les secrets Secrets Manager et les clés KMS. Flèche du stack par PR vers les ressources partagées, étiquetée : recherchées par nom, jamais créées.">
<g font-family="Inter,system-ui,sans-serif" font-size="13">
<rect x="20" y="20" width="400" height="260" rx="14" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/>
<text x="220" y="48" text-anchor="middle" fill="#4fffb0" font-size="14" font-weight="700">Par pull request · stack pr-123</text>
<rect x="40" y="66" width="360" height="40" rx="8" fill="#0d1120" stroke="#2a3150"/><text x="220" y="91" text-anchor="middle" fill="#f1f3ff">Service App Runner · 0,25 vCPU / 0,5 Go</text>
<rect x="40" y="114" width="360" height="40" rx="8" fill="#0d1120" stroke="#2a3150"/><text x="220" y="139" text-anchor="middle" fill="#f1f3ff">Fonctions Lambda (toutes)</text>
<rect x="40" y="162" width="360" height="40" rx="8" fill="#0d1120" stroke="#2a3150"/><text x="220" y="187" text-anchor="middle" fill="#f1f3ff">Tables DynamoDB · à la demande · DESTROY</text>
<rect x="40" y="210" width="360" height="40" rx="8" fill="#0d1120" stroke="#2a3150"/><text x="220" y="235" text-anchor="middle" fill="#f1f3ff">Base Postgres pr_123 sur le cluster partagé</text>
<text x="220" y="268" text-anchor="middle" fill="#9aa3c7" font-size="12">créé en ~6 min · détruit au merge ou à la fermeture</text>
<rect x="480" y="20" width="400" height="260" rx="14" fill="#151b2e" stroke="#ffd166" stroke-width="1.5"/>
<text x="680" y="48" text-anchor="middle" fill="#ffd166" font-size="14" font-weight="700">Partagé · compte de dev</text>
<rect x="500" y="66" width="360" height="40" rx="8" fill="#0d1120" stroke="#2a3150"/><text x="680" y="91" text-anchor="middle" fill="#f1f3ff">VPC · sous-réseaux · l'unique NAT gateway</text>
<rect x="500" y="114" width="360" height="40" rx="8" fill="#0d1120" stroke="#2a3150"/><text x="680" y="139" text-anchor="middle" fill="#f1f3ff">Cluster Aurora Serverless v2 (auto-pause)</text>
<rect x="500" y="162" width="360" height="40" rx="8" fill="#0d1120" stroke="#2a3150"/><text x="680" y="187" text-anchor="middle" fill="#f1f3ff">Dépôts ECR · projets CodeBuild</text>
<rect x="500" y="210" width="360" height="40" rx="8" fill="#0d1120" stroke="#2a3150"/><text x="680" y="235" text-anchor="middle" fill="#f1f3ff">Secrets Manager · clés KMS · WAF</text>
<text x="680" y="268" text-anchor="middle" fill="#9aa3c7" font-size="12">recherchés par nom depuis le stack PR, jamais créés par lui</text>
<path d="M420,150 L478,150" stroke="#7b8cff" stroke-width="2" stroke-dasharray="5,4"/>
</g>
</svg>
</div>

Tout ce qui a un prix à l'heure et un temps de création lent est partagé : le VPC, le NAT, le cluster Aurora. Tout ce qui est gratuit au repos et rapide à créer est par PR : App Runner à taille minimale, les Lambdas, les tables DynamoDB. La base de données est le compromis. Un cluster séparé par PR prendrait 10 minutes et coûterait 40 $ par mois au plancher de 0,5 ACU ; une *base* séparée sur le cluster partagé, c'est un `CREATE DATABASE` et ça ne coûte rien. Les migrations tournent sur `pr_123` à la création du stack, exactement comme en production, donc une PR qui change le schéma a une preview avec son propre schéma.

## Le côté CDK

L'environnement existait déjà comme valeur de contexte CDK : `dev`, `staging`, `prod`. Une preview est une quatrième variante, `pr`, avec un numéro.

```ts
// bin/app.ts
const env = app.node.tryGetContext('env') ?? 'dev';
const pr = app.node.tryGetContext('pr');           // "123" ou undefined
const suffix = pr ? `-pr-${pr}` : '';

new PlatformStack(app, `platform-${env}${suffix}`, {
  env: accounts[env === 'pr' ? 'dev' : env],
  config: {
    ...configs[env === 'pr' ? 'dev' : env],
    removalPolicy: pr ? RemovalPolicy.DESTROY : configs[env].removalPolicy,
    warmInstances: pr ? 1 : configs[env].warmInstances,
    instanceSize: pr ? 'small' : configs[env].instanceSize,
    databaseName: pr ? `pr_${pr}` : 'app',
    previewToken: pr ? Secret.fromSecretNameV2(...).secretValue : undefined,
  },
});
```

Le stack lui-même ne sait pas qu'il est une preview. Il reçoit une configuration où la politique de suppression dit DESTROY, l'instance est petite, le nom de la base contient un numéro. Les ressources partagées sont importées avec `Vpc.fromLookup`, `DatabaseCluster.fromDatabaseClusterAttributes` et `Repository.fromRepositoryName`, ce que le stack de dev faisait déjà pour les références entre stacks. Le diff pour supporter les previews faisait moins de cent lignes, et l'essentiel était la plomberie de configuration ci-dessus.

## Le côté GitHub Actions

Deux workflows. Le premier tourne sur `pull_request` avec les types `opened`, `synchronize` et `reopened` :

```yaml
jobs:
  preview:
    runs-on: ubuntu-latest
    permissions: { id-token: write, contents: read, pull-requests: write }
    concurrency: preview-${{ github.event.number }}
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::<dev-account>:role/github-deploy-dev
          aws-region: us-east-1
      - run: ./scripts/build-images.sh --tag pr-${{ github.event.number }}-${{ github.sha }}
      - run: npx cdk deploy platform-pr-${{ github.event.number }} --context env=pr --context pr=${{ github.event.number }} --require-approval never
      - run: ./scripts/comment-preview-url.sh ${{ github.event.number }}
```

Le second tourne sur `pull_request` avec le type `closed`, et c'est une seule étape : `cdk destroy` du même nom de stack, suivi de `DROP DATABASE pr_123` via une petite Lambda qui a l'accès réseau au cluster. Mergée ou abandonnée, la PR emporte son environnement avec elle.

Le script de commentaire lit l'URL App Runner dans les sorties du stack et la poste une fois, puis édite le même commentaire à chaque push pour que la PR ne se remplisse pas de bruit de bot. L'URL est le nom d'hôte `*.awsapprunner.com`. Nous ne créons pas de domaines personnalisés pour les previews : une validation de certificat par PR ajoute cinq minutes et n'apporte rien.

## Du push à l'URL : environ six minutes

| Étape | Temps |
|---|---|
| Checkout, prise de rôle | 20 s |
| Build de trois images dans CodeBuild, en parallèle | ~3 min |
| `cdk deploy` sur un nouveau stack, création du service App Runner incluse | ~2,5 min |
| Migrations sur `pr_123` | 10 s |
| Commentaire sur la PR | 2 s |

Aux pushes suivants, le stack existe, donc seul le tag d'image change et App Runner fait une mise à jour progressive : environ quatre minutes. Pas les quarante secondes de Vercel, mais assez rapide pour qu'un relecteur qui demande un changement le voie dans la même session.

<div class="article-figure">
<svg viewBox="0 0 900 190" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Cycle de vie d'un environnement de preview : pull request ouverte, images construites et taguées avec le numéro de PR et le commit, cdk deploy du stack pr-123 dans le compte de dev, URL commentée sur la pull request, chaque push met à jour le même stack, la pull request mergée ou fermée déclenche cdk destroy et drop database.">
<defs><marker id="arrPv" 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="140" height="60" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="85" y="66" text-anchor="middle" fill="#f1f3ff" font-weight="700">PR ouverte</text><text x="85" y="84" text-anchor="middle" fill="#9aa3c7">ou push</text>
<line x1="157" y1="70" x2="185" y2="70" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrPv)"/>
<rect x="188" y="40" width="150" height="60" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="263" y="66" text-anchor="middle" fill="#f1f3ff" font-weight="700">build des images</text><text x="263" y="84" text-anchor="middle" fill="#9aa3c7">tag pr-123-&lt;sha&gt;</text>
<line x1="340" y1="70" x2="368" y2="70" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrPv)"/>
<rect x="371" y="40" width="170" height="60" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="456" y="66" text-anchor="middle" fill="#f1f3ff" font-weight="700">cdk deploy pr-123</text><text x="456" y="84" text-anchor="middle" fill="#9aa3c7">compte dev · DESTROY</text>
<line x1="543" y1="70" x2="571" y2="70" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrPv)"/>
<rect x="574" y="40" width="140" height="60" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="644" y="66" text-anchor="middle" fill="#f1f3ff" font-weight="700">URL sur la PR</text><text x="644" y="84" text-anchor="middle" fill="#9aa3c7">un commentaire, édité</text>
<line x1="716" y1="70" x2="744" y2="70" stroke="#ff6b8a" stroke-width="1.5" marker-end="url(#arrPv)"/>
<rect x="747" y="40" width="140" height="60" rx="10" fill="#151b2e" stroke="#ff6b8a" stroke-width="1.5"/><text x="817" y="66" text-anchor="middle" fill="#f1f3ff" font-weight="700">mergée / fermée</text><text x="817" y="84" text-anchor="middle" fill="#ff6b8a">destroy + drop db</text>
<path d="M644,102 C644,140 263,140 263,102" fill="none" stroke="#9aa3c7" stroke-width="1.2" stroke-dasharray="4,3"/>
<text x="456" y="150" text-anchor="middle" fill="#9aa3c7">chaque push : même stack, nouveau tag d'image, mise à jour progressive en ~4 min</text>
<text x="456" y="178" text-anchor="middle" fill="#ffd166">balayeur TTL : tout stack pr-* de plus de 7 jours est détruit quoi qu'il arrive</text>
</g>
</svg>
</div>

## Les trois choses qui l'empêchent de devenir un chaos

**Un balayeur.** Les workflows échouent. Un événement `closed` se perd quand GitHub a un incident, ou quand quelqu'un supprime la branche depuis la CLI. Une Lambda planifiée liste les stacks CloudFormation nommés `platform-pr-*`, vérifie la date de création et détruit tout ce qui a plus de sept jours. Elle a réellement servi quatre fois. Sans elle, les previews mortes s'accumulent à 5 $ par mois chacune, en silence.

**Un token de preview.** Les previews sont sur des URL publiques avec des données d'apparence réelle. Un middleware Next.js vérifie un cookie posé par `/preview?token=…`, le token étant lu depuis un secret partagé à l'exécution, pas figé au build. Les relecteurs cliquent une fois sur le lien du commentaire de PR, qui porte le token, puis naviguent normalement. Ce n'est pas une frontière de sécurité contre un attaquant déterminé, mais ça tient les robots et le partage accidentel à l'écart, et les données derrière sont des fixtures, jamais de la production.

**Des fixtures, pas des données de prod.** La base par PR est peuplée depuis un fichier de fixtures versionné dans le dépôt : une poignée de comptes, de commandes, les états qui comptent pour la revue. Copier des données de production dans les previews est le moyen le plus rapide de les faire fuiter, et ça rend aussi les previews lentes à créer. Si un relecteur a besoin d'un état précis, il l'ajoute aux fixtures, et toute preview future l'aura.

## Ce que ça coûte

| Élément | Par PR, ouverte 3 jours |
|---|---|
| App Runner, 0,25 vCPU / 0,5 Go, surtout au repos | ~0,50 $ |
| DynamoDB à la demande, quelques milliers de requêtes | ~0,01 $ |
| Lambda | ~0,00 $ |
| Base de données sur le cluster partagé | 0 $ |
| CodeBuild, 3 images × ~4 builds | ~1,20 $ |
| **Total** | **~1,70 $** |

Avec vingt pull requests par mois, c'est environ 35 $, dont CodeBuild fait l'essentiel. Le cluster Aurora partagé en dev existait déjà et se met en pause tout seul ; les previews le maintiennent éveillé un peu plus, ce qui est réel mais difficile à mesurer. Comparé à un siège Vercel Pro par développeur, c'est moins cher, et contrairement à Vercel, ça prévisualise aussi l'API et la couche de données.

## Quand ne pas faire ça

Si votre backend est stable et que vos PR sont presque toujours du front-end, une preview front-end seule contre le dev partagé convient et est bien plus simple. Si votre stack prend vingt minutes à créer parce qu'il a un ALB, une instance RDS et un cluster Redis, les environnements par PR seront trop lents pour être utiles, et vous devriez regarder les namespaces sur un cluster partagé ou les bases éphémères avec branching. Et si vous avez plus d'une poignée de développeurs, surveillez les limites de débit de l'API CloudFormation : vingt `cdk deploy` concurrents dans un compte les atteindront.

Pour une équipe de trois, qui livre plusieurs pull requests par jour sur un stack qui se crée en six minutes, c'est le meilleur investissement en expérience développeur que nous ayons fait. Les revues sont devenues plus rapides, et « ça marche sur ma machine » a cessé d'être un argument, parce que la machine est la même.

Vous voulez des previews pour votre propre stack, sur votre propre compte AWS ? [Parlons-en](/contact).
