Zum Inhalt springen
tecminds

Next.js Server Actions sterben hinter Coolify/Traefik — Origin vs X-Forwarded-Host CSRF, und `allowedOrigins` ohne Schema

Ein Form-POST, der auf localhost und auf nacktem Vercel geht, stirbt auf einem Coolify-plus-Traefik-Next.js-App-Router-Stack, weil x-forwarded-host nicht zu origin passt. Support hört, der Speichern-Button mache nichts, es komme HTML zurück, oder Server Action aborted. Zuerst den Proxy so setzen, dass X-Forwarded-Host der öffentliche Host ist; erst dann host:port-Strings in serverActions.allowedOrigins — ohne Schema, ohne Wildcard-Shopping. Das ist die Feldnotiz.

TTobias LüscherCo‑Founder · TecMinds2026-09-17 · 12 Min Lesezeit

Next.js Server Actions sterben hinter Coolify/Traefik — Origin vs X-Forwarded-Host CSRF, und allowedOrigins ohne Schema

Der teuerste Form-POST ist der, den der Browser schon als gesendet markiert hat. Wir haben an einem ruhigen Nachmittag ein Settings-Save ausgeliefert — denselben Coolify-plus-Traefik-self-hosted-Next.js-App-Router-Stack, den wir für Schweizer-KMU-Produkte wie Acurio fahren — und dasselbe <form action={saveSettings}>, das auf localhost:3000 und auf einem nackten Vercel-Preview einen 200 zurückgab, ist in Production gestorben. Support hat es als "der Speichern-Button macht nichts" eröffnet. Ein zweites Ticket sagte "wir kriegen HTML zurück." Ein drittes hat Server Action aborted reingepastet. Der Container war grün. Auth.js hatte weiter eine Session. Die Zeile hat sich nicht bewegt. Das Server-Log hat ungefähr das hier gedruckt:

`x-forwarded-host` header with value `<internal>` does not match `origin` header with value `<public-domain>` from a forwarded Server Actions request. Aborting the action.

Das ist die Aufzeichnung von Next.js Server Actions CSRF hinter Coolify/Traefik — Origin versus Host / X-Forwarded-Host, warum der Reverse Proxy den Container-Hostnamen weiterleitet, warum serverActions.allowedOrigins eine host:port-Liste ohne Schema ist, und warum du zuerst den Proxy fixst und erst dann die Allowlist aufweitest.

Der Stack ist der, den wir schon von der anderen Seite aufgeschrieben haben. Langlebiges Coolify hält den Next.js-Container, Postgres und Traefik. Vercel ist die Kontrolle, die X-Forwarded-Host schon auf den öffentlichen Host setzt. Ein lokales next dev ist keines von beiden. Ein Server-Action-POST ist kein Route Handler, den du mit Session-Cookie curlen und als bewiesen bezeichnen darfst. Die Vercel-Cron-Notiz hat den Cousin schon benannt: ein Client, der im Browser geht, ist nicht der Client, den Production wirklich schickt. Hier ist der Client der Browser. Der Hop, der lügt, ist der Proxy.

Der POST, der nie eine Action wurde

Server Actions sind POST-Requests mit einem Next-eigenen Content-Type und einem CSRF-Check vor deiner Funktion. Next vergleicht den Host im Origin-Header des Requests mit dem eigenen Host der App, genommen aus X-Forwarded-Host oder Host, und bricht ab, wenn die beiden differieren. Der offizielle Knopf ist serverActions.allowedOrigins. Setzt du ihn nicht, ist nur Same-Origin erlaubt. Ein Request ganz ohne Origin kommt mit einer Warnung durch statt abgelehnt zu werden — das ist ein Missing-Browser-Fall, nicht dieser. Dieser hat beide Header. Sie widersprechen sich.

