Signing in & deploying (in depth)
Sign in once withxanots login. It runs the standard authorization-code + PKCE
browser flow (powered by the OpenID-certified openid-client):
opens your browser, you approve, and the CLI captures the redirect on a 127.0.0.1
callback. On first use it dynamically registers its own OAuth client (RFC 7591) so the
authorize step never depends on the server tolerating an arbitrary loopback port; the
registration is cached in ~/.xano/xanots-clients.json. The instance you’re bound to is
read from the token’s own aud claim.
login also pins the numeric workspace you consented to into the credential, so every
later command acts on exactly that workspace without looking it up again. There is no
--workspace flag — a credential addresses exactly one instance and one workspace. Run
xanots workspace details to see which.
The credential caches by default in a shared ~/.xanots/auth.json, reusable
from any project directory — so a single xanots login covers all your projects.
Override the OAuth host with --origin/$XANO_ORIGIN and the loopback port with --port.
When the browser can’t reach this machine — a remote shell, a container, a
Codespace — the 127.0.0.1 redirect lands nowhere and login waits five minutes for a
callback that will never arrive. Run xanots login --paste instead. It binds nothing:
it prints the authorize URL, you finish signing in in whatever browser you have, and
your browser then fails to load the 127.0.0.1 callback — which is expected. Paste that
URL back from the address bar (or just the code= value out of it) and the sign-in
completes. Everything else is identical, including the instance and workspace you pick
at consent.
Two things follow from how the code is bound. The paste has to happen in the same run
that printed the URL, so --paste needs a terminal — for automation use the environment
credential instead (see Environment variables). And --port still
matters under --paste, not because anything binds it, but because it is part of the
redirect URL’s identity.
Note this is not what XANO_NO_BROWSER does: that only suppresses the browser
launch, and still needs the redirect to arrive here.
Running login again when a usable credential is already cached prints what you’re
signed in to and stops, rather than spending another browser round trip — pass --force
to sign in anyway. That guard is also what keeps login from silently replacing a
hand-authored type: "token" credential (below).
Credential formats. auth.json holds one credential, discriminated by type:
./.xano/auth.json or global
~/.xanots/auth.json) on the same precedence ladder below, and both determine the same
thing: one instance, one workspace. A token credential is never created, refreshed, or
revoked by the CLI — xanots logout just deletes the file, and the token itself stays
valid until you revoke it wherever you minted it. Save it with owner-only permissions
(chmod 600) and keep it out of git.
Upgrading: this format is a break. AnProject-local credentials — passauth.jsonwritten before it is rejected with a message naming the fix — runxanots loginagain.
--local to login to cache tokens in a
project-local ./.xano/auth.json instead (which login auto-adds to .gitignore),
scoping the sign-in to that directory. Every command that reads credentials
(deploy/details, whoami, token refresh) resolves them project-local first, global
as a fallback: it uses ./.xano/auth.json when present, otherwise ~/.xanots/auth.json —
so a --local project keeps working without repeating the flag. login and logout do
not fall back: they target the shared global cache unless you pass --local. An explicit
--config/$XANO_CONFIG always wins over everything.
deploy <file> runs the exact same pipeline as export (including xano.lock
seeding), then create-or-refreshes the target environment and imports the compiled
workspace into it as a full replace. deploy --bundle <path> skips the compile and uploads a
bundle a previous export wrote (handy in CI). A projected, secret-free summary prints to stdout
as JSON — baseUrl plus the workspace id/name, and the static URL (plus a verified
boolean reporting whether the frontend was confirmed live) when --static is used — while the
human-readable progress (and the live URLs) echoes to stderr. The raw
workspace blob is deliberately never dumped: it carries per-tenant secrets that must not land
in shell history or CI logs.
Where it goes — the instance your token is bound to (the token’s aud), never a
flag. xanots deploy create-or-refreshes an ephemeral, which is the only environment it
writes to; production promotion is the separate xanots release path to your main instance
workspace, which merges rather than replacing.
Static host — deploy --static <dir> archives a directory and deploys it to a
static host after the backend import. The frontend lands on the ephemeral itself, so the
backend and the frontend live in one environment. The CLI uploads the archive to
/api:meta/workspace/{id}/static_host/default/build with your ordinary bearer. That route
auto-creates the default host and auto-deploys to dev, returning the live URL — so
the static step is independent of the backend deploy (the backend still runs first because
it’s the primary action).
Liveness verification — the build endpoint returning 200 only means the archive was
accepted; the edge may still be starting a cold host (which 503s for tens of seconds) or
briefly routing the previous build. So after the upload the CLI polls the deployed URL until
the static server reports it is serving this build — its X-Xano-Canonical response header
matches the canonical returned for the build just pushed — then prints Frontend is live. It
polls every second for the first 30s, then every two seconds out to 120s. An unconfirmed poll
is a warning, not a failure (the build uploaded fine and usually comes online moments
later; the exit code stays 0 and the summary records "verified": false). Verification is
skipped when the response carries no canonical to compare against. Pass --skip-liveness to
skip the wait entirely — useful for fast iterative deploys or when the deployed URL isn’t
reachable from the machine running the CLI.
Config injection — before archiving, the deploy rewrites EVERY .html document in the
build, inserting an inline <script> at the top of <head> that assigns each config value to a
window.<KEY> global (so it runs before the app bundle). The backend URL is seeded
automatically as window.XANO_HOST (from the backend deploy’s own response), and
--static-env KEY=VALUE (repeatable) merges in extra keys, overriding the seed on a name
clash. A static host has no server runtime — it serves these files verbatim — so injected
values are public: base URLs and publishable keys only, never secrets (those go in
backend env, read via env(name)). Rewriting every document, not just the root, is what a
prerendered build needs: it serves a different document per route, so a root-only injection
leaves every deep link and refresh running with the global unset — and the page still renders.
Injection is skipped (reported as a warning, not a failure) when the archive has no document
with a <head> to anchor to, and any individual document that lacks one is named; values are
<-escaped so one containing </script> can’t break out of the element. This is why a
prebuilt frontend/dist can retarget any environment with no rebuild.
Caching — verify with a cache buster. The static host servesindex.htmlwithCache-Control: public, max-age=3600, so a browser (or CDN) that loaded the page before your latest deploy can hold the old HTML — including a pre-injection<script>-less version — for up to an hour. Ifwindow.XANO_HOSTlooks missing, it’s almost always this: hard-reload (Cmd/Ctrl+Shift+R) or open DevTools with “Disable cache” checked. When verifying from a script or agent, append a throwaway query param so you never read a cached copy —curl -s "$URL/?nocache=$(date +%s)"— and check the fetched HTML for the injectedwindow.XANO_HOSTline rather than retrying the same cached URL.
xanots status prints the same environment base URL out of band, for cases where you’d
rather bake it in at build time — along with the environment’s name and expiry, without your
having to know either. (xanots whoami prints the instance base URL, i.e. the account’s
origin rather than the environment.) A static failure after a committed backend deploy does not roll
back: it exits with code 3 and a resumable message telling you to re-run with --static
to retry just that step.
deploy reuses cached tokens and refreshes them automatically when the access token
expires (Xano rotates the refresh token on every use; the new one is persisted). A rejected
refresh (invalid_grant) clears the stale cache and tells you to xanots login again.
CI & agents run non-interactively. The credential to reach for is the meta credential
as three environment variables — the type: "token" record above, with no file:
--config path and $XANO_REFRESH_TOKEN;
whichever it displaces is named on stderr, so it never wins silently.
All three are required together. Setting some but not others is a hard error naming the
rest, rather than a quiet fallback to another credential — a workflow with one misspelled
secret must not deploy against whatever happens to be on the runner.
Automated agents: do not invokexanots login— it blocks on interactive browser consent. Use the three variables above. The older$XANO_REFRESH_TOKEN+$XANO_CLIENT_IDpair still works (both copied once fromauth.jsonafter a localxanots login; the target instance is read from the refresh token’saudand the workspace resolved per run), but Xano rotates refresh tokens on use — a stored one is spent by its first exchange, so a job that runs twice fails the second time. Prefer the meta credential.
Checking against a live instance (xanots preflight)
xanots preflight proves your compiled output against a real, running Xano
instance — not a static snapshot. It compiles your workspace, imports it into a
fresh ephemeral environment created for that run, exports it back, and diffs it
against what you compiled, so you catch three classes of problem a local build can’t:
- Import accepts — the engine actually accepts the bundle (malformed-but-shaped output is rejected here).
- Round-trip parity — the workspace the engine stores, re-exported in the same bundle format, matches your compiled JSON after normalization (full object logic included). Every authored kind is diffed — tables, functions, queries, triggers, tasks, and more — each object matched by identity and reported per kind.
- Runtime (
--runtime) — each deployed function actually runs on the engine, with logs surfaced on failure.
xanots deploy uses, plus the workspace export — and never touches XanoScript. There is one
way into an instance, so a transport bug is one preflight reproduces rather than
routes around.
It is non-destructive: nothing you own is written to. Each run creates its own
ephemeral environment, imports into that, and deletes it afterwards — including when
the import is rejected or a transport error is thrown. The environment carries a
short expiry, so even a killed process leaves nothing permanent behind. A fresh
environment per run is also what makes the diff trustworthy: the objects read back
can only have come from this bundle, never from what a previous run left.
Setup — copy .env.example to .env (gitignored) and fill in a base URL +
token. Switching between a cloud dev instance and a local Docker one is just a
different XANO_VALIDATE_INSTANCE:
.env is autoloaded; a real env var wins),
--instance overrides per run, and the token is env-only — never a flag. This
harness is deliberately separate from the auth.json credential the rest of the
CLI uses. A non-zero exit means a check failed; --verbose prints full diffs and raw
engine detail instead of a projected summary.
Wiring the frontend to the backend
Wiring the frontend to the backend. The deploy bakes the environment’s backend URL into every HTML document in your build automatically, as awindow.XANO_HOST global evaluated
before your app bundle — every document, so a prerendered build’s deep links and refreshes
boot with the same backend the root does. So read it at runtime with a build-time fallback and you never have to
know the URL ahead of time:
window.XANO_HOST is the environment URL your deployed APIs answer at (the same value
xanots status prints as the environment’s URL); it is not xanots whoami, which prints
your account’s instance origin. Because injection happens at deploy time, a prebuilt
frontend/dist retargets any environment with no rebuild — ideal for headless agents.
Add your own public config (base URLs, publishable keys) with --static-env KEY=VALUE
(repeatable), exposed the same way as window.<KEY>. A static host serves these files
verbatim to the browser, so everything injected is public — never put secrets here;
those belong in backend env, read server-side via env(name).
Showing a stored file. A file column comes back as { path, name, type, size, meta, access, url }. Don’t use its url: on a tenant-scoped environment that field addresses the
instance host without the /tenant/<name> segment and 404s — as a broken <img>, while
every assertion about the response still passes. Build the URL from path and the host you
already have:
index.html writes the global in bracket
notation — window["XANO_HOST"]="…"; — so grep for the bare token XANO_HOST, not the
exact string window.XANO_HOST (the dot form is valid to read the global in your app,
Deploy targets, and what a release changes
Two targets, so the dev loop and the production step stay distinct:
Not every instance has ephemeral environments enabled. Where they are off,
deploy says so
and names who can turn them on — there is no second destination to fall back to. xanots export and xanots preflight still work meanwhile.
Every deploy is a full replace of the disposable environment — always fresh, no
merge mode, no flags to get wrong. A release is the opposite by design: it changes what
your code defines and leaves the rest of the workspace — including every row in every
table — alone.
An unchanged project is a no-op. Before importing, the release compares the bundle it is
about to send against the workspace it is about to send it to, object by object. When every
object is already there, nothing is sent: the plan prints
no changes, the JSON summary
reports "upToDate": true with "operations": 0, and no updated_at moves. That makes
release usable as a reconcile step — safe to run on a schedule, in CI on every merge, or
behind a “make production match main” button.
The comparison is deliberately one-sided: a false “changed” costs one import that was
happening anyway, while a false “unchanged” would silently skip a real release. So anything
it cannot prove equal counts as changed, and it is skipped entirely for --replace (which
mints fresh identities) and for --seed/--reset-data (which write table rows, about which
the comparison knows nothing). Under --prune, an object the workspace holds and the project
no longer defines is work to do, so that is not a no-op either.
Anything destructive is previewed first — the CLI fetches the plan, prints what would
change, and asks. --yes skips the prompt for CI but never skips the preview. Start with
xanots release ./xano/index.ts --dry-run to see the plan without committing to it.
⚠️ --prune removes tables this project released and no longer defines, and a removed
table takes its rows with it — no flag prevents that. The preview reports it explicitly;
read it before confirming.
--prune is scoped to what this project released, and xano.lock is what defines that
scope: it holds an entry for every object the project has ever exported. A planned deletion
with no lock entry is an object the project never created — a table built in the UI, another
team’s API group — and the release is refused rather than deleting it. A prune with no lock
at all is refused too: without one there is no record of what belongs to the project, so
every deletion would be a guess. To delete something that is genuinely yours to remove,
import its identities first (xanots lock import). Releasing a pre-exported --bundle carries no entry
file to find a lock beside, so name it with --lock=<path>.
A dropped column is destructive, and does not need a flag to happen. Removing a column
from a table schema and releasing destroys the column and every value in it. The server’s
plan calls that a routine in-place update, so the release compares your schema against the
live workspace, names each column that would be dropped, and asks before doing it — on an
ordinary release, with no destructive flag passed.
Environment variables are add-only on a release. A merge creates keys that do not exist
yet and leaves existing ones as they are, so changing a value in code and releasing will not
change it on the workspace. The preview names any key it will decline to update. To change
one, set it on the workspace directly, or use --replace (which rebuilds the workspace).
A merge matches objects by the stable identity your project assigns them, so it only
recognizes a workspace it has released to before. Releasing into one built by hand — or
populated by --replace, which assigns its own — matches nothing: every object is a
create, and with --prune the workspace is emptied and rebuilt rather than updated. The
preview says so in as many words when it happens. To adopt objects that are already there,
pin their guid on the matching defs first. Deploys are authenticated over OAuth — sign in once,
and the CLI refreshes tokens automatically. The target instance comes from your token
(never a stray flag), and the CLI prints what it’s about to do before it touches anything.
CI & agents run fully headless from two env vars — no browser needed:
⚠️ A deploy is a full replace of the target environment, including its table records, before importing. The blast radius is your own disposable ephemeral — but anything