Skip to content
ChatfuelSDK
Deployment

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 HOST is not loopback. The default host is 0.0.0.0, so this is the configuration you get by starting an open-mode app on a server with no HOST set. Install the auth module, bind loopback with HOST=127.0.0.1, or say the deployment is meant to answer strangers under one token with CHATFUEL_OPEN_PROXY=1;
  • ALLOWED_ORIGINS is * and HOST is 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.

ModeWhat the server is doing
onVITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY are both set. Every proxied request must carry the caller's session, and the fences are live.
offNeither is set. Open mode — no callers, and the deployment's own workspaces as the fence.
misconfiguredOne 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.

On this page