Skip to content
ChatfuelSDK
Deployment

Vercel

One command links the project, pushes your production environment, deploys, and asks the deployment whether it came up.

npm run deploy is scripts/deploy-vercel.mjs inside your own project, driving the Vercel CLI. There is no GitHub repo to connect and no dashboard to visit, and re-running it is the normal way to ship an update — every step is written to be run again.

npm run deploy

What it refuses to deploy

The script reads .env before it touches the CLI, and stops on a configuration it already knows is broken — before the build, rather than after it, when the refusal would be a 503 nobody can read from outside:

  • no .env at all, or no CHATFUEL_TOKEN in it;
  • VITE_SUPABASE_URL without VITE_SUPABASE_ANON_KEY or the other way round — half a pair is a deployment that builds, starts and answers every request ProxyAuthMisconfigured;
  • the pair set but no SUPABASE_SERVICE_ROLE_KEY or no CHATFUEL_WORKSPACE_ID — nobody could finish signing up;
  • neither set and no CHATFUEL_OPEN_PROXY=1 — open mode on a public URL, which the proxy would refuse to serve anyway. Who may reach it has the reasoning and the ways out.

Without the auth pair and without VITE_CHATFUEL_WORKSPACE_ID it warns and goes on: the app opens on whichever workspace the account lists first.

Step by step

It finds the Vercel CLI, or fetches it

vercel when it is installed, npx --yes vercel@latest when it is not. One --version call comes first, so the first-run download happens under the line that explains it and a CLI that cannot start is reported as that rather than as a failed sign-in.

From this machine the deploy needs api.vercel.com, and registry.npmjs.org as well when the CLI has to be fetched. Nothing else: the build downloads what it needs on Vercel's side, and telemetry upload is switched off for every call the script makes, so a blocked telemetry host cannot stop a deploy. If something does not get through, the script names the host it could not reach rather than blaming the build.

It checks the sign-in

vercel whoami. Signed in, it moves on. Not signed in and in a terminal, it opens the CLI's own login. Not signed in and not in a terminal — a CI job — it stops and tells you to run vercel login yourself or set VERCEL_TOKEN. A rejected VERCEL_TOKEN is reported as that, and a CLI that could not reach Vercel is reported as a network failure, not as a rejected credential.

It asks whether a push will ever redeploy

One request, right after the sign-in and before anything is linked or built: does this Vercel account have the GitHub connection that redeploy-on-push needs? A connection needs nothing said, and an answer it cannot read is the check declining to have an opinion — which reads on screen exactly as if it were not here. Only a definite no prints anything, one line: a push to GitHub will not redeploy this app. Nothing it finds out stops the deploy or changes a step of it.

It is asked here rather than at vercel git connect, where the refusal would otherwise arrive, because by then the repository exists, the source is in it and the app is live — every step the answer could have changed has already happened.

The name is the address (<name>.vercel.app when it is free), which is why it is asked rather than taken from the directory: every app the wizard makes starts out with the same directory name, and the second one would land on top of the first. The script lists every project on the account first; if the name is taken it says so and asks whether to deploy into it anyway, because vercel link would otherwise link to it and deploy over whatever is live there. Pass --project <name> or set VERCEL_PROJECT_NAME to skip the question. An existing .vercel/project.json is reused and no question is asked.

It pushes the variables from .env — to production only

Each one goes up with vercel env add <name> production --force. --force overwrites the value for that target instead of adding a second one. Variables that exist on Vercel but not in your .env are left alone; the script never deletes. Preview and development stay empty — see below for why, and for DEPLOY_PREVIEW_ENV=1.

It deploys to production

vercel deploy --prod --yes, with both output streams shown as they arrive. A deploy still running after fifteen minutes is stopped and reported. A build that fails is reported with its last line, since the rest is hundreds of lines up by then. If Vercel printed no URL the script asks vercel ls for the deployment that just went live rather than throwing it away.

It finds the public URL

vercel deploy prints the deployment URL, which under Vercel's default Deployment Protection sits behind an SSO wall — fine for you, a dead link for everybody else. The production domain assigned alongside it is the public one, and which alias is which is not derivable from the name. So the script asks each in turn and hands you the first that answers without the wall. Only if none of them do is there anything to change in the dashboard — Project → Settings → Deployment Protection → Vercel Authentication → Disabled — and it says so.

It asks the deployment whether it came up

