Skip to main content

Signing in & deploying (in depth)

Sign in once with xanots 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:
Both formats work at either location (project-local ./.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. An auth.json written before it is rejected with a message naming the fix — run xanots login again.
Project-local credentials — pass --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 hostdeploy --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 serves index.html with Cache-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. If window.XANO_HOST looks 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 injected window.XANO_HOST line 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:
Nothing is read from or written to disk, and nothing rotates, so the same three secrets keep working run after run — which is what makes this the right shape for a CI job. It outranks every other credential, including an explicit --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 invoke xanots login — it blocks on interactive browser consent. Use the three variables above. The older $XANO_REFRESH_TOKEN + $XANO_CLIENT_ID pair still works (both copied once from auth.json after a local xanots login; the target instance is read from the refresh token’s aud and 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:
  1. Import accepts — the engine actually accepts the bundle (malformed-but-shaped output is rejected here).
  2. 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.
  3. Runtime (--runtime) — each deployed function actually runs on the engine, with logs surfaced on failure.
It talks only to public meta API routes — the same archive import 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:
Config comes from the environment (a .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 a window.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:
Verifying the injection: the served index.html writes the global in bracket notationwindow["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