Skip to main content

Environment variables

Every variable the CLI and SDK read. All are optional — the defaults in the right column are what you get when the variable is unset. Authentication (see Signing in & deploying for the full precedence ladder) xanosdk preflight — its own target, deliberately separate from the deploy login Output & diagnostics Update notifier Xano Engine (see Deploying locally) Escape hatches
process.env read inside a workspace definition is a different thing entirely: it resolves at export time and bakes the literal into the bundle. For a value the deployed stack reads at runtime, use workspaceConfig({ env }) + env("NAME"), covered in the next section.

Workspace environment variables

Everything above is read by the CLI, on your machine. A workspace env var is a different set entirely: it lives in the backend, the deployed stack reads it at request time with env("NAME"), and the CLI only ever carries it to the engine. Nothing in the table above affects one. Names are declared in source; values are not.
A pull carries the names into the config and leaves the values in the backend, so a checked-out project never holds a secret it did not fetch on purpose. The files xano/ is the scaffold’s backend directory. The file is read from the directory of the entry you compile: an entry anywhere under a top-level <dir>/ (src/index.ts, backend/index.ts) reads <dir>/.env, and an entry at the project root reads xano/.env. A tree generate wrote reads the .env beside its own index.ts. The info line each compile prints names the file it looked for. Filling it
The source is a backend that is running: workspace, ephemeral[:<name>], local[:<name>], or tenant:<name> — the backend grammar every command shares. Left off, it is the ephemeral or Xano Engine this project last deployed to. A bundle path is refused, because its env is whatever it was built with, which is the stale set this command exists to replace. env pull overwrites an existing xano/.env, so it confirms first and names the source it is about to read. A developer expecting dev secrets should not silently take production’s. --yes skips that confirmation, including the list of local values the pull would drop. Creating a missing xano/.env replaces nothing, so it is not asked about; a file already holding exactly the source’s values is up to date, and nothing is asked or written. It is the only command that writes xano/.env, but not the only place a secret lands on disk: signing in stores your tokens in the credential file, and a compiled bundle carries the resolved env values in cleartext (see the --bundle note below). Changing one value on a running backend
env set creates the variable or replaces its value, and env unset removes it. Every other variable on the backend stays as it is, and nothing is deployed. Use these to rotate a secret: a deploy can’t do that job. A merge deploy only adds names that aren’t there yet, and --replace rewrites the whole workspace. The destination is --to workspace, --to ephemeral[:<name>], --to tenant:<name>, or --to local[:<name>] for a Xano Engine. With none of these, the command writes to the ephemeral or Xano Engine this project last deployed to. It never falls back to your workspace or a tenant, and a Xano Engine needs no Xano account. A write to the workspace or a tenant asks for confirmation first, and --yes skips it; that flag is required when there is no terminal, including when the value is piped in. Ephemerals and the Xano Engine are throwaway, so the command doesn’t ask. The value is never printed. Pipe it on stdin to keep it out of shell history. One trailing newline is dropped, so echo works too. An empty value is refused unless --allow-empty-env=NAME names that variable, because an empty value usually means a shell variable wasn’t set. To remove a variable, use env unset. unset on a name the backend doesn’t have reports that nothing was there and exits zero. Neither command touches xano/.env. The next deploy still sends what that file holds, so update it too if the change should outlive the next deploy. Overriding it, on any command that compiles a bundle: A deploy replaces the workspace’s env set. So a declared name left empty in the config that no file and no flag supplies would clear the live value, and deploy refuses instead, naming the variables. That refusal fires on the ordinary path (a fresh clone has no xano/.env), and it is a refusal rather than a warning because the cleared value is not recoverable from anything the project holds. A name the config itself spells a real value for is supplied by the config and is never refused. export and preflight report instead of refusing: one writes a file, the other imports into a throwaway tenant. A --keep-data deploy merges, and a merge only adds names the target does not hold, so an empty value clears nothing and is not refused — unless --keep-data cannot keep that environment’s data and the deploy replaces it after all. A deploy --to without --replace merges the same add-only way, and it still refuses: a name the target does not hold would be created there with no value, on a backend someone keeps. A --dry-run sends nothing, so it reports the refusal the real deploy would make as a warning and prints its plan. xanosdk env pull warns about each declared name the backend holds no value for, since the file it writes has none for it either. The refusal only covers declared names. A variable that exists in the backend but is not in workspaceConfig({ env }), and is not among the values being sent, is dropped by the same replacement with no refusal and no warning, because nothing declares it to check. Two ordinary routes reach that state: someone adds a variable in the backend after your last pull, and swapping xano/.env for an --backend-env-file that holds only the declared names. So declare every name the stack reads, and carry the rest across when you swap files. The refusal is part of the compile, so xanosdk deploy --bundle <path> does not get it. A pre-built bundle carries whatever env it was built with and sends exactly that, so a build-once-deploy-the-artifact pipeline resolves its values at build time: pass --backend-env-file/--env-var to the export that produces the bundle. Two consequences worth knowing before you build that pipeline:
  • The bundle is a secret-bearing file. A compiled bundle carries the resolved values in cleartext. Keep it out of git and out of shared build artifacts, and delete it after the deploy. The scaffold gitignores workspace.json, but not an arbitrary --out path.
  • Nothing re-checks the env at that deploy. --env-var, --backend-env-file, and --allow-empty-env are accepted on a --bundle deploy and do nothing, because there is no compile to resolve them into. Rebuild the bundle instead, and rebuild it whenever a declared name is added: the export that skipped a value only warns, and the deploy that ships it does not check again.