Localhost widerspricht sich nie. Origin ist http://localhost:3000. Host ist localhost:3000. Es gibt kein Traefik. Nacktes Vercel widerspricht sich fast nie: die Plattform leitet den öffentlichen Host weiter, den du in die Adresszeile getippt hast. Coolify-plus-Traefik ist der erste Ort, an dem die öffentliche Domain des Browsers und der Hostname, von dem der Container glaubt, ihn zu servieren, auseinanderfallen. Traefik routet auf einen internen Service — nextjs:3000, ein Compose-Service-Name, ein von Coolify generierter Backend-Host — und dieser interne Name landet in X-Forwarded-Host. Next macht dann genau das, wofür der CSRF-Check da ist: es weigert sich, eine Mutation zu laufen, die behauptet, von deiner öffentlichen Site zu kommen, während der Server denkt, der Host sei der Container.

Der Abort ist kein Validierungsfehler, den du in saveSettings fangen kannst. Die Action startet nicht. Es gibt kein try/catch um einen Prisma-Write, der die fehlende Zeile erklärt hätte. Der Client-Fetch, den Next für die Action generiert hat, hat ein Action-Result erwartet. Er hat ein Dokument bekommen — eine Error-Page, eine 500-Hülle, HTML. Das ist das Ticket "wir kriegen HTML zurück." Der Button "macht nichts", weil die Transition nie applied hat. Server Action aborted ist der ehrliche String. Er ist auch der, den niemand liest bis Stunde zwei, weil die erste Stunde in Auth.js und in der Form-Component versinkt.

Die Auth.js-passwordChangedAt-Notiz hat uns schon beigebracht, dass Session-Fehler Kostüme tragen. Damals hat es echte User nach /login gebounced. Hier lässt es sie auf derselben Seite mit einem toten Save. Derselbe Chip. Andere Schicht. Die Next-15-cookies/params-await-Notiz ist das andere Kostüm, für das wir einen Nachmittag verbrannt haben: ein Throw in einem Parent-Layout, der nach "das Formular ist kaputt" aussieht und ein fehlendes await ist. Grep das Log nach x-forwarded-host und cookies should be awaited, bevor du die Action umschreibst.

Origin ist der Browser. X-Forwarded-Host ist, wer den Request zuletzt angefasst hat.

Schreib die drei Werte auf, bevor irgendwer den Coolify-Service neu startet. In einem Wegwerf-Route-Handler oder einem console.info oben in der Action-Datei — und in Next 15 heisst das await headers(), nicht der Sync-Call, den die Cookies-Notiz pensioniert hat:

import { headers } from "next/headers";

export async function saveSettings(formData: FormData) {
  const h = await headers();
  console.info("serverAction.csrf", {
    origin: h.get("origin"),
    host: h.get("host"),
    xForwardedHost: h.get("x-forwarded-host"),
    xForwardedProto: h.get("x-forwarded-proto"),
  });
  // …
}

Wenn die Action vor deiner Funktion abortet, druckt dieses Log nicht. Das ist das Tell. Dieselben drei Reads auf ein GET /api/debug-headers legen, das du nach dem Vorfall löschst, oder sie aus Traefik-Access-Logs dumpen. Du willst eine Zeile, die so aussieht:

origin=https://app.example.ch
host=nextjs:3000
x-forwarded-host=nextjs:3000

oder x-forwarded-host auf einem internen Coolify-Hostnamen, während origin bei der öffentlichen Domain bleibt, die der User getippt hat. Diese Abweichung ist der Bug. Die öffentliche Domain ist nicht falsch. Der forwarded Host ist es.

allowedOrigins matcht nur den Host von Origin — Hostname plus Port, wenn die URL einen trägt. https://app.example.ch/settings ist der Eintrag app.example.ch. https://app.example.ch:8443/settings ist app.example.ch:8443. In der Liste gibt es kein Schema. Es gibt keinen Pfad. Ein https://-Prefix ist, wie die Allowlist lautlos nichts tut und du am selben Nachmittag * ergänzt.

Wildcards sind ein Label (*.example.ch) oder eines-oder-mehr (**.example.ch). Sie matchen nicht den nackten Apex. Ein Port kann nicht gewildcardet werden. Partieller Ersatz (app-*.example.ch) wird nicht unterstützt. Die Docs-Tabelle ist kurz und einmal wert, gelesen zu werden, weil der erste "Fix", den wir in einem PR gesehen haben, allowedOrigins: ['https://*.example.ch'] war — Schema plus Wildcard, null Matches, derselbe Abort.