One request to /chatfuel/healthz, the proxy's health route reached through the rewrite. The route answers one field, ok, and nothing about the gate or which variable is missing — it is open to unauthenticated callers. ok true and the deploy is done. A redirect means Deployment Protection answered instead; the script reports where it was sent rather than following it. A non-JSON answer means something other than the proxy replied, and it points at the routes in vercel.json. ok false means the proxy is up and misconfigured: open the deployment log, where the proxy names the variable it is missing on start-up, check it with vercel env ls, and run the command again.

The URL it prints at the end is the public one. If you installed the auth module, one thing the script cannot do for you: Supabase has to be told the deployed origin may receive auth redirects, or sign-in on that domain fails. It prints the URL to add under Authentication → URL configuration → Redirect URLs.

What goes up, and what stays home

Not the whole file. The script pushes the names on a fixed list, and only those your .env fills in: a name you added yourself, and a name that is there but commented out or empty, are both left behind, and the deployment runs without them — a fence that is off, a ceiling that never comes down.

Sensitive — Vercel's write-only default, so they cannot be read back out of the dashboard, even by you: CHATFUEL_TOKEN, SUPABASE_SERVICE_ROLE_KEY, PUBLISHING_SECRET, ADMIN_PASSWORD, ADMIN_COOKIE_SALT. None of them carries a VITE_ prefix, so Vite cannot bake them into the client bundle even in principle.

Readable in the dashboard — pushed with --no-sensitive, because a workspace id nobody can look at is a debugging tax for no security: CHATFUEL_API_BASE, CHATFUEL_WORKSPACE_ID, VITE_SUPABASE_URL, VITE_SUPABASE_ANON_KEY, VITE_CHATFUEL_WORKSPACE_ID, VITE_APP_NAME, VITE_APP_LOGO, VITE_CHATFUEL_DASHBOARD_URL, PUBLISHING_MEDIA_BUCKET, PUBLISHING_MEDIA_QUOTA_MB, PUBLISHING_MEDIA_TTL_DAYS, ALLOWED_ORIGINS, ALLOWED_HOSTS, CHATFUEL_OPEN_PROXY, REST_MAX_CONCURRENT, GRAPHQL_MAX_CONCURRENT, GRAPHQL_MAX_BATCH, WS_MAX_SOCKETS, WS_PRE_AUTH_SOCKETS, TENANT_REQUESTS_PER_MINUTE, TENANT_MAX_SOCKETS, TRUST_FORWARDED_FOR, CHATFUEL_RESOURCE_FENCE, CHATFUEL_RESOURCE_STORE, CHATFUEL_OPERATION_ALLOWLIST, CHATFUEL_OPERATION_ALLOWLIST_EXTRA, CHATFUEL_OPERATION_ALLOWLIST_OFF, AUTH_RECOVERY_LINK_LOG, PUBLIC_URL. A team that enforces sensitive variables turns --no-sensitive into an error, and the script takes the enforced answer.

Never pushed, though the proxy reads them: VERCEL_AUTOMATION_BYPASS_SECRET, which Vercel sets itself per project and a pushed value would overwrite; PORT and NO_PROXY, which belong to whatever runs the process. What each name does is on Environment variables.

.env is never uploaded (.vercelignore), so the Vercel project environment is the only source of configuration there — a build cannot silently read a stale local file instead. .envrc, *.pem, *.key, *.log, .vercel/ and .supabase/ stay home for the same reason, and scripts/, supabase/, the Dockerfile and the tests are not part of the deployed app.

VITE_* values are read twice — baked into dist/ at build time and read again by the proxy at runtime — which is why they are pushed as project variables rather than passed to the build alone.

Preview is left empty

A preview build runs the code of whatever branch triggered it, with the project's preview environment in process.env. Give preview the production values and every branch — a pull request from outside your team included — executes unreviewed code holding the master Chatfuel token, the production admin password and the Supabase service-role key, which is the one credential that switches row-level security off entirely. A build step can send them anywhere. Vercel's deployment protection does not cover this: it decides who may open a preview URL, not what the code inside the build may read.

If you want working previews, give them their own values — a separate Chatfuel token, a separate Supabase project, a separate admin password. Two ways to put them there: by hand in the Vercel dashboard under Preview, or a preview-only .env and DEPLOY_PREVIEW_ENV=1 npm run deploy, which adds preview to the targets the script pushes to. That flag pushes whatever .env it is given, so pointing it at the production one is exactly the mistake it exists to make deliberate; the script warns before it does it.

What vercel.json does

