# Config la build vs config la runtime în Next.js pe containere: bug-ul care a avut nevoie de două deploy-uri

Am comutat un feature flag. Flag-ul era o variabilă de mediu pe serviciul App Runner, `NEXT_PUBLIC_NEW_CHECKOUT=true`, schimbată prin CDK, desfășurată curat, serviciu sănătos. Checkout-ul vechi a rămas. Am verificat mediul pe instanța care rula: variabila era acolo, setată pe `true`. Am repornit serviciul. Tot checkout-ul vechi. A fost nevoie de un al doilea deploy, cu o schimbare de cod care nu atingea nimic legat, ca să apară checkout-ul nou.

Explicația are o propoziție și orice developer de Next.js a citit-o: variabilele `NEXT_PUBLIC_*` sunt inlinuite în bundle-ul JavaScript la build. O citiserăm și noi. Ce nu interiorizaserăm e ce înseamnă asta când build-ul și runtime-ul sunt două locuri diferite, adică exact ce îți dau containerele. Articolul ăsta e regulamentul pe care l-am scris după.

## Trei feluri de configurație, nu două

Documentația Next.js vorbește despre build-time și runtime. Pe o platformă de containere sunt de fapt trei feluri, și al treilea e cel care mușcă.

| Fel | Exemplu | Unde trăiește | Când se poate schimba |
|---|---|---|---|
| Build-time, public | `NEXT_PUBLIC_API_URL` | Inlinuit în chunk-urile de client din `.next/static` | Doar prin rebuild-ul imaginii |
| Runtime, doar server | `DATABASE_URL`, chei de API | `process.env` în server components, route handlers, middleware | La următorul request după schimbarea variabilei |
| Runtime, public | Un feature flag de care are nevoie browserul | Nicăieri, implicit. Trebuie să construiești tu calea | Oricând vrei, dacă o construiești |

Primul fel e ce îți dă `NEXT_PUBLIC_`: rapid, static și înghețat la `next build`. Al doilea fel funcționează cum se așteaptă toată lumea; un server component care citește `process.env.DATABASE_URL` o citește la request, iar o schimbare pe serviciul App Runner intră în vigoare la următoarea pornire de instanță. Al treilea fel e capcana: o valoare de care are nevoie browserul, pe care ai vrea s-o schimbi fără rebuild. Next.js nu are un răspuns încorporat, așa că oamenii întind mâna după `NEXT_PUBLIC_` și primesc primul fel din greșeală.

<div class="article-figure">
<svg viewBox="0 0 900 250" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Cronologie cu două faze. Faza de build în CodeBuild: next build inlinuiește variabilele NEXT_PUBLIC în chunk-urile statice, imaginea e împinsă în ECR. Faza de rulare pe App Runner: containerul pornește cu mediul serviciului, codul de server citește process.env la request, dar chunk-urile conțin deja valorile de la build, deci schimbarea NEXT_PUBLIC pe serviciu nu are efect.">
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<rect x="20" y="30" width="400" height="180" rx="14" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/>
<text x="220" y="56" text-anchor="middle" fill="#7b8cff" font-size="14" font-weight="700">Build · CodeBuild · o dată per imagine</text>
<rect x="40" y="74" width="360" height="36" rx="8" fill="#0d1120" stroke="#2a3150"/><text x="220" y="97" text-anchor="middle" fill="#f1f3ff">next build</text>
<rect x="40" y="120" width="360" height="36" rx="8" fill="#0d1120" stroke="#ffd166"/><text x="220" y="143" text-anchor="middle" fill="#ffd166">NEXT_PUBLIC_* → inlinuit în chunk-urile .next/static</text>
<rect x="40" y="166" width="360" height="30" rx="8" fill="#0d1120" stroke="#2a3150"/><text x="220" y="186" text-anchor="middle" fill="#9aa3c7">imaginea împinsă în ECR, etichetată cu SHA-ul git</text>
<rect x="480" y="30" width="400" height="180" rx="14" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/>
<text x="680" y="56" text-anchor="middle" fill="#4fffb0" font-size="14" font-weight="700">Rulare · App Runner · la fiecare pornire</text>
<rect x="500" y="74" width="360" height="36" rx="8" fill="#0d1120" stroke="#2a3150"/><text x="680" y="97" text-anchor="middle" fill="#f1f3ff">containerul pornește cu mediul serviciului</text>
<rect x="500" y="120" width="360" height="36" rx="8" fill="#0d1120" stroke="#4fffb0"/><text x="680" y="143" text-anchor="middle" fill="#4fffb0">codul de server citește process.env la request ✓</text>
<rect x="500" y="166" width="360" height="30" rx="8" fill="#0d1120" stroke="#ff6b8a"/><text x="680" y="186" text-anchor="middle" fill="#ff6b8a">chunk-urile țin încă valorile NEXT_PUBLIC de la build ✗</text>
<path d="M420,120 L478,120" stroke="#9aa3c7" stroke-width="2" stroke-dasharray="5,4"/>
<text x="450" y="236" text-anchor="middle" fill="#9aa3c7">Schimbarea NEXT_PUBLIC_* pe serviciu nu schimbă nimic din ce vede browserul până la rebuild-ul imaginii.</text>
</g>
</svg>
</div>

