Environment variables
Every variable the app reads, which side reads it, what it defaults to, and what it does.
A VITE_-prefixed secret is not a secret. Anything with that prefix is compiled into the browser
bundle at build time; everything else is read by the server at runtime. That is why the Chatfuel
token, the Supabase service-role key and the admin password have no prefix — Vite cannot bake them
into the client even in principle.
Keep the build environment and the runtime environment identical. Where they differ, the failure surfaces at the gate rather than at the mismatch — see the rule that catches everyone once.
Always
| Name | Side | Default | What it is |
|---|---|---|---|
CHATFUEL_TOKEN | server | — | The dashboard token, from the token page. Read only by the proxy, never sent to the browser. |
VITE_CHATFUEL_WORKSPACE_ID | client | — | The workspace the app opens on. Not a fence: every workspace the token's account owns is in the picker. Unused when the auth module is installed. |
CHATFUEL_API_BASE | server | https://panel.chatfuel.com | Upstream API base. Change it only for a staging environment. |
VITE_APP_NAME | client | Chatfuel App | Browser tab, top bar, sign-in screen. |
VITE_APP_LOGO | client | logo.svg | A file in public/, or an absolute URL. The tab icon itself is in index.html — the head is parsed before any of this is read. |
VITE_CHATFUEL_DASHBOARD_URL | client | https://panel.chatfuel.com | Where the empty states send someone to do what the app deliberately does not — create a workspace, create a bot, look at the bill. It does not default from CHATFUEL_API_BASE, which is unprefixed and so unreadable from the bundle. |
PORT | server | 3000 | Port for npm start. |
HOST | server | 0.0.0.0 | Interface npm start binds. 127.0.0.1 makes the deployment this machine's own, which is what open mode may run on without asking for anything else. Vercel does not read it: a function has no interface to choose. |
The auth module
VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY decide the gate: both set turns it on, neither is
open mode, and one without the other is ProxyAuthMisconfigured — the proxy refuses every request
rather than guessing which half you meant.
The rest is what the gate needs to be useful. Without SUPABASE_SERVICE_ROLE_KEY the server
cannot register a bot it just created; without CHATFUEL_WORKSPACE_ID it has no Chatfuel workspace
to create one in, and says so on start-up.
| Name | Side | Default | What it is |
|---|---|---|---|
VITE_SUPABASE_URL | client and server | — | Project URL. |
VITE_SUPABASE_ANON_KEY | client and server | — | Publishable key. |
SUPABASE_SERVICE_ROLE_KEY | server | — | Secret key. Not optional under auth: it registers each bot the server creates, and it mounts the bot and password-recovery routes. |
CHATFUEL_WORKSPACE_ID | server | — | The one Chatfuel workspace every customer's bot is created in — the one whose plan pays for them all, so its bot limit is the ceiling for the whole deployment. Unrelated to VITE_CHATFUEL_WORKSPACE_ID. |
AUTH_RECOVERY_LINK_LOG | server | off | true or 1 lets /chatfuel/auth/recovery-link write the link it mints to the server log, which is that route's only delivery channel. Unset, the route mints nothing and answers 501. |
SUPABASE_PROJECT_REF | bookkeeping | — | The project's ref, written to .env when the wizard creates or links the project over an access token. Nothing reads it back; it is there so the id is on hand for the Supabase CLI. |
CHATFUEL_BOT_CAP, CHATFUEL_BOT_TOTAL_CAP | the wizard run | the numbers in the migration | Substituted into 0001_auth.sql as it is applied — the per-workspace ceiling and the whole-deployment one. Not written to .env: afterwards they are SQL in your own database. Anything that is not a plain number is left alone. The numbers are on Accounts. |
The publishing module
Needed only for posts that go out unattended. Without them the module still composes and publishes
on the spot; it offers no schedule. Turning scheduling on is an /admin action, so it needs
ADMIN_PASSWORD as well as the gate.
| Name | Side | Default | What it is |
|---|---|---|---|
PUBLISHING_SECRET | server | — | What the app and its database use to prove a request came from the other. The database stores only its sha256. Without it nothing is scheduled. |
PUBLIC_URL | server | — | Where this deployment answers from outside, e.g. https://posts.example.com. Required to turn scheduling on and to issue a recovery link: each attaches a credential to the address it names, and nothing in the request is allowed to decide it, so unset is a refusal rather than a fallback. |
VERCEL_AUTOMATION_BYPASS_SECRET | server | set by Vercel | Recorded when the queue is registered, so the scheduler's callback is not turned away at the edge before any code runs. |
PUBLISHING_MEDIA_BUCKET | server | cf-pub-media | The bucket in this deployment's own Supabase Storage that the proxy's media route stores into, so a post composed today is still published from a URL that resolves tomorrow. The app and the migration must name the same one; the wizard fills both from this value. |
PUBLISHING_MEDIA_QUOTA_MB | server | 512 | How much of that bucket one bot may hold. Past it an upload answers 507 MediaQuotaExceeded and nothing is stored. |
PUBLISHING_MEDIA_TTL_DAYS | server | 30 | How long a file no post of that bot refers to any more is kept. The next upload for that bot is what lets it go. |
The admin module
| Name | Side | Default | What it is |
|---|---|---|---|
ADMIN_PASSWORD | server | — | The only thing that opens /admin, which reads and changes the whole account behind CHATFUEL_TOKEN. At least sixteen characters, or the panel refuses to run and says why. Unset means the admin routes are not mounted at all. Changing it signs every open session out. |
ADMIN_COOKIE_SALT | server | derived from this deployment's own token and Supabase URL | The salt the admin session cookie is signed under. Set it to sign every open session out without changing the password, or when two deployments share a password and must not share cookies. |
TRUST_FORWARDED_FOR | server | off | 1 or true makes x-forwarded-for the key the wrong-password counter throttles on. Only behind an edge that writes that header itself; with nothing in front, the header is the caller's own text and a fresh value per attempt is the rate limit gone. |
An admin session lasts two hours, and /admin is never in the navigation rail — the URL is the
whole way in. The admin panel is the procedure.
Limits, and who may call
Every one of these is optional, and every default is the one a deployment gets by saying nothing. Ceilings and fences is what each of them is for.
| Name | Side | Default | What it is |
|---|---|---|---|
ALLOWED_ORIGINS | server | same-origin only | Origins besides the app's own whose pages may call the proxy, comma- or space-separated. Anything else answers 403 ProxyOriginForbidden before the route runs, and an upgrade is refused before a socket exists. * allows every origin with credentials, and on a host that is not loopback the server refuses to serve it. |
ALLOWED_HOSTS | server | the hosts named by ALLOWED_ORIGINS and PUBLIC_URL, plus loopback | The names this deployment answers to, as they arrive in Host — the answer to DNS rebinding. Anything else answers 403 ProxyHostForbidden. Set it for a tunnel or preview URL neither of those names, and prefer naming that host to *. |
CHATFUEL_OPEN_PROXY | server | unset | 1 says this deployment is meant to answer strangers under one token. It is a signature, not a fix, and it does not cover ALLOWED_ORIGINS='*'. |
REST_MAX_CONCURRENT | server | 8 | How many /chatfuel/api/* uploads may be in flight at once. Each sits in memory while it is forwarded. Past it, 503 ProxyBusy. |
GRAPHQL_MAX_CONCURRENT | server | 32 | How many GraphQL requests may be in flight at once. Higher than the upload ceiling because it is the route the whole app talks through. |
GRAPHQL_MAX_BATCH | server | 25 | How many operations one GraphQL body may carry. Past it, 413 BatchTooLarge. The shell sends no batches at all. |
WS_MAX_SOCKETS | server | 256 | How many browser WebSockets one process will hold. Each opens a second one upstream. |
WS_PRE_AUTH_SOCKETS | server | 32 | How many of those may sit unadmitted at once — open, but not past the connection_init the gate reads. A small fraction of the total on purpose: what it protects is the sessions already admitted. |
TENANT_REQUESTS_PER_MINUTE | server | 600 | How many requests one tenant may send per minute, as a token bucket that also holds a minute's worth as burst. Past it, 429 TenantBusy. |
TENANT_MAX_SOCKETS | server | 8 | How many sockets one tenant may hold at once, out of WS_MAX_SOCKETS. Past it the socket closes 4429. |
CHATFUEL_RESOURCE_FENCE | server | bound with the gate, off without | Whether a request naming a resource inside a bot — a flow, a block, a contact — is checked against the bot that id was handed out under. bound refuses an id it knows to be another tenant's; strict refuses one it has never seen too. An unrecognised value falls back to the default rather than to off. |
CHATFUEL_RESOURCE_STORE | server | on with the gate and a service-role key | Whether those bindings are shared through the deployment's own Supabase rather than one process's memory. It is what makes bound survive a restart and strict safe on more than one instance. |
CHATFUEL_OPERATION_ALLOWLIST | server | on with the gate, off without | Whether an operation this app does not send is refused before any other question about it is asked. A root field that is not on the generated list answers 403 OperationNotAllowed. |
CHATFUEL_OPERATION_ALLOWLIST_EXTRA | server | unset | Root fields to allow beside that list, comma-separated. It widens the fence by name, and a name says nothing about what the document under it selects. |
CHATFUEL_OPERATION_ALLOWLIST_OFF | server | unset | The second half of turning the allowlist off behind the gate. CHATFUEL_OPERATION_ALLOWLIST=off alone is ignored and the startup line says it was ignored; 1 here is what makes it hold. |
Serving from a sub-path
| Name | Side | Default | What it is |
|---|---|---|---|
VITE_BASE_PATH | client | / | Build-time base, e.g. /app/. Absent is what makes the fallback to / happen — an empty string is not absent. |
BASE_PATH | server | / | The same value at runtime. /app and /app/ are the same value to the server. |
Both or neither, and not on Vercel — see Serving from a sub-path.
Behind a company proxy
Node's built-in fetch ignores these unless the process was started with a flag nobody types, and
ws never reads them at all — so the proxy reads them itself, for HTTP and for the WebSocket
relay. The wizard's own outbound calls go through the same code.
| Name | Side | Default | What it is |
|---|---|---|---|
https_proxy, HTTPS_PROXY | server | unset | The proxy to send outbound requests through. First in the order that wins. |
http_proxy, HTTP_PROXY | server | unset | Next in that order. |
all_proxy, ALL_PROXY | server | unset | Last in that order. |
no_proxy, NO_PROXY | server | loopback only | A comma list of hosts, each matching itself and its subdomains, or * for all of them. |
Lowercase wins over uppercase, and an empty value means unset. Loopback is always exempt, whatever
NO_PROXY says — a dev server on this machine is not the proxy's business.
What has to be reachable: panel.chatfuel.com always, registry.npmjs.org to install anything,
api.vercel.com to deploy, api.github.com to push.
Where the values go on a deployment
npm run deploy pushes a fixed list of the names above into the Vercel project, production only —
which names, which of them go up write-only, and what DEPLOY_PREVIEW_ENV=1 opts into are on the
Vercel page. PORT, NO_PROXY and VERCEL_AUTOMATION_BYPASS_SECRET are
never pushed: the first two belong to whatever runs the process, and Vercel sets the third itself.