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 deployWhat 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
.envat all, or noCHATFUEL_TOKENin it; VITE_SUPABASE_URLwithoutVITE_SUPABASE_ANON_KEYor the other way round — half a pair is a deployment that builds, starts and answers every requestProxyAuthMisconfigured;- the pair set but no
SUPABASE_SERVICE_ROLE_KEYor noCHATFUEL_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.
It asks what to call the project, then links the directory
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.