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ă.
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ă:
// 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>
);
}
// 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ă:
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:
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.
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.