{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "framework": "vite",
  "buildCommand": "npm run build:client",
  "outputDirectory": "dist",
  "rewrites": [
    { "source": "/chatfuel/:cfpath*", "destination": "/api/chatfuel" },
    { "source": "/(.*)", "destination": "/index.html" }
  ],
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        {
          "key": "Content-Security-Policy",
          "value": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; media-src 'self' blob: https:; font-src 'self' data:; connect-src 'self' https: wss:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'"
        },
        {
          "key": "Strict-Transport-Security",
          "value": "max-age=63072000; includeSubDomains"
        },
        { "key": "X-Content-Type-Options", "value": "nosniff" },
        { "key": "X-Frame-Options", "value": "DENY" },
        { "key": "Referrer-Policy", "value": "no-referrer" },
        { "key": "Cross-Origin-Opener-Policy", "value": "same-origin" },
        {
          "key": "Permissions-Policy",
          "value": "camera=(), geolocation=(), payment=(), usb=(), microphone=(self)"
        }
      ]
    }
  ],
  "functions": { "api/chatfuel.ts": { "maxDuration": 300 } }
}

The first rewrite puts every /chatfuel/* request on the proxy function. The second is the SPA fallback that every host needs, and the reason a bare /healthz answers HTML here: only /chatfuel/* reaches the function, so the health route is /chatfuel/healthz.

The headers block is the same set the bundled Node server sends from SECURITY_HEADERS in the proxy package, and it has to be: the bundle is identical on both hosts, so a policy it survives on one and not the other is a bug you only meet in production. script-src 'self' costs nothing — Vite emits one module tag and no inline script; img-src, media-src and connect-src pin the scheme and not the host, because the media CDN, your Supabase project and whatever host an attachment came from are not knowable when a config file is written. If your deployment has one media origin and one Supabase project, narrowing those two directives is the single most useful edit you can make to the policy.

maxDuration: 300 is the function's ceiling. The proxy's own budget for the handful of slow Instagram publish mutations is 290 s, just under it on purpose: a function killed at five minutes takes the request with it.

What api/chatfuel.ts is

It exports an http.Server, which is what lets Vercel Functions accept the WebSocket upgrade for subscriptions. A plain rewrite straight to panel.chatfuel.com would be simpler and is not usable: a rewrite forwards the browser's headers and cannot add one, so the Chatfuel token would have to be sent by the client — the single thing the proxy exists to prevent. The headers block above sets response headers, not request ones.

It is one static filename rather than a catch-all api/chatfuel/[...path].ts because Vercel's zero-config api/ directory compiles a catch-all into a route that matches a single path segment — /chatfuel/graphql survives that and /chatfuel/auth/provision does not. So the requested path travels as the cfpath query parameter and restoreUrl() puts it back before the proxy core sees it. That parameter name is a contract between vercel.json and api/chatfuel.ts; the two must not drift. /api/chatfuel is reachable directly, so the parameter is treated as the caller's: written twice, decoding to a leading /, a . or .. segment, a ? or a # is answered 400 InvalidRequest, and anything else is re-encoded segment by segment before the core parses it.

Env is read once per cold start, from process.env alone — there is no .env to load. The function prints the proxy's mode on one line per cold start, and if the configuration is one the proxy refuses to serve it says so on the next: a function has no loopback to bind, so open mode there is the master token answering whoever finds the URL, and has to be said on purpose with CHATFUEL_OPEN_PROXY=1. Refused, the function still boots and still answers /chatfuel/healthz, because a deployment that cannot say why it is refusing is one nobody can fix; everything else answers 503 ProxyRefusedToServe, and an upgrade is dropped. A /chatfuel/* path the core does not serve answers 404 NotFound as JSON.

A WebSocket lives no longer than the function

A subscription's socket closes when the function hits its duration limit — 300 s on Hobby — and the relay closes the browser side with 1012, which the client's graphql-ws treats as non-fatal: it reconnects through a fresh function and refetches. Nothing is lost; that is what the reconnect refetch exists for. On your own server no function duration applies.

Root only

Vercel serves the app from the domain root. If you need https://example.com/app/, run the bundled Node server yourself — see Serving from a sub-path.

Shipping an update

The same command. The link is reused, the project is never re-created, variables are overwritten rather than duplicated, and the production address moves to the new deployment. Local edits are not live until you run it.

Once the app is in a GitHub repository, npm run connect-git points the Vercel project at it so a push deploys; it needs the link npm run deploy wrote and an origin remote, and re-running it is a no-op. Push to GitHub is the procedure.

On this page