Documentation tokens go in a different file. xano/.env is backend-only. The token gating a hosted doc site lives in xano/.secrets.json, also gitignored, also preserved across a pull. The split is not arbitrary, and the rule tells you where a future secret lands. A backend variable is addressed by name — env("NAME") is how a stack reads it, so the name is its identity, and a dotenv file is the right shape. A documentation token is addressed by the object that holds it: this workspace, that API group. It has no name of its own, so it is stored under that object’s identity instead, and env(...) cannot read it from anywhere. That file is SDK-owned. xanosdk pull writes it and every build reads it back, so a pull followed by a deploy restores the gate with no manual step — which is the whole reason it exists. Nobody edits it by hand; --no-secrets on a pull finds the tokens and writes none, for a machine where a secret on disk is not wanted. A pull only answers for an object that already HAS a token. A gate you declare in source — a new API group, or a doc site you decided to close — has none to fetch, and that is what xanosdk secrets fill is for:
It mints a cryptographically random token per gate, the same shape a platform-minted one has, and only ever adds: a value already in the file is never replaced, because replacing one invalidates every doc-site link already handed out and may be the value the live gate is using right now. An entry no scope in your source claims is left alone for the same reason. When every gate is already filled it writes nothing at all, so a re-run is a no-op rather than a file change. A minted token gates nothing until a deploy sends it — the file having a value and the doc site wanting that value are two different moments. Because it is gitignored, a teammate’s clone and CI both have none: those pass --secrets-file <path> (which replaces the default, the way --backend-env-file does) or --doc-token "<scope>=<value>" for one scope. A scope is workspace or an API group’s name; the flags take one per occurrence and repeat, and refuse a name two groups answer to rather than guessing which you meant. Prefer the file: a token on the command line is visible in shell history, the process list, and any CI log that echoes the command. A pull refuses to write the file when git says the path is not ignored, and reports the drop rather than failing — the tree it wrote is fine, it just carries no tokens. When git cannot be asked at all (no repository, no git on PATH) it writes: there is nothing there that could commit the secret. The refusal works the same way as the backend one, with one difference worth knowing: an unsupplied gate emits no documentation block at all rather than an empty token. On the workspace that leaves the target’s gate as it is instead of clearing it, which is what stops a two-step export then deploy --bundle pipeline from clearing a gate no refusal could fire on. On an API group an absent key is not a non-action — the engine writes its default, which clears that group’s gate. There is no safe bundle to write, so a group that publishes docs and declares a gate with no token supplied fails the export on every command, not just deploy. Only --allow-empty-doc-token=<scope> turns an unsupplied gate into a deliberately cleared one. One thing to clear out by hand if you used an earlier build: a XANO_DOCS_TOKEN… line left in xano/.env from when tokens lived there is now an ordinary backend variable, because that file is backend-only again. It will be sent as part of the workspace’s env, where env("NAME") reads it from every request. Delete the line — the value belongs in xano/.secrets.json, and xanosdk pull puts it there. An entry the file holds that no scope in your source claims is reported and never deleted — usually a renamed API group in a project with no lock. The SDK cannot tell a rename from a group you commented out, and it cannot re-fetch a secret it throws away. Do not confuse any of this with deploy --static-env, which bakes public config into the HTML a browser downloads. One is a workspace secret; the other is visible to everyone. typedEnv(), the stack.env-undeclared warning, and what a release does with env across environments are in Authoring reference.

Identity & the xano.lock file

Every top-level object carries a stable guid — Xano’s identity anchor. On a sync import the engine matches an incoming object to an existing one by guid and updates it in place; no match means a new object. So re-running export/deploy on the same code maps cleanly onto the same workspace — no duplicates. By default the guid derives from the object’s name — for a query, from its api group, verb, and name together, which is the engine’s own uniqueness for an endpoint, so GET items and POST items are distinct objects and a path may repeat across groups. Set an explicit guid to pin identity across a rename, or to adopt an existing workspace object into code. xano.lock freezes the whole workspace’s identities at once — every auto-derived guid, plus the canonical URL tokens of API groups and toolsets (which the engine otherwise randomizes, giving the same code different public URLs per environment). Every command that compiles an entry file writes it — export, deploy, release, preflight, with no flag — and updates it on every build, atomically, before the bundle. Commit it next to your code. npm run xano:check is the CI guard (xanosdk export ./xano/index.ts --check --strict, which implies --frozen-lock, writes nothing, and needs neither xano/.env nor xano/.secrets.json): it fails rather than change the lock, so an uncommitted identity change is caught in review instead of on a deploy. It also fails while the lock carries an entry no exported object matches. That entry is how a rename looks before its fix-up — the old name keeps the identity while the new name mints a fresh one, so the next sync deletes the object and creates another in its place. A plain export only warns and writes the entry down, and from then on nothing tells a rename from a deletion, so the check asks you to say which: lock rename to finish the rename, lock prune to drop an entry whose object really is gone. If you adopted a live workspace with lock import and are porting it a piece at a time, those unported entries pin objects that are still serving and must not be pruned — pass --allow-lock-orphans to accept them and keep the rest of the check. An installed toolchain module’s own frozen check rides on the same run, so a stale artifact it owns fails there too, instead of being rewritten. compile and paths/routes only read a lock; they never write one, so listing your routes never mints a URL token it would then discard. --no-lock builds without one, for a throwaway run or a tree that cannot be written. Identities then derive from names, the instance invents public URLs your project will not learn, and deploy --to workspace --prune is unavailable. It is refused over a lock that already parses — building name-derived guids against a lock that pins different ones is a delete-and-recreate of every object, reported as success. If the lock itself is corrupt, --no-lock is the way past it. Releasing to a workspace that already exists? Adopt what it already serves first:
Without that step your first locked build mints its own URL token for every API group that names no canonical in code. The workspace keeps the slug it already serves — an unpinned value is a preference, and an update keeps the stored one — so the lock would record a URL that is not the one being served, and would keep recording it. Adopting first makes the lock true. A brand-new workspace has nothing to adopt and needs no such step. One directory, one lock. The lock lands beside the entry file, so two entry files in the same directory share it — each build then reports the other’s objects as orphans, and a lock prune would delete them. Give each entry its own directory, or name its lock explicitly with --lock=<path>. Precedence at emit is always explicit in-code value → lock entry → name derivation. When the lock is actually load-bearing. Because the default derivation is deterministic — md5("<type>:<name>"), and md5("query:<group>|<verb>|<name>") for a query — a project that created all of its own objects can regenerate a byte-identical lock from its own source. Delete that lock, release again, and the same guids come back: the objects match and update in place. For that project the lock is a cache, and losing it costs nothing. The lock is load-bearing exactly where a live guid diverges from that derivation, which happens two ways:
  • Adopted objects — anything built in the Xano UI first and taken over with lock import. The engine assigned those guids randomly; nothing in your code can re-derive them.
  • Renamed objects — lock rename pins the original guid under the new name, so the derivation no longer reproduces it.
For those entries the lock is irreplaceable, and losing it means the next release matches nothing and creates a duplicate of every diverged object. A workspace adopted wholesale from the UI can be almost entirely divergent, so treat that lock as the critical artifact. Either way, commit it — the cache is worth having, and you generally will not know which entries have diverged without looking. Renames — with a lock, a rename in code no longer means delete+create on sync. The export warns about the orphaned entry and names the fix-up:
Merge conflicts — two branches that both touched the lock can conflict in git, and a lock still holding <<<<<<< markers is refused by every command, saying so. Resolve it as the union of both sides: keep every entry under objects (and landed) from each, then delete the markers. If both branches renamed the same object — one to stock, the other to goods — the union holds one guid under two keys, which every command refuses, naming both. Keep the name your code exports and drop the other; both commands read such a lock:
rename/adopt take no entry file, so on their own they look for xano.lock in the current directory. Pass --entry=<path> to derive it beside the entry the way export/deploy/prune do, or --lock=<path> to name the file outright. They never reach for a lock you did not point them at — when they spot one next door they say so and stop, rather than writing a file you did not name. After --replace — a replace rebuilds the workspace with fresh engine identities, so the lock is stale the moment it finishes and the next ordinary merge would match nothing and try to create everything. xanosdk deploy ./xano/index.ts --to workspace --replace now re-pins the lock from the rebuilt workspace itself and tells you to commit it. If there is no lock to re-pin, it says so — without one, the next merge duplicates every object. Importing a live workspace’s identities — xanosdk lock import <bundle.json> seeds the lock from a real engine packageExport, capturing the live workspace’s random guids by (type, name) so code takes over an existing workspace and the first sync updates in place instead of duplicating. CI — xanosdk export ./xano/index.ts --check --strict (the scaffolded npm run xano:check) runs every check an export runs and writes nothing, so it needs no secrets. It implies --frozen-lock, which fails instead of changing the lock, so a canonical minted in a throwaway container can never silently diverge public URLs. Mint locally, commit the lock. It fails on an entry no exported object matches too, which is the state a rename leaves behind until lock rename moves the identity across (or lock prune drops it).
Generated from @xano/sdk 1.0.9.