## Regula: o singură imagine, toate mediile

Decizia care a rezolvat clasa de bug-uri, nu doar instanța: **aceeași imagine rulează în dev, staging și producție.** Fără build-uri specifice mediului. Dacă imaginea e aceeași, atunci prin construcție nimic din ce diferă între medii nu poate fi build-time, iar întrebarea „e build-time sau runtime?” își răspunde singură pentru fiecare variabilă nouă.

Ce rămâne build-time e o listă foarte scurtă: SHA-ul git, data build-ului și SDK-urile terțe care insistă pe o cheie statică la bundle. Tot restul, inclusiv tot ce e public, e runtime. Și pentru că imaginea e aceeași, promovarea unui build din staging în producție e o schimbare de tag pe serviciul App Runner, nu un rebuild. Asta e funcția care plătește disciplina.

## Config public la runtime, făcut cum trebuie

Browserul tot are nevoie de niște valori. În loc de `NEXT_PUBLIC_`, layout-ul rădăcină, care e server component, citește mediul la request și îi dă browserului exact cheile pe care ar trebui să le aibă:

```tsx
// 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="ro">
      <body>
        <PublicConfigProvider value={publicConfig()}>{children}</PublicConfigProvider>
      </body>
    </html>
  );
}
```

```tsx
// 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;
};
```

Trei proprietăți ale pattern-ului contează. Lista albă e explicită: `publicConfig()` numește fiecare cheie care ajunge în browser, deci un secret nu poate scăpa printr-un prefix greșit. Valorile sunt citite per request, deci o schimbare pe serviciu e live la următoarea pornire de instanță, fără rebuild. Și layout-ul nu trebuie să fie prerandat static ca să meargă, ceea ce în App Router înseamnă că layout-ul, sau ceva din el, e dinamic; noi citim oricum `headers()` în layout pentru locale, ceea ce îl face deja dinamic. Dacă layout-ul tău e static, împachetează citirea în `unstable_noStore()` sau `connection()`, în funcție de versiunea de Next.js.

Middleware-ul primește același tratament: citește `process.env` la marginea fiecărui request, ceea ce e runtime prin natură, așa că token-ul de preview și flag-ul de mentenanță stau acolo fără niciun ceremonial.

## Ce rămâne build-time și cum nu ne mai ardem

Două lucruri din stack-ul nostru chiar au nevoie de o valoare la bundle: SDK-ul de error tracking vrea DSN-ul când se inițializează bundle-ul de client, și SHA-ul build-ului pe care îl arătăm în footer și îl trimitem cu fiecare apel de API. Ambele sunt la fel în toate mediile (un singur proiect de error tracking, cu mediul setat la runtime ca tag; SHA-ul e SHA-ul), deci nu încalcă regula imaginii unice.

Pentru ele pasăm argumente de build Docker, nu variabile de mediu, iar Dockerfile-ul face distincția vizibilă:

```dockerfile
FROM node:22-alpine AS builder
ARG RELEASE_SHA                       # doar la build, copt în 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               # runtime; App Runner suprascrie restul
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
CMD ["node", "server.js"]
```

Un `ARG` din etapa de builder nu poate fi setat pe serviciul App Runner, deci nimeni nu poate fi tentat să „îl schimbe din consolă”. Dacă o valoare e `ARG`, e rebuild. Dacă e citită de `publicConfig()`, e setare de serviciu. Nu există un al treilea loc.

## Verificarea care l-ar fi prins

Scriptul nostru de deploy verifică acum ce va primi browserul cu adevărat, după fiecare deploy:

```bash
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; }

# bundle-ul descărcat de browser trebuie să poarte același 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; }
```

Două linii de `grep` pe JavaScript-ul servit. Pică deploy-ul când serverul zice un release și bundle-ul de client zice altul, exact starea în care am stat o zi întreagă fără să știm.

<div class="article-figure">
<svg viewBox="0 0 900 200" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Flux de decizie pentru o valoare nouă de configurație. Are browserul nevoie de ea? Dacă nu, citește process.env pe server: runtime. Dacă da, diferă între medii? Dacă da, expune-o prin publicConfig în layout-ul rădăcină: runtime public. Dacă nu, și un SDK are nevoie de ea la bundle, folosește un ARG de build Docker: build-time, la fel în toate mediile.">
<defs><marker id="arrCf" 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" font-size="12">
<rect x="20" y="70" width="180" height="60" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="110" y="96" text-anchor="middle" fill="#f1f3ff" font-weight="700">valoare nouă de config</text><text x="110" y="114" text-anchor="middle" fill="#9aa3c7">are browserul nevoie de ea?</text>
<line x1="202" y1="85" x2="288" y2="45" stroke="#7b8cff" stroke-width="1.5" marker-end="url(#arrCf)"/><text x="240" y="56" fill="#9aa3c7">nu</text>
<line x1="202" y1="115" x2="288" y2="155" stroke="#7b8cff" stroke-width="1.5" marker-end="url(#arrCf)"/><text x="240" y="150" fill="#9aa3c7">da</text>
<rect x="290" y="20" width="250" height="50" rx="10" fill="#0d1120" stroke="#4fffb0" stroke-width="1.5"/><text x="415" y="41" text-anchor="middle" fill="#4fffb0" font-weight="700">runtime, doar server</text><text x="415" y="58" text-anchor="middle" fill="#9aa3c7">process.env în codul de server</text>
<rect x="290" y="130" width="250" height="60" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="415" y="152" text-anchor="middle" fill="#f1f3ff" font-weight="700">diferă între medii?</text><text x="415" y="172" text-anchor="middle" fill="#9aa3c7">sau e cerută de un SDK la bundle?</text>
<line x1="542" y1="145" x2="628" y2="105" stroke="#7b8cff" stroke-width="1.5" marker-end="url(#arrCf)"/><text x="580" y="116" fill="#9aa3c7">diferă</text>
<line x1="542" y1="175" x2="628" y2="175" stroke="#7b8cff" stroke-width="1.5" marker-end="url(#arrCf)"/><text x="565" y="192" fill="#9aa3c7">la fel peste tot</text>
<rect x="630" y="70" width="250" height="60" rx="10" fill="#0d1120" stroke="#4fffb0" stroke-width="1.5"/><text x="755" y="92" text-anchor="middle" fill="#4fffb0" font-weight="700">runtime, public</text><text x="755" y="112" text-anchor="middle" fill="#9aa3c7">publicConfig() în layout-ul rădăcină</text>
<rect x="630" y="140" width="250" height="50" rx="10" fill="#0d1120" stroke="#ffd166" stroke-width="1.5"/><text x="755" y="161" text-anchor="middle" fill="#ffd166" font-weight="700">build-time · ARG Docker</text><text x="755" y="178" text-anchor="middle" fill="#9aa3c7">o imagine, verificată cu grep după deploy</text>
</g>
</svg>
</div>

## Lecția mai largă

Bug-ul nu a fost un bug de Next.js și nu a fost un bug de App Runner. A fost o graniță pe care nu o trasaserăm: care valori sunt proprietăți ale artefactului și care sunt proprietăți ale mediului. Vercel îți ascunde granița asta rebuild-uind la fiecare schimbare de mediu, și e un compromis bun dacă ești pe Vercel. Pe containere granița e a ta, deci trebuie s-o trasezi intenționat. O singură imagine pentru toate mediile e cea mai simplă linie de trasat, iar grep-ul de la deploy e cel mai ieftin mod de a dovedi că ai rămas de partea corectă a ei.

Dacă muți o aplicație Next.js de pe Vercel pe containere și vrei să sari peste ziua pe care am pierdut-o noi, [hai să vorbim](/contact).