Zuerst den Proxy fixen

allowedOrigins ist eine CSRF-Allowlist-Erweiterung. Jeder extra Host ist ein Host, der eine Mutation aufrufen darf. Die Server-Actions-Config sagt den leisen Teil: hinter einem Reverse Proxy ist kein Eintrag nötig, solange der Proxy den öffentlichen Host in X-Forwarded-Host weiterleitet. Leitet er stattdessen den eigenen Host weiter, schickt der Browser app.example.ch und der Server reportet localhost:3000 oder nextjs:3000. Die Liste existiert für diese Abweichung. Der bessere Fix ist, die Abweichung nicht zu erzeugen.

Traefiks passHostHeader defaultet auf true. Das ist nötig und nicht hinreichend. Ein innerer Hop — Coolifys generiertes Backend, ein zweiter Container, eine "hilfreiche" Middleware, die Host auf den Service-Namen umschreibt, damit die App "sich selbst kennt" — kann X-Forwarded-Host trotzdem mit dem internen Namen stempeln. Der Container sieht dann den internen Namen als forwarded Host, und Nexts CSRF-Check vergleicht das mit dem Browser-Origin.

Den öffentlichen Host auf dem letzten Hop erzwingen, dem der Next.js-Container traut:

# Traefik-File-Provider-Skizze. Coolify-Labels sind dieselbe Middleware.
http:
  middlewares:
    public-forwarded-host:
      headers:
        customRequestHeaders:
          X-Forwarded-Host: "app.example.ch"
          X-Forwarded-Proto: "https"

Coolify kennt die öffentliche Domain, die du am Service hängen hast, schon. Der Job ist, diesen String zum Wert von X-Forwarded-Host zu machen, nicht den Docker-DNS-Namen, mit dem Traefik Port 3000 gefunden hat. Nach der Änderung sollte die Debug-Zeile origin-Host === x-forwarded-host-Host lesen (Port inklusive, wenn eine Seite einen hat). Dann brauchst du für diesen Vorfall kein allowedOrigins.

X-Forwarded-Host nicht vom Client nehmen. Der Browser kann schicken, was er will. Traefik soll den Header setzen; der Next.js-Prozess soll nur dem Proxy-Hop trauen. Die URL-Token-Portal-Notiz hat das für X-Forwarded-For schon gesagt: IP-gekeyte Limits funktionieren nur, wenn der Hop, dem du traust, der ist, der den Header geschrieben hat. Dieselbe Header-Familie, dieselbe Regel. Terminierst du TLS an Traefik und lässt den Container danach jeden inbound X-Forwarded-* glauben, hast du das CSRF-Loch aufgemacht, das der Abort schliessen wollte.

Die Proxy-Config redeployen, nicht nur das Next.js-Image. Wir haben einen Coolify-Restart am Web-Service verbrannt, während Traefik die alte Middleware behalten hat. Der Container kam grün hoch. Der Abort ist geblieben.

Dann, falls nötig: host:port, kein Schema

Wenn nach ehrlichem Traefik immer noch ein interner Host in X-Forwarded-Host auftaucht — eine zweite Domain, ein Preview-Hostname, den Coolify injiziert, ein Health-Check-Pfad, der weiter den Compose-Namen forwarded — nimm diese Hosts in allowedOrigins auf. Die aktuellen Next-Docs nesten den Key weiter unter experimental.serverActions. Wenn deine Next-Version serverActions schon auf die oberste Ebene von next.config gehoben hat, sind die Keys dieselben.

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  experimental: {
    serverActions: {
      allowedOrigins: [
        "app.example.ch",
        "nextjs:3000", // nur wenn dieser Host weiter in X-Forwarded-Host auftaucht
      ],
    },
  },
};

export default nextConfig;

Die öffentliche Domain ist die in der Adresszeile. Der interne Host ist der, den das Log weiter druckt. Du brauchst beide nur, wenn beide weiter auftauchen. Du brauchst kein https://app.example.ch. Du brauchst kein http://nextjs:3000. Du brauchst keinen Trailing Slash. Du brauchst nicht den Pfad des Formulars.

Nicht überweiten. *.local plus **.internal plus jeder Sibling-Service auf der Coolify-Box ist, wie eine Server Action auf App A von App B im selben Cluster aufrufbar wird. Ein Preview-Wildcard, den du "damit PRs gehen" ergänzt hast, ist ein Preview-Wildcard, der nach dem Merge weiter geht, wenn du ihn zu löschen vergisst. Die zwei Hosts bevorzugen, die du wirklich gemessen hast. Den internen Eintrag in der Woche löschen, in der der Proxy den öffentlichen Host forwarded; ihn liegen zu lassen ist ein zweiter Origin, dem du keinen Grund mehr hast zu trauen.

allowedDevOrigins ist eine andere Liste. Tunnel-basiertes Remote-next dev braucht den Tunnel-Hostnamen dort und hier. Production-Coolify ist dieser Tunnel nicht. Keinen ngrok-Host in die Production-Config kopieren, weil ein Teammate ihn am Freitag gebraucht hat.

bodySizeLimit ist ein anderes Kostüm

Die Default-Server-Action-Body-Size ist 1MB. serverActions.bodySizeLimit setzt sie hoch ('2mb', '500kb', eine rohe Byte-Zahl). Das Limit ist der rohe HTTP-Body, inklusive Multipart-Boundaries — 10–20 KB Luft lassen, wenn du nah dran bist. Ein 413 / "body exceeded" ist keine Origin-Abweichung. Ein Logo-Upload, der in Production gestorben ist und auf localhost ging, weil die lokale Datei 200 KB war und das Production-Fixture 3 MB, ist dieser Knopf, nicht CSRF.

bodySizeLimit nicht auf 50mb hochsetzen, während du den Abort debuggst. Du shippst eine grössere Angriffsfläche und siehst weiter x-forwarded-host does not match origin. Zuerst den Log-String lesen. Das Body-Limit nur anheben, wenn der String das Body-Limit ist.

Die Kostüme, die den Nachmittag klauen

Drei andere Fehler treten auf diesem Stack als "der Speichern-Button macht nichts" auf. Sie benennen, damit sie das CSRF-Ticket nicht fressen.

Middleware, die ein Dokument zurückgibt. Globales middleware.ts, das einen anonym aussehenden POST nach /login schickt, gibt dem Action-Fetch HTML. Der Cron-Middleware-Abschnitt ist derselbe Matcher, gegenteiliger Client: dort hatte der Scheduler kein Cookie; hier hat der User eines, und du hast trotzdem redirectet, weil der Matcher POST plus Next-Action-Header als Fremden behandelt hat. Den Action-Pfad ausnehmen oder die Session durchlassen. CSRF nicht "fixen", indem du Middleware ausmachst.

Ein 4xx, den das Framework emittiert hat, weil CSRF den POST gefressen hat. Der Stripe-Webhook-Claim hat das schon als Retry-Grund gelistet: Stripe behandelt ein CSRF-4xx wie jede andere gescheiterte Delivery. Teilen sich eine Server Action und ein Webhook eine schlechte Proxy-Story, kriegst du einen toten Button und ein zweites checkout.session.completed. Das ist nicht derselbe Bug. Der Webhook will event.id geclaimt. Die Action will, dass Origin und forwarded Host übereinstimmen.

Ein Next-15-Unwrap-Throw. cookies(), headers(), params sind Promises. Ein Layout, das sie weiter sync aufruft, wirft, der Flight scheitert, das Formular sieht tot aus. Der String ist cookies should be awaited oder params should be awaited, nicht der Forwarded-Host-Abort. Beide lesen.

Keines davon ist ein Grund, allowedOrigins: ['*'] zu setzen. Es gibt keinen dokumentierten Stern, der "trau dem Internet" heisst. Wenn du ein Snippet findest, das etwas anderes sagt, ist das keine Next-Config, das ist ein Loch.

Die Checkliste, die wir nach einem Save abarbeiten, der "nichts gemacht" hat

Fünf Checks, in dieser Reihenfolge, bevor irgendwer die Server Action umschreiben darf.

Den Abort-String lesen, nicht den Button. x-forwarded-host … does not match origin ist diese Notiz. cookies should be awaited ist die Next-15-Notiz. Ein 307 nach /login ist Middleware. Ein Body-Limit-Fehler ist bodySizeLimit. HTML im Network-Tab ohne einen dieser Strings ist weiter ein Dokument, wo ein Action-Result sein sollte — Origin, Host, X-Forwarded-Host auf einem GET loggen, den du kontrollierst.

Localhost und Vercel sind nicht Coolify. Ein grünes Save auf next dev und ein grünes Save auf einem Vercel-Preview beweisen Traefik nicht. Auf der öffentlichen Coolify-Domain reproduzieren. Dort die drei Header vergleichen.

X-Forwarded-Host auf den öffentlichen Host setzen. Traefik- / Coolify-letzter Hop. passHostHeader plus ein explizites öffentliches X-Forwarded-Host, wenn ein innerer Hop es überschrieben hat. Nur dem Proxy trauen. Zuerst den Proxy redeployen, dann die App. Wenn Origin-Host und forwarded Host matchen, aufhören. Du brauchst keine Config-Änderung.

Wenn weiter ein interner Host auftaucht, nur host:port ergänzen. serverActions.allowedOrigins: öffentliche Domain plus der interne host:port, den das Log weiter druckt. Kein https://. Kein Pfad. Kein *. Kein übrig gebliebener Tunnel-Host. Den internen Eintrag löschen, wenn der Proxy ehrlich ist.

Body-Size nicht mit CSRF verwechseln. 1MB Default. Nur für einen gemessenen Upload anheben. Allowlist und Body-Limit nicht im selben PR aufweiten, "weil wir schon dabei sind."

Drei Regeln, die das nächste Proxy-Flag überleben

Drei Regeln überleben diese Aufzeichnung und verallgemeinern sich über das hinaus, wie Coolify nächstes Jahr eine Domain nennt.

Eine abgebrochene Action ist kein gescheitertes Save. Die Funktion ist nicht gelaufen. Es gibt keine Zeile, keinen Zod-Fehler, keinen Prisma-Code. Den CSRF-Check mit den drei Headern beweisen. Ein Button, der nichts macht, ist ein Dokument, wo ein Action-Result sein sollte.

Der Proxy ist der Host, den das Framework glaubt. Origin ist der Browser. X-Forwarded-Host ist der letzte Hop, den du Traefik zu schreiben beigebracht hast. Vercel schreibt den öffentlichen Host für dich. Coolify schreibt den Container-Namen, wenn du es zulässt. Diesen Stempel fixen, bevor du die CSRF-Allowlist erweiterst.

allowedOrigins ist host:port, und jeder extra Host ist ein Mutations-Origin. Kein Schema. Kein Wildcard-Shopping. Öffentliche Domain plus der interne Name, den du noch siehst, dann den internen Namen löschen. bodySizeLimit ist eine andere Decke. Middleware-HTML ist ein anderes Kostüm.

Die Komposition ist die Notiz. Wir haben ein totes Save als kaputte Server Action behandelt, weil localhost grün war, Vercel grün war und der Coolify-Container grün war. Produktion war ein Traefik-Hop, der nextjs:3000 forwarded hat, während der Browser Origin: https://app.example.ch schickte. Ein ehrliches X-Forwarded-Host, oder zwei host:port-Strings ohne Schema, hätten das Loch in der ersten Minute gezeigt — derselben Minute, die wir mit dem Umschreiben des Formulars verbracht haben.

Wenn eine Server Action hinter Coolify "nichts macht" und derselbe POST auf localhost geht — buche einen kostenlosen AI-Potenzial-Check. Die Vercel-Cron-Aufzeichnung ist die Erinnerung, dass ein Plattform-Hop nicht die Manieren deines Browsers teilt; die Auth.js-JWT-Notiz ist die Erinnerung, dass ein Kostüm auf dem Session-Chip kein Beweis ist, dass die Mutation gelaufen ist.

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.

NÄCHSTER SCHRITTHat dich das interessiert?