Your own server
Build both halves, run one Node process, know what it refuses to serve, and read the startup line first when anything looks wrong.
npm start is one process — node server/dist/entry.js. It serves the built client out of
dist/ and proxies Chatfuel traffic with your token injected server-side, on one node:http
server with no framework. Anything you put in front of it is yours to choose, and two
configurations it will not serve at all.
Building and starting
npm run build # client → dist/, then server → server/dist/entry.js
npm start # node server/dist/entry.js (PORT, default 3000)npm run build:client and npm run build:server build the halves separately, which is what a CI
job usually wants. The server bundle is built plugin-free for Node 22, and ws, undici and
https-proxy-agent stay external — they are runtime dependencies, loaded from node_modules
when the process starts.
What the entry does
server/entry.ts loads a .env next to the app if there is one, then hands HOST, PORT,
BASE_PATH, the environment and the app's own operation documents (src/operationDocs.ts) to
the proxy package's createChatfuelServer. If listen() refuses the configuration it exits 1
on the refusal's message, with no stack trace to bury it. Otherwise it prints one line:
chatfuel app: http://localhost:<port>, with the base path appended when there is one.
REFUSING TO SERVE
The refusals are checked before the socket is bound. A refusal printed next to a server that is
already answering is a log line; one that never binds is the fence it was written to be. So
npm start logs REFUSING TO SERVE, the reason, and exits without listening when:
- the auth gate is off and
HOSTis not loopback. The default host is0.0.0.0, so this is the configuration you get by starting an open-mode app on a server with noHOSTset. Install the auth module, bind loopback withHOST=127.0.0.1, or say the deployment is meant to answer strangers under one token withCHATFUEL_OPEN_PROXY=1; ALLOWED_ORIGINSis*andHOSTis not loopback, in any mode. Name the origins that serve your app.
Who may reach it is the reasoning; the variables are on Environment variables.
What answers a request
In order: /chatfuel/healthz, then the Chatfuel routes (POST /chatfuel/graphql, WS
/chatfuel/graphql, /chatfuel/api/* and the routes the configuration mounts), then static files
from dist/.
Static serving is GET/HEAD only — anything else is 405 with allow: GET, HEAD — and contained
inside dist/ twice over: .., an absolute segment and a leading-dot segment are refused before
any filesystem call, and the file that stat found is realpath'd and re-checked against the
directory, so a symlink planted under dist/ cannot serve whatever it points at. Hashed
/assets/* are public, max-age=31536000, immutable, index.html is no-cache, everything
else must-revalidate. An unknown path that is not asset-shaped gets index.html, because it is
a route; a path under /assets/, or one whose extension is a type the server serves, keeps its
404 — a renamed bundle must never receive HTML.
Every response carries the same security headers the Vercel deployment sends from vercel.json:
the content-security policy, HSTS, nosniff, X-Frame-Options: DENY, no-referrer, the
opener policy and the permissions policy — on every page and every 404 alike. An upgrade to
anything but the relay path is destroyed. SIGTERM closes the proxy, the server and its idle
keep-alive connections.
Read /chatfuel/healthz, then the startup line
/chatfuel/healthz answers {"ok":true} with 200, or {"ok":false} with 503 when the
configuration has a problem, uncached and with nothing about the mode: it is open to anyone who
can reach the deployment. The mode is on the startup line, and that is the first thing to read
when a deployment misbehaves, because it says which half of the configuration the server came up
with:
chatfuel server: listening on http://0.0.0.0:3000 — serving /srv/app/dist; auth gate on (…)The tail is one of three shapes: auth gate on (…), followed by which routes are mounted, the
resource fence, the operation allowlist, the document registry and the admin panel; auth gate off, bot fence: …, followed by the same; or auth gate MISCONFIGURED — every request will fail with ProxyAuthMisconfigured. Lines naming a configuration problem — a missing token, a missing
CHATFUEL_WORKSPACE_ID, an admin password under the floor — come before it, and the warnings
about the deployment's shape come after it.
| Mode | What the server is doing |
|---|---|
| on | VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY are both set. Every proxied request must carry the caller's session, and the fences are live. |
| off | Neither is set. Open mode — no callers, and the deployment's own workspaces as the fence. |
| misconfigured | One is set and the other is not. The proxy fails closed: every request answers 500 ProxyAuthMisconfigured rather than guessing which half you meant. |
off on a deployment you built with Supabase is the other half of the same problem: the client
renders a sign-in screen the server knows nothing about — see
the rule that catches everyone once.
The port, the host and the .env beside the app
PORT, default 3000. HOST, default 0.0.0.0 — every interface, which is what makes the
open-mode refusal above the default outcome rather than an edge case.
A .env next to the app is loaded on start, and real environment variables win over it. That is
what makes npm run build && npm start behave like npm run dev on your own machine, and it is
why the file is safe to leave beside a deployment that sets real variables. A Node without
loadEnvFile (before 20.12) reads nothing, and such a deployment has to set real variables.
Whatever you put in front
It has to do the same one thing the bundled server already does: answer an unknown path with
index.html. On nginx that is try_files $uri /index.html;. Without it a reload of
/deals/board is a 404 while clicking to the same page works — the failure looks like a routing
bug and is not one.
Two variables are about the thing in front. TRUST_FORWARDED_FOR=1 lets the server believe
x-forwarded-for and x-forwarded-proto, and is for an edge that writes those headers itself —
a reverse proxy you run — and nothing else; with nothing in front, the headers are the caller's
own text. PUBLIC_URL is where the deployment answers from outside, and the routes that attach a
credential to an address refuse to derive it from a request; set it wherever TLS terminates
somewhere other than this process. Both are on Environment variables.
WebSockets are unbounded here
There is no function duration to close a subscription's socket. That is the one behaviour that differs from Vercel, where a socket lives at most 300 s on Hobby.
The relay does keep a deadline of its own with the gate on: a socket closes at its session's
exp, or after an hour, whichever comes first, with 1012 — the client reconnects, asks for a
token again and is gated from scratch. That is how a membership revoked in your Supabase project,
which the proxy is never told about, is felt within the token's lifetime rather than never.