Your own server
Build both halves, run one Node process, and read /healthz 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.
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.
What answers a request
In order: /healthz, then the Chatfuel routes (POST /chatfuel/graphql, WS /chatfuel/graphql,
/chatfuel/api/*), then static files from dist/.
Static serving is GET/HEAD only and contained inside dist/ — .. and absolute segments are
refused before any filesystem call. Hashed /assets/* are public, max-age=31536000, immutable,
index.html is no-cache, x-content-type-options: nosniff goes on everything, and 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. An upgrade to anything but the relay path is destroyed, and SIGTERM closes.
Read /healthz first
/healthz answers {"ok":true,"auth":"on|off|misconfigured"}. It is the first thing to read when
a deployment misbehaves, because it tells you which half of the configuration the server actually
came up with.
auth | 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 bot fence is live. |
off | Neither is set. Open mode — the single-user, pre-auth behaviour. |
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. |
The server prints the same mode on its startup line. 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, and the .env beside the app
PORT, default 3000; the host defaults to 0.0.0.0.
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 in a deployed image that sets 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.
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 and the client reconnects.