NODE_ENV ist nicht Production: Wie eine Env-Variable unseren PostHog-Funnel verseucht hat
Unser Analytics-Dev-Disable-Guard prüfte NODE_ENV — also feuerte ein Prod-Docker-Image auf der Dev-Stufe still und leise 530 Events über 90 Tage ins Produktions-PostHog-Projekt. Der Fix war ein Hostname-Gate, nicht eine grössere Env-Variable-Matrix.
NODE_ENV ist nicht Production: Wie eine Env-Variable unseren PostHog-Funnel verseucht hat
Ein Funnel-Report am Montagmorgen ist der falsche Ort, um zu entdecken, dass quick_check_started von localhost:3000 aus feuert. Noch schlimmer wird es, wenn man weiter herunterscrollt und sieht, dass signup_completed seit drei Monaten von dev.acurio.ch aus feuert — der Dev-Stufe, also genau der Box, die niemand ins Produktions-Analytics-Projekt einspeisen sollte. Wir hatten zwei Leak-Vektoren, beide über denselben Dev-Disable-Guard, und sie hatten unseren NODE_ENV Production Check ein Vierteljahr lang still und leise verseucht.
Der Fix war klein. Die eigentliche Lektion ist es wert, aufgeschrieben zu werden: In einer Welt mit prod-gebauten Docker-Images, Environment-gesteuerten Third-Party-SDKs und Multi-Tier-Deployments ist NODE_ENV === "production" keine verlässliche Antwort auf die Frage "soll dieser Code mit dem echten Produktions-Backend sprechen?". Es ist die Antwort auf eine andere Frage — nämlich "ist das ein Production-Build des Bundles?". Diese beiden Fragen waren einmal dasselbe. Heute sind sie es nicht mehr.
Der Bug tauchte in Acurio auf, unserem Tool zur Zitatprüfung wissenschaftlicher Arbeiten. Acurio läuft als einzelner self-hosted Next.js-Container auf Coolify, mit einer separaten Dev-Stufe (dev.acurio.ch) auf einer kleineren Box für Integrationstests vor dem Merge. Beide Stufen führen dasselbe CI-gebaute Image aus. Genau dieses Detail hat aus einer einzigen Fehleinschätzung bei einem Env-Variablen-Check einen drei Monate langen Analytics-Leak gemacht.
Warum NODE_ENV nicht mehr bedeutet, was du denkst
Der offizielle Next.js-Guide von PostHog liefert dir — wie die meisten Third-Party-SDK-Guides — einen Snippet, der die Initialisierung an process.env.NODE_ENV === "production" koppelt. Die Logik ist geradlinig und vor zehn Jahren war sie korrekt: Production-Builds laufen mit NODE_ENV=production, Dev-Server laufen mit NODE_ENV=development, also ist das ein One-Liner, der Tracking von deinem Laptop fernhält. Die Next.js-Dokumentation zu NODE_ENV ist explizit zu dieser Konvention.
Die Konvention brach in dem Moment, in dem wir ein Docker-Image auslieferten. Ein next build produziert ein Standalone-Bundle mit fest eingebranntem NODE_ENV=production. Dieses Image wird dann auf jede Stufe deployt, die es konsumiert — Prod, Dev, Staging, Preview, überall, wo ein Teammitglied zufällig bun run build && bun run start auf dem eigenen Laptop ausführt. Auf all diesen Stufen ist process.env.NODE_ENV === "production" true, weil sie den Production-Build ausführen. Die Variable verlor ihre Unterscheidungskraft in dem Moment, in dem wir aufhörten, Dev und Prod als separate Artefakte auszuliefern.
Hinzu kommt: Der PostHog-Project-Key ist ein NEXT_PUBLIC_*-Wert — public-by-design, zum Build-Zeitpunkt ins Client-Bundle gebrannt, per Definition auf jeder Stufe identisch, die dasselbe Image zieht. Selbst wenn du der Dev-Stufe ein eigenes PostHog-Projekt geben wolltest, könntest du das nicht über Coolifys Runtime-Env tun, ohne das Image mit einem anderen --build-arg neu zu bauen. Unsere Dev-Stufe erbte den Prod-PostHog-Key also gratis, und das Einzige, was ihre Events aus dem Prod-Projekt fernhielt, war das NODE_ENV-Gate — das, wie wir jetzt wissen, auf der Dev-Stufe die ganze Zeit true zurückgab.
Über neunzig Tage hinweg waren das rund 530 verseuchende Events: quick_check_started und quick_check_completed von localhost:3000 (unsere eigenen Dev-Sessions) sowie signup_completed von dev.acurio.ch (ein Teammitglied beim Testen des Magic-Link-Flows auf der Dev-Box). Es sind plausible Events — sie sehen nicht nach Noise aus — und genau deshalb meldeten unsere Funnel-Charts immer wieder verdächtige "Produktions"-Anmeldungen, die in der Datenbank gar nicht auftauchten. Der Drift war klein genug, um ihn zu ignorieren, und gross genug, um jeden A/B-Vergleich im selben Zeitfenster zu verzerren.
Das Hostname-Gate
Der Fix, der gelandet ist, blieb bewusst klein. Wir haben das NODE_ENV-Gate an allen drei PostHog-Initialisierungs-Engpässen durch eine Prüfung auf den kanonischen Production-Hostname ersetzt:
const PROD_HOST = "app.acurio.ch";
function isTrackingEnabled(): boolean {
if (typeof window === "undefined") return false;
if (process.env.NEXT_PUBLIC_POSTHOG_ENABLE_IN_DEV === "true") return true;
return window.location.hostname === PROD_HOST;
}
Der Client (apps/web/lib/posthog-client.tsx) liest window.location.hostname. Die Landingpage (apps/landing/src/lib/posthog.ts) prüft gegen acurio.ch. Der Server (apps/web/lib/posthog-node.ts) parst die BETTER_AUTH_URL und inspiziert dort den Hostname, weil Node kein window hat. Alle drei geben false zurück für jeden Host, der nicht buchstäblich die kanonische Produktions-Domain ist. Die Dev-Stufe — deren BETTER_AUTH_URL dev.acurio.ch ist — wird sauber zur No-Op. Ein lokales bun run dev, ein lokales bun run build && start, ein Coolify-Preview-Deploy, ein Teammitglied, das das Prod-Image vom Laptop aus startet: Sie alle lösen zu einem Nicht-Prod-Host auf und werden bei der SDK-Initialisierung still und leise verworfen. Die Escape-Hatch NEXT_PUBLIC_POSTHOG_ENABLE_IN_DEV=true bleibt für den (seltenen) Fall erhalten, in dem du PostHog wirklich lokal debuggen willst; sie umgeht den Host-Check absichtlich und ist als Debug-only-Opt-In dokumentiert.
Das ist dieselbe Form von Fix wie die slowapi-Rate-Limit-Header-Stolperfalle, die wir im Mai aufgeschrieben haben: Die Library war nicht kaputt, der Vertrag war es. Der beworbene Vertrag war "guarde auf NODE_ENV"; der tatsächliche Vertrag, der korrektes Production-only-Verhalten produziert, lautet "guarde auf etwas, das per Definition nur am Produktions-Endpoint wahr ist". Ein Hostname-Check erfüllt das. Eine in ein Multi-Tier-Docker-Image gebrannte Env-Variable nicht.
Eine Fussnote zur Wahl von BETTER_AUTH_URL auf der Serverseite: Wir haben process.env.VERCEL_URL und process.env.COOLIFY_URL-artige Runtime-Injektionen erwogen, aber sie führen eine weitere Schicht "ist das überall gesetzt, wo wir es erwarten" ein, und jede Stufe, die es zu setzen vergisst, fällt auf den Default zurück — der naturgemäss Prod ist. Wenn man den kanonischen Hostname als einzige Wahrheitsquelle wählt, gibt es genau einen String in der Codebase, der bestimmt, ob Tracking feuert, und ein Audit kann ihn in zwei Sekunden grepen.
Was das für jedes Multi-Tier-Deployment bedeutet
Drei Erkenntnisse, in der Reihenfolge, in der sie dir beim nächsten Deployment Zeit sparen.
Hör auf, NODE_ENV mit "ist das das Produktions-Deployment" gleichzusetzen. Es beantwortet eine andere Frage — "ist das ein Production-Build des JavaScript-Bundles". Die beiden waren äquivalent, als deine Dev-Umgebung next dev und deine Prod-Umgebung next start auf derselben Box ausführte. Sie sind nicht mehr äquivalent in einem Setup, in dem (a) das Build-Artefakt ein Docker-Image ist, (b) das Image über Stufen hinweg wiederverwendet wird oder (c) Entwickler lokal jemals einen Prod-Build laufen lassen, um einen Bug zu reproduzieren. Alle drei sind heute der Default. Wenn dein Gate ausdrücken soll "sprich mit dem echten Produktions-Backend", dann guarde auf den tatsächlichen Produktions-Endpoint — einen Hostname, eine Deployment-ID, eine Env-Variable, die einzigartig für genau diese eine Stufe ist und die du end-to-end kontrollierst.
Behandle deine Public-Env-Variablen als Deploy-Zeit-Konstante, nicht als Runtime-Switch. Jeder NEXT_PUBLIC_*-Wert wird zur Build-Zeit ins Client-Bundle eingebrannt. Wenn du willst, dass die Dev-Stufe mit einem anderen PostHog-Projekt, einem anderen Sentry-DSN oder einem anderen Stripe-Key spricht, kannst du das nicht durch Setzen einer anderen Env-Variable in Coolify zur Runtime tun — zu dem Zeitpunkt ist das Bundle schon mit dem Prod-Wert eingebrannt auf dem Wire. Du musst entweder das Image pro Stufe mit unterschiedlichen --build-arg-Werten neu bauen oder akzeptieren, dass die Differenzierung zur Runtime im Code passieren muss, nachdem die Variable gelesen wurde. Wir haben die zweite Option gewählt, weil wir ein Image über alle Stufen hinweg deployen wollten, und das Host-Gate macht das sicher. Wer den ersten Weg geht, findet den Trade-off in der Next.js-Dokumentation zur Public Runtime Configuration.
Auditiere die Third-Party-SDKs, die auf das Environment achten — nicht nur die, die du selbst geschrieben hast. Sentry, PostHog, LaunchDarkly, Statsig, Segment, Datadog RUM — sie alle liefern Guides, die dieselbe NODE_ENV-Abkürzung verwenden, und sie alle tragen dasselbe Risiko. Der PostHog-Leak, den wir aufgeräumt haben, ist eine Bug-Kategorie: Jedes Analytics- oder Error-Tracking-SDK, das hinter einem NODE_ENV-Gate initialisiert wird, wird still und leise aus jeder Nicht-Prod-Stufe in die Produktion senden, die zufällig einen Production-Build ausführt. Ein Grep auf NODE_ENV über lib/ ist zwanzig Sekunden Arbeit und legt den gesamten Blast Radius offen. Die breitere Lektion — dass sich die sicheren Defaults mit der Deployment-Topologie ändern — ist dieselbe, die wir im Pilot-to-Production-Playbook für Schweizer KMU immer wieder umkreisen: Muster, die in einer Grössenordnung korrekt waren, sind in der nächsten falsch, und der einzige Weg, den Drift zu erwischen, ist, sie nach Plan erneut zu prüfen.
Die Hauptlektion ist klein genug, um auf einen Klebezettel zu passen: NODE_ENV === "production" ist kein Production-Deployment-Check, sondern ein Production-Build-Check. Wenn du den ersten brauchst, guarde auf den kanonischen Hostname. Der Grund ist das längere Lesen wert — jedes Multi-Tier-Deployment, das ein einzelnes Docker-Image ausliefert, wird diesen Bug reproduzieren, und das Symptom wird ein Funnel-Chart sein, das mit der Datenbank still widerspricht, solange niemand hinschaut.
Wenn du gerade dabei bist, Analytics in ein Next.js-Produkt zu verdrahten, das über Coolify, Fly, Railway oder eine andere Container-Plattform ausgeliefert wird, die ein einziges Build-Artefakt über Stufen hinweg wiederverwendet, ist das Host-Gate das günstige Upgrade, das ein Quartal verseuchter Funnels verhindert. Lies die Next.js-NODE_ENV-Docs für das, was die Variable tatsächlich garantiert, und wenn du ein zweites Augenpaar auf deine Deployment-Topologie haben willst, bevor du live gehst, buche einen kostenlosen AI-Potenzial-Check.
acurio · Halluzinierte Zitate? Nicht in deinem Manuskript.
Citation‑Checker für Zotero. Findet halluzinierte oder nur teilweise gestützte Quellen in KI‑geschriebenen Texten. Thesis‑Pakete ab CHF 19, Schweizer Datenverarbeitung.