Next.js Server Actions Abort Behind Coolify/Traefik — Origin vs X-Forwarded-Host CSRF, and `allowedOrigins` Without a Scheme
A form POST that works on localhost and on bare Vercel dies on a Coolify-plus-Traefik Next.js App Router stack with x-forwarded-host not matching origin. Support hears the save button does nothing, HTML comes back, or Server Action aborted. Fix the proxy so X-Forwarded-Host is the public host; only then add host:port strings to serverActions.allowedOrigins — no scheme, no wildcard shopping. This is the field note.
Next.js Server Actions Abort Behind Coolify/Traefik — Origin vs X-Forwarded-Host CSRF, and allowedOrigins Without a Scheme
The most expensive form POST is the one the browser already marked as sent. We shipped a settings save on a quiet afternoon — the Coolify-plus-Traefik self-hosted Next.js App Router stack we already run for Swiss SME products like Acurio — and the same <form action={saveSettings}> that returned a 200 on localhost:3000 and on a bare Vercel preview died in production. Support opened it as "the save button does nothing." A second ticket said "we get HTML back." A third pasted Server Action aborted. The container was green. Auth.js still had a session. The row did not move. The server log printed something like:
`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.
This is the writeup of Next.js Server Actions CSRF behind Coolify/Traefik — Origin versus Host / X-Forwarded-Host, why the reverse proxy forwards the container hostname, why serverActions.allowedOrigins is a host:port list without a scheme, and why you fix the proxy first and only then widen the allowlist.
The stack is the one we already wrote from the other side. Long-lived Coolify holds the Next.js container, Postgres, and Traefik. Vercel is the control that already sets X-Forwarded-Host to the public host. A local next dev is neither. A Server Action POST is not a Route Handler you can curl with a session cookie and call proven. The Vercel Cron note already named the cousin: a client that works in the browser is not the client production actually sends. Here the client is the browser. The hop that lies is the proxy.
The POST That Never Became an Action
Server Actions are POST requests with a Next-owned content type and a CSRF check in front of your function. Next compares the host in the request's Origin header against the app's own host, taken from X-Forwarded-Host or Host, and aborts when the two differ. The official knob is serverActions.allowedOrigins. If you do not set it, only same-origin is allowed. A request with no Origin at all is allowed through with a warning rather than rejected — that is a missing-browser case, not this one. This one has both headers. They disagree.
Localhost never disagrees. Origin is http://localhost:3000. Host is localhost:3000. There is no Traefik. Bare Vercel almost never disagrees: the platform forwards the public host you typed in the address bar. Coolify-plus-Traefik is the first place the browser's public domain and the hostname the container believes it is serving come apart. Traefik routes to an internal service — nextjs:3000, a compose service name, a Coolify-generated backend host — and that internal name is what lands in X-Forwarded-Host. Next then does exactly what the CSRF check is for: it refuses to run a mutation that claims to come from your public site while the server thinks the host is the container.
The abort is not a validation error you can catch in saveSettings. The action does not start. There is no try/catch around a Prisma write that would have explained the missing row. The client fetch that Next generated for the action expected an action result. It got a document instead — an error page, a 500 shell, HTML. That is the "we get HTML back" ticket. The button "does nothing" because the transition never applied. Server Action aborted is the honest string. It is also the one nobody reads until hour two, because the first hour is spent in Auth.js and in the form component.
The Auth.js passwordChangedAt note already taught us that session failures love a costume. That one bounced real users to /login. This one leaves them on the same page with a dead save. Same chip. Different layer. The Next 15 cookies/params await note is the other costume we burned an afternoon on: a throw in a parent layout that looks like "the form is broken" and is a missing await. Grep the log for x-forwarded-host and cookies should be awaited before you rewrite the action.
Origin Is the Browser. X-Forwarded-Host Is Whoever Last Touched the Request.
Write the three values down before anyone restarts the Coolify service. In a throwaway Route Handler or a console.info at the top of the action file — and in Next 15 that means await headers(), not the sync call the cookies note retired:
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"),
});
// …
}
If the action is aborting before your function, this log will not print. That is the tell. Put the same three reads on a GET /api/debug-headers that you delete after the incident, or dump them from Traefik access logs. You want a line that looks like:
origin=https://app.example.ch
host=nextjs:3000
x-forwarded-host=nextjs:3000
or x-forwarded-host set to a Coolify internal hostname while origin stays the public domain the user typed. That mismatch is the bug. The public domain is not wrong. The forwarded host is.
allowedOrigins matches only the host of Origin — hostname plus port when the URL carries one. https://app.example.ch/settings is the entry app.example.ch. https://app.example.ch:8443/settings is app.example.ch:8443. There is no scheme in the list. There is no path. A https:// prefix is how the allowlist silently does nothing and you add * the same afternoon.
Wildcards are one label (*.example.ch) or one-or-more (**.example.ch). They do not match the bare apex. A port cannot be wildcarded. Partial replacement (app-*.example.ch) is not supported. The docs table is short and worth reading once, because the first "fix" we saw in a PR was allowedOrigins: ['https://*.example.ch'] — scheme plus wildcard, zero matches, same abort.
Prefer Fixing the Proxy
allowedOrigins is a CSRF allowlist expansion. Every extra host is a host that may invoke a mutation. The Server Actions config says the quiet part: behind a reverse proxy, no entry is needed as long as the proxy forwards the public host in X-Forwarded-Host. When it forwards its own host instead, the browser sends app.example.ch and the server reports localhost:3000 or nextjs:3000. The list exists for that mismatch. The better fix is to stop creating the mismatch.
Traefik's passHostHeader defaults to true. That is necessary and not sufficient. An inner hop — Coolify's generated backend, a second container, a "helpful" middleware that rewrites Host to the service name so the app "knows itself" — can still stamp X-Forwarded-Host with the internal name. The container then sees the internal name as the forwarded host, and Next's CSRF check compares that against the browser Origin.
Force the public host on the last hop the Next.js container trusts:
# Traefik file provider sketch. Coolify labels are the same middleware.
http:
middlewares:
public-forwarded-host:
headers:
customRequestHeaders:
X-Forwarded-Host: "app.example.ch"
X-Forwarded-Proto: "https"
Coolify already knows the public domain you attached to the service. The job is to make that string the value of X-Forwarded-Host, not the Docker DNS name Traefik used to find port 3000. After the change, the debug line should read origin host === x-forwarded-host host (port included if either side has one). Then you do not need allowedOrigins for this incident.
Do not take X-Forwarded-Host from the client. The browser can send whatever it wants. Traefik should set the header; the Next.js process should only trust the proxy hop. The URL-token portal note already said this for X-Forwarded-For: IP-keyed limits only work if the hop you trust is the hop that wrote the header. Same header family, same rule. If you terminate TLS at Traefik and then let the container believe any inbound X-Forwarded-*, you have opened the CSRF hole the abort was trying to close.
Redeploy the proxy config, not only the Next.js image. We have burned a Coolify restart on the web service while Traefik kept the old middleware. The container came up green. The abort stayed.
Then, If Needed: host:port, No Scheme
If an internal host still appears in X-Forwarded-Host after you have been honest with Traefik — a second domain, a preview hostname Coolify injects, a health-check path that still forwards the compose name — add those hosts to allowedOrigins. Current Next docs still nest the key under experimental.serverActions. If your Next version already lifted serverActions to the top of next.config, the keys are the same.
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
serverActions: {
allowedOrigins: [
"app.example.ch",
"nextjs:3000", // only if this host still appears in X-Forwarded-Host
],
},
},
};
export default nextConfig;
The public domain is the one in the address bar. The internal host is the one the log still prints. You need both only when both still appear. You do not need https://app.example.ch. You do not need http://nextjs:3000. You do not need a trailing slash. You do not need the path of the form.
Do not over-widen. *.local plus **.internal plus every sibling service on the Coolify box is how a Server Action on app A becomes callable from app B on the same cluster. A preview wildcard you added "so PRs work" is a preview wildcard that still works after the PR is merged if you forget to delete it. Prefer the two hosts you actually measured. Delete the internal entry the week the proxy starts forwarding the public host; leaving it in is a second origin you no longer have a reason to trust.
allowedDevOrigins is a different list. Tunnel-based remote next dev needs the tunnel hostname there and here. Production Coolify is not that tunnel. Do not copy a ngrok host into the Production config because a teammate needed it on Friday.
bodySizeLimit Is a Different Costume
Default Server Action body size is 1MB. serverActions.bodySizeLimit raises it ('2mb', '500kb', a raw byte count). The limit is the raw HTTP body, including multipart boundaries — leave 10–20 KB of headroom if you are close. A 413 / "body exceeded" failure is not an Origin mismatch. A logo upload that died on production and worked on localhost because the local file was 200 KB and the production fixture was 3 MB is this knob, not CSRF.
Do not raise bodySizeLimit to 50mb while you are debugging the abort. You will ship a larger attack surface and still see x-forwarded-host does not match origin. Check the log string first. Raise the body limit only when the string is the body limit.
The Costumes That Steal the Afternoon
Three other failures present as "the save button does nothing" on this stack. Name them so they do not eat the CSRF ticket.
Middleware that returns a document. Global middleware.ts that sends an anonymous-looking POST to /login will give the action fetch HTML. The Cron middleware section is the same matcher, opposite client: there the scheduler had no cookie; here the user does, and you still redirected because the matcher treated POST + Next action headers as a stranger. Exempt the action path or let the session through. Do not "fix CSRF" by disabling middleware.
A 4xx the framework emitted because CSRF ate the POST. The Stripe webhook claim already listed this as a retry reason: Stripe treats a CSRF 4xx like any other failed delivery. If a Server Action and a webhook share a bad proxy story, you will get a dead button and a second checkout.session.completed. They are not the same bug. The webhook wants event.id claimed. The action wants Origin and forwarded host to agree.
A Next 15 unwrap throw. cookies(), headers(), params are Promises. A layout that still calls them sync throws, the flight fails, the form looks dead. The string is cookies should be awaited or params should be awaited, not the forwarded-host abort. Read both.
None of those are a reason to set allowedOrigins: ['*']. There is no documented star that means "trust the internet." If you find a snippet that says otherwise, it is not a Next config, it is a hole.
The Checklist We Run After a Save That "Did Nothing"
Five checks, in this order, before anyone is allowed to rewrite the Server Action.
Read the abort string, not the button. x-forwarded-host … does not match origin is this note. cookies should be awaited is the Next 15 note. A 307 to /login is middleware. A body-limit error is bodySizeLimit. HTML in the Network tab without any of those strings is still a document where an action result should be — log Origin, Host, X-Forwarded-Host on a GET you control.
Localhost and Vercel are not Coolify. A green save on next dev and a green save on a Vercel preview do not prove Traefik. Reproduce on the public Coolify domain. Compare the three headers there.
Fix X-Forwarded-Host to the public host. Traefik / Coolify last hop. passHostHeader plus an explicit public X-Forwarded-Host if an inner hop overwrote it. Trust only the proxy. Redeploy the proxy, then the app. When origin host and forwarded host match, stop. You do not need a config change.
If an internal host still appears, add host:port only. serverActions.allowedOrigins: public domain, plus the internal host:port the log still prints. No https://. No path. No *. No leftover tunnel host. Delete the internal entry when the proxy is honest.
Do not confuse body size with CSRF. 1MB default. Raise only for a measured upload. Do not widen the allowlist and the body limit in the same PR "while we are here."
Three Rules That Survive the Next Proxy Flag
Three rules survive this writeup and generalise past whatever Coolify calls a domain next year.
An aborted action is not a failed save. The function did not run. There is no row, no Zod error, no Prisma code. Prove the CSRF check with the three headers. A button that does nothing is a document where an action result should be.
The proxy is the Host the framework believes. Origin is the browser. X-Forwarded-Host is the last hop you taught Traefik to write. Vercel writes the public host for you. Coolify will write the container name if you let it. Fix that stamp before you expand the CSRF allowlist.
allowedOrigins is host:port, and every extra host is a mutation origin. No scheme. No wildcard shopping. Public domain plus the internal name you still see, then delete the internal name. bodySizeLimit is a different ceiling. Middleware HTML is a different costume.
The composition is the note. We treated a dead save as a broken Server Action because localhost was green, Vercel was green, and the Coolify container was green. Production was a Traefik hop that forwarded nextjs:3000 while the browser sent Origin: https://app.example.ch. One honest X-Forwarded-Host, or two host:port strings without a scheme, would have shown the hole in the first minute — the same minute we spent rewriting the form.
If a Server Action "does nothing" behind Coolify and the same POST works on localhost — book a free AI Potenzial-Check. The Vercel Cron writeup is the reminder that a platform hop does not share your browser's manners; the Auth.js JWT note is the reminder that a costume on the session chip is not a proof the mutation ran.
acurio · Hallucinated citations? Not in your manuscript.
Citation checker for Zotero. Finds hallucinated or partially supported sources in AI‑written text. Thesis packages from CHF 19, Swiss data processing.