# Your own server

> Build both halves, run one Node process, and read /healthz first when anything looks wrong.

Page: https://sdk.chatfuel.com/docs/deploy/node-server
Markdown: https://sdk.chatfuel.com/docs/deploy/node-server.md

`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 [#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 [#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 [#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](/docs/deploy).

## The port, and the `.env` beside the app [#the-port-and-the-env-beside-the-app]

`PORT`, default `3000`; the host defaults to `0.0.0.0`.

<Callout type="info">
  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.
</Callout>

## Whatever you put in front [#whatever-you-put-in-front]

It has to do the same one thing the bundled server already does: &#x2A;*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 [#websockets-are-unbounded-here]

There is no function duration to close a subscription's socket. That is the one behaviour that
differs from [Vercel](/docs/deploy/vercel), where a socket lives at most 300 s on Hobby and the
client reconnects.
