Skip to content
Config au build vs config à l'exécution dans Next.js sur conteneurs : le bug qui a demandé deux déploiements
← ← Retour aux Réflexions Development

Config au build vs config à l'exécution dans Next.js sur conteneurs : le bug qui a demandé deux déploiements

Nous avons basculé un feature flag. Le flag était une variable d'environnement sur le service App Runner, NEXT_PUBLIC_NEW_CHECKOUT=true, changée via CDK, déployée proprement, service en bonne santé. L'ancien checkout est resté. Nous avons vérifié l'environnement sur l'instance en cours : la variable était là, à true. Nous avons redémarré le service. Toujours l'ancien checkout. Il a fallu un second déploiement, avec un changement de code qui ne touchait à rien de lié, pour que le nouveau checkout apparaisse.

L'explication tient en une phrase, et tout développeur Next.js l'a lue : les variables NEXT_PUBLIC_* sont intégrées dans le bundle JavaScript au moment du build. Nous l'avions lue aussi. Ce que nous n'avions pas intériorisé, c'est ce que ça signifie quand votre build et votre exécution sont deux endroits différents, ce qui est exactement ce que donnent les conteneurs. Cet article est le règlement que nous avons écrit ensuite.

Trois sortes de configuration, pas deux

La documentation Next.js parle de build et d'exécution. Sur une plateforme de conteneurs, il y a en réalité trois sortes, et la troisième est celle qui mord.

Sorte Exemple Où elle vit Quand elle peut changer
Build, publique NEXT_PUBLIC_API_URL Intégrée dans les chunks client sous .next/static Seulement en reconstruisant l'image
Exécution, serveur seulement DATABASE_URL, clés d'API process.env dans les server components, route handlers, middleware À la requête suivante après le changement
Exécution, publique Un feature flag dont le navigateur a besoin Nulle part, par défaut. Vous devez construire le chemin vous-même Quand vous voulez, si vous le construisez

La première sorte est ce que donne NEXT_PUBLIC_ : rapide, statique et figée à next build. La deuxième fonctionne comme tout le monde s'y attend ; un server component qui lit process.env.DATABASE_URL la lit à la requête, et un changement sur le service App Runner prend effet au prochain démarrage d'instance. La troisième est le piège : une valeur dont le navigateur a besoin, que vous aimeriez changer sans rebuild. Next.js n'a pas de réponse intégrée, alors les gens attrapent NEXT_PUBLIC_ et obtiennent la première sorte par accident.

Build · CodeBuild · une fois par image next build NEXT_PUBLIC_* → intégré dans les chunks .next/static image poussée vers ECR, taguée avec le SHA git Exécution · App Runner · à chaque démarrage le conteneur démarre avec l'environnement du service le code serveur lit process.env à la requête ✓ les chunks gardent les valeurs NEXT_PUBLIC du build ✗ Changer NEXT_PUBLIC_* sur le service ne change rien de ce que voit le navigateur tant que l'image n'est pas reconstruite.

La règle : une seule image, tous les environnements

La décision qui a corrigé la classe de bugs, pas seulement l'occurrence : la même image tourne en dev, staging et production. Pas de builds spécifiques à un environnement. Si l'image est la même, alors par construction rien de ce qui diffère entre environnements ne peut être du build, et la question « c'est du build ou de l'exécution ? » se répond d'elle-même pour chaque nouvelle variable.

Ce qui reste au build est très court : le SHA git, la date du build, et les SDK tiers qui exigent une clé statique au moment du bundle. Tout le reste, y compris tout ce qui est public, est à l'exécution. Et parce que l'image est la même, promouvoir un build de staging vers la production est un changement de tag sur le service App Runner, pas un rebuild. C'est la fonctionnalité qui paie la discipline.

La config publique à l'exécution, bien faite

Le navigateur a quand même besoin de certaines valeurs. Au lieu de NEXT_PUBLIC_, le layout racine, qui est un server component, lit l'environnement à la requête et donne au navigateur exactement les clés qu'il doit avoir :

// app/layout.tsx (server component)
import { PublicConfigProvider } from '@/lib/public-config';

const publicConfig = () => ({
  apiUrl: process.env.API_URL!,
  newCheckout: process.env.FEATURE_NEW_CHECKOUT === 'true',
  release: process.env.RELEASE_SHA ?? 'dev',
});

export default function RootLayout({ children }) {
  return (
    <html lang="fr">
      <body>
        <PublicConfigProvider value={publicConfig()}>{children}</PublicConfigProvider>
      </body>
    </html>
  );
}
// lib/public-config.tsx
'use client';
import { createContext, useContext } from 'react';
const Ctx = createContext<ReturnType<typeof publicConfig> | null>(null);
export const PublicConfigProvider = Ctx.Provider;
export const usePublicConfig = () => {
  const c = useContext(Ctx);
  if (!c) throw new Error('PublicConfigProvider missing');
  return c;
};

Trois propriétés de ce pattern comptent. La liste blanche est explicite : publicConfig() nomme chaque clé qui atteint le navigateur, donc un secret ne peut pas fuiter à cause d'un mauvais préfixe. Les valeurs sont lues par requête, donc un changement sur le service est actif au prochain démarrage d'instance, sans rebuild. Et le layout ne doit pas être prérendu statiquement pour que ça marche, ce qui dans l'App Router signifie que le layout, ou quelque chose dedans, est dynamique ; nous lisons de toute façon headers() dans le layout pour la locale, ce qui le rend déjà dynamique. Si votre layout est statique, enveloppez la lecture dans unstable_noStore() ou connection() selon votre version de Next.js.

Le middleware reçoit le même traitement : il lit process.env en bordure de chaque requête, ce qui est de l'exécution par nature, donc le token de preview et le flag de maintenance y vivent sans aucune de ces cérémonies.

Ce qui reste au build, et comment ne plus se brûler

Deux choses dans notre stack ont vraiment besoin d'une valeur au bundle : le SDK de suivi d'erreurs veut son DSN quand le bundle client s'initialise, et le SHA du build que nous affichons dans le pied de page et envoyons avec chaque appel d'API. Les deux sont identiques dans tous les environnements (un seul projet de suivi d'erreurs, avec l'environnement posé à l'exécution comme tag ; le SHA est le SHA), donc ils ne violent pas la règle de l'image unique.

Pour ceux-là, nous passons des arguments de build Docker, pas des variables d'environnement, et le Dockerfile rend la distinction visible :

FROM node:22-alpine AS builder
ARG RELEASE_SHA                       # build seulement, figé dans le bundle
ENV NEXT_PUBLIC_RELEASE_SHA=$RELEASE_SHA
COPY . .
RUN npm ci && npm run build

FROM node:22-alpine AS runner
ENV NODE_ENV=production               # exécution ; App Runner surcharge le reste
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
CMD ["node", "server.js"]

Un ARG dans l'étape builder ne peut pas être défini sur le service App Runner, donc personne ne peut être tenté de « juste le changer dans la console ». Si une valeur est un ARG, c'est un rebuild. Si elle est lue par publicConfig(), c'est un réglage du service. Il n'y a pas de troisième endroit.

La vérification qui l'aurait attrapé

Notre script de déploiement vérifie maintenant ce que le navigateur va réellement recevoir, après chaque déploiement :

url=$(aws apprunner describe-service --service-arn "$ARN" --query 'Service.ServiceUrl' --output text)
sha=$(curl -sf "https://$url/api/health" | jq -r .release)
[ "$sha" = "$GIT_SHA" ] || { echo "running $sha, expected $GIT_SHA"; exit 1; }

# le bundle téléchargé par le navigateur doit porter la même release
chunk=$(curl -sf "https://$url/" | grep -o '/_next/static/chunks/main-app-[a-z0-9]*\.js' | head -1)
curl -sf "https://$url$chunk" | grep -q "$GIT_SHA" || { echo "client bundle is stale"; exit 1; }

Deux lignes de grep sur le JavaScript servi. Ça fait échouer le déploiement quand le serveur annonce une release et le bundle client une autre, précisément l'état dans lequel nous sommes restés une journée entière sans le savoir.

nouvelle valeur de configle navigateur en a besoin ? non oui exécution, serveur seulementprocess.env dans le code serveur diffère selon l'environnement ?ou requise par un SDK au bundle ? diffère identique partout exécution, publiquepublicConfig() dans le layout racine build · ARG Dockerune image, vérifiée par grep après déploiement

La leçon plus large

Le bug n'était pas un bug Next.js et ce n'était pas un bug App Runner. C'était une frontière que nous n'avions pas tracée : quelles valeurs sont des propriétés de l'artefact et lesquelles sont des propriétés de l'environnement. Vercel vous cache cette frontière en reconstruisant à chaque changement d'environnement, et c'est un bon compromis si vous êtes sur Vercel. Sur conteneurs, la frontière vous appartient, donc vous devez la tracer volontairement. Une seule image pour tous les environnements est la ligne la plus simple à tracer, et le grep au déploiement est le moyen le moins cher de prouver que vous êtes resté du bon côté.

Si vous migrez une application Next.js de Vercel vers des conteneurs et voulez sauter la journée que nous avons perdue, parlons-en.