Skip to content
Build-Time- vs. Runtime-Konfiguration in Next.js auf Containern: der Bug, der zwei Deploys brauchte
← ← Zurück zu Gedanken Development

Build-Time- vs. Runtime-Konfiguration in Next.js auf Containern: der Bug, der zwei Deploys brauchte

Wir haben ein Feature-Flag umgelegt. Das Flag war eine Umgebungsvariable auf dem App-Runner-Service, NEXT_PUBLIC_NEW_CHECKOUT=true, per CDK geändert, sauber ausgerollt, Service gesund. Der alte Checkout blieb. Wir prüften die Umgebung auf der laufenden Instanz: die Variable war da, auf true. Wir starteten den Service neu. Immer noch der alte Checkout. Es brauchte einen zweiten Deploy, mit einer Codeänderung, die nichts damit zu tun hatte, bis der neue Checkout erschien.

Die Erklärung ist einen Satz lang, und jeder Next.js-Entwickler hat sie gelesen: NEXT_PUBLIC_*-Variablen werden zur Build-Zeit ins JavaScript-Bundle eingebettet. Wir hatten sie auch gelesen. Was wir nicht verinnerlicht hatten, ist, was das bedeutet, wenn Build und Laufzeit zwei verschiedene Orte sind, und genau das bekommen Sie mit Containern. Dieser Artikel ist das Regelwerk, das wir danach geschrieben haben.

Drei Arten von Konfiguration, nicht zwei

Die Next.js-Dokumentation spricht von Build-Zeit und Laufzeit. Auf einer Container-Plattform gibt es tatsächlich drei Arten, und die dritte ist die, die beißt.

Art Beispiel Wo sie lebt Wann sie sich ändern kann
Build-Zeit, öffentlich NEXT_PUBLIC_API_URL Eingebettet in Client-Chunks unter .next/static Nur durch Neubau des Images
Laufzeit, nur Server DATABASE_URL, API-Schlüssel process.env in Server Components, Route Handlers, Middleware Beim nächsten Request nach der Änderung
Laufzeit, öffentlich Ein Feature-Flag, das der Browser braucht Standardmäßig nirgends. Den Weg müssen Sie selbst bauen Wann immer Sie wollen, wenn Sie ihn bauen

Die erste Art ist das, was NEXT_PUBLIC_ liefert: schnell, statisch und bei next build eingefroren. Die zweite Art funktioniert so, wie es alle erwarten; eine Server Component, die process.env.DATABASE_URL liest, liest sie zur Request-Zeit, und eine Änderung am App-Runner-Service greift beim nächsten Instanzstart. Die dritte Art ist die Falle: ein Wert, den der Browser braucht und den Sie ohne Rebuild ändern möchten. Next.js hat darauf keine eingebaute Antwort, also greifen die Leute zu NEXT_PUBLIC_ und bekommen versehentlich die erste Art.

Build · CodeBuild · einmal pro Image next build NEXT_PUBLIC_* → in .next/static-Chunks eingebettet Image nach ECR gepusht, mit Git-SHA getaggt Laufzeit · App Runner · jeder Instanzstart Container startet mit der Service-Umgebung Server-Code liest process.env zur Request-Zeit ✓ Chunks halten noch die NEXT_PUBLIC-Werte vom Build ✗ NEXT_PUBLIC_* am Service zu ändern, ändert nichts, was der Browser sieht, bis das Image neu gebaut ist.

Die Regel: ein Image, alle Umgebungen

Die Entscheidung, die die Bug-Klasse und nicht nur den Einzelfall behob: dasselbe Image läuft in Dev, Staging und Produktion. Keine umgebungsspezifischen Builds. Wenn das Image dasselbe ist, dann kann per Konstruktion nichts, was sich zwischen Umgebungen unterscheidet, Build-Zeit sein, und die Frage „ist das Build-Zeit oder Laufzeit?“ beantwortet sich für jede neue Variable von selbst.

Was als Build-Zeit übrig bleibt, ist sehr kurz: der Git-SHA, das Build-Datum und Drittanbieter-SDKs, die auf einem statischen Schlüssel zur Bundle-Zeit bestehen. Alles andere, auch alles Öffentliche, ist Laufzeit. Und weil das Image dasselbe ist, ist das Befördern eines Builds von Staging nach Produktion eine Tag-Änderung am App-Runner-Service, kein Rebuild. Das ist das Feature, das die Disziplin bezahlt.

Öffentliche Laufzeit-Konfiguration, richtig gemacht

Der Browser braucht trotzdem einige Werte. Statt NEXT_PUBLIC_ liest das Root-Layout, eine Server Component, die Umgebung zur Request-Zeit und reicht dem Browser genau die Schlüssel, die er haben soll:

// 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="de">
      <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;
};

Drei Eigenschaften dieses Musters zählen. Die Allowlist ist explizit: publicConfig() benennt jeden Schlüssel, der den Browser erreicht, sodass ein Secret nicht durch ein falsches Präfix leaken kann. Die Werte werden pro Request gelesen, sodass eine Änderung am Service beim nächsten Instanzstart ohne Rebuild live ist. Und das Layout darf dafür nicht statisch vorgerendert werden, was im App Router heißt, dass das Layout oder etwas darin dynamisch ist; wir lesen im Layout ohnehin headers() für die Locale, was es bereits dynamisch macht. Wenn Ihr Layout statisch ist, kapseln Sie das Lesen je nach Next.js-Version in unstable_noStore() oder connection().

Die Middleware bekommt dieselbe Behandlung: sie liest process.env am Rand jedes Requests, was von Natur aus Laufzeit ist, sodass das Preview-Token und das Wartungs-Flag dort ohne dieses Zeremoniell leben.

Was Build-Zeit bleibt, und wie wir uns nicht wieder verbrennen

Zwei Dinge in unserem Stack brauchen wirklich einen Wert zur Bundle-Zeit: das Error-Tracking-SDK will seinen DSN, wenn das Client-Bundle initialisiert, und der Build-SHA, den wir im Footer zeigen und mit jedem API-Aufruf senden. Beide sind in jeder Umgebung gleich (ein Error-Tracking-Projekt, mit der Umgebung zur Laufzeit als Tag gesetzt; der SHA ist der SHA), also brechen sie die Ein-Image-Regel nicht.

Dafür übergeben wir Docker-Build-Argumente, keine Umgebungsvariablen, und das Dockerfile macht den Unterschied sichtbar:

FROM node:22-alpine AS builder
ARG RELEASE_SHA                       # nur Build-Zeit, ins Bundle eingebacken
ENV NEXT_PUBLIC_RELEASE_SHA=$RELEASE_SHA
COPY . .
RUN npm ci && npm run build

FROM node:22-alpine AS runner
ENV NODE_ENV=production               # Laufzeit; App Runner überschreibt den Rest
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
CMD ["node", "server.js"]

Ein ARG in der Builder-Stage kann am App-Runner-Service nicht gesetzt werden, also kann niemand versucht sein, es „einfach in der Konsole zu ändern“. Ist ein Wert ein ARG, ist es ein Rebuild. Wird er von publicConfig() gelesen, ist es eine Service-Einstellung. Einen dritten Ort gibt es nicht.

Die Prüfung, die es gefangen hätte

Unser Deploy-Skript prüft jetzt nach jedem Deploy, was der Browser tatsächlich bekommt:

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

# das Bundle, das der Browser lädt, muss dasselbe Release tragen
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; }

Zwei Zeilen grep gegen das ausgelieferte JavaScript. Es lässt den Deploy fehlschlagen, wenn der Server ein Release nennt und das Client-Bundle ein anderes, und genau in diesem Zustand waren wir einen ganzen Tag lang, ohne es zu wissen.

neuer Konfigwertbraucht ihn der Browser? nein ja Laufzeit, nur Serverprocess.env im Server-Code je Umgebung verschieden?oder von einem SDK zur Bundle-Zeit gebraucht? verschieden überall gleich Laufzeit, öffentlichpublicConfig() im Root-Layout Build-Zeit · Docker ARGein Image, nach dem Deploy per grep geprüft

Die größere Lektion

Der Bug war kein Next.js-Bug und kein App-Runner-Bug. Es war eine Grenze, die wir nicht gezogen hatten: welche Werte Eigenschaften des Artefakts sind und welche Eigenschaften der Umgebung. Vercel versteckt diese Grenze vor Ihnen, indem es bei jeder Umgebungsänderung neu baut, und das ist ein guter Tausch, wenn Sie auf Vercel sind. Auf Containern gehört die Grenze Ihnen, also müssen Sie sie absichtlich ziehen. Ein Image für alle Umgebungen ist die einfachste Linie, die man ziehen kann, und das grep beim Deploy ist der billigste Beweis, dass Sie auf der richtigen Seite geblieben sind.

Wenn Sie eine Next.js-App von Vercel auf Container umziehen und den Tag überspringen möchten, den wir verloren haben, sprechen Sie mit uns.