The tools
Environments
Two questions with one answer here: which Node, Bun or pnpm this repository is
built with — pinned in uf.config.js, installed into a store shared by every
repository on the machine — and where the values in process.env and
import.meta.env come from, which is the .env cascade at the bottom of this
page.
Declaring one
uf.config.js// @flow
export default {
env: {
toolchain: {
node: "24.14.0",
pnpm: "9.15.0",
},
},
};
Exact versions, not ranges. A range is not an environment: it answers "what
could this build with", and the question a repository has to answer is what it
is built with. uf env list shows what the project declares beside what the
store already holds.
Node, Bun and Deno are runtimes; npm, pnpm and Yarn are package managers. All six are the same kind of thing here — a pinned tool, fetched from its own publisher, verified against the digest that publisher ships.
Installing it
uf env installuf env install · my-app
───────────────────────
- node@24.14.0 installed
- pnpm@9.15.0 installed
✓ 5 executables linked into ~/.local/share/uf/envs/my-app-4f2a1c8e93b0d571/bin
Each tool is unpacked once, into a store keyed by tool, version and platform —
node-24.14.0-darwin-arm64 — and every repository that wants that exact tool
links to the same copy. Two projects on the same Node share it; a third on a
different one gets its own, and neither disturbs the other.
uf writes in three places and no others: the store, the per-project link
directory beside it, and .uf/profile in the repository once you have picked
an environment. There is no sudo step, no shell profile to edit, and no
version manager to initialise in your .zshrc. The download's checksum is
verified before anything is unpacked, against the digest the publisher ships
beside the archive.
The store lives under $XDG_DATA_HOME/uf/store, and $UF_STORE overrides
it; the links live beside it under uf/envs, and $UF_ENVS overrides those.
Using it
uf env exec node --versionv24.14.0
uf env exec puts that directory in front of PATH for one command and runs
it, exiting with whatever the command exited with. The shell you are in is
unchanged, and so is every other repository on the machine.
The links are beside the store rather than inside the project, in a directory
named for the project and keyed by its path — which keeps the property that
made them per-project in the first place: two checkouts on different Node
versions sit beside each other and neither is "active". What it drops is a
directory nobody wrote, nobody reads and every .gitignore had to know about.
$UF_ENVS moves them.
That is the whole model, and it is nix develop's: an environment is something
a directory has, not something a shell is switched into. Nothing has to be
activated, nothing has to be deactivated, and a script that runs uf env exec
gets the same tools whether it was started from your shell, from an editor, or
from CI.
Collecting what nothing uses
uf env gc repositories that are gone
- /Users/you/src/old-project (gone)
removed
- node-24.14.0-darwin-arm64
- pnpm-9.15.0-darwin-arm64
✓ removed 2 entries, forgot 1 root; 0 entries in use
A store that only grows is a store nobody trusts. Every uf env install
registers the repository as a root, and the collector keeps exactly what some
live root reaches: a repository that was deleted stops holding its tools, in
the same pass that notices it is gone.
uf env gc --dry-runsays what would go and removes nothing, which is the version to run first.
What the toolchain half does not do
It does not manage the uf binary itself — that is uf use and uf self-update. It
does not read .nvmrc, .tool-versions or package.json's engines field:
uf.config.js is the one place a uf project writes down what it needs, and a
second source of truth for the same question is the problem, not the feature.
Environment variables
Everything above is about tools. This is about values: a URL, a feature flag, a database password — the things that differ between your laptop, CI and production, and that must not be committed as code.
.env# Anything the browser may see starts with VITE_.
VITE_API_URL=https://api.example.com
# Anything else is the server's alone.
DATABASE_URL=postgres://localhost/app
app/_uf.page.js// @flow
import * as React from "@uniflowed/react";
export component Page() {
// Substituted at build time, and it is in the bundle the browser downloads.
return <a href={String(import.meta.env.VITE_API_URL)}>the API</a>;
}
Server code — a route handler, a loader, a component that only ever renders on
the server — reads every variable through process.env instead, including the
ones with no prefix.
uf dev, uf build, uf preview, uf start, uf test, uf run and uf exec
all read the same files the same way. A value that reaches the dev server and
not the build is a bug, not a setting.
Which files, and in what order
A command runs in a mode, and the mode picks two of the four files:
| File | When it is read | Commit it? |
|---|---|---|
.env | always | yes |
.env.local | always | no — this is your machine's |
.env.<mode> | in that mode | yes |
.env.<mode>.local | in that mode | no |
They are read in that order, and the last one to set a name wins. So
.env.production beats .env, and .env.production.local beats both. A file
that is not there is skipped, which is why .env.local can be in
.gitignore and nothing breaks on a machine that has never had one.
uf new writes .env.local and .env.*.local into .gitignore, because
those two are where a credential goes; the tracked .env and .env.<mode> are
the project's defaults and belong in the repository. Vite's dev server refuses
to serve any of them over HTTP whatever else is configured — see
docs/security.md.
The mode
development for uf dev, production for uf build, uf preview and uf start, test for uf test, development for uf run and uf exec. It is
also what the browser reads as import.meta.env.MODE.
Four ways to say otherwise, and they are tried in this order:
--mode <name>on the command:uf build --mode stagingreads.env.stagingand builds withimport.meta.env.MODE === "staging".- The profile
uf env use <name>recorded, in.uf/profile. It is a local choice — a scaffolded project already ignores.uf/— and it lasts until you change it. env.activeinuf.config.js, for a project whose default mode is not one of the usual ones.- The command's own default, above.
A mode becomes the end of a file name, so it may hold letters, digits, _, -
and . and nothing else. local is refused, because .env.local is already
the file that overrides every mode.
The mode chooses files; it does not choose optimisations. uf build --mode staging is still a production build — minified, with import.meta.env.PROD
true — because that is what building means. The one variable that changes it is
NODE_ENV, which is an ordinary variable here and which Vite reads: a file that
sets NODE_ENV=development makes even uf build produce a development build.
If you did not mean that, do not put NODE_ENV in a .env file.
uf env use staging✓ mode staging: `.env`, `.env.local`, `.env.staging` and `.env.staging.local`, in .uf/profile
The environment always wins
A variable that is already set when uf starts is never overwritten by a file:
DATABASE_URL=postgres://prod/app uf builduses that one, whatever .env.production says. That is what makes a .env file
a default rather than a policy, and it is why a CI secret, a container's
environment and a docker run -e all keep working with no configuration at all.
uf inspect prints the mode, the files that were read and how many variables
came out of them — names only, never values, because that output ends up in
issues.
What reaches the browser
Only a name that starts with the client prefix — VITE_, until a project
sets another. That value is substituted into the JavaScript the browser
downloads, so it is public: anyone who opens the site can
read it. Everything else is available to server code through process.env and
is never written into a client bundle, even if a module that ships to the
browser asks for it — import.meta.env.DATABASE_URL in client code is
undefined, not the password.
That boundary is Vite's, uf does not widen it, and there is a test that a value
without the prefix is absent from every file in dist/
(crates/uf_cli/tests/vite.rs). A project that prefers another prefix sets
Vite's own option, vite: { envPrefix: "PUBLIC_" }, and uf honours it. An empty
prefix is refused — by Vite, which throws on it, and by uf, which goes on using
VITE_ — because it would put every variable in the bundle.
The rule to hold on to: if it is a secret, it must not start with the prefix.
The file format
One NAME=value a line, and:
# A comment. Blank lines are ignored.
export ALSO_FINE=yes # `export ` is allowed, and ignored
QUOTED=" spaces are kept " # a trailing comment needs a space in front
LITERAL='no $expansion, no \n escapes, exactly as written'
MULTILINE="first line
second line"
ESCAPES="a tab\there and a newline\nthere"
HASH=pass#word # no space in front, so it is part of the value
HOST=example.com
API_URL=https://${HOST}/api # or $HOST, without the braces
PRICE=costs \$5 # a literal dollar
- A name starts with a letter or
_and continues with letters, digits or_. - Double quotes understand
\n,\r,\t,\\,\",\'and\$; a backslash before anything else is a backslash, so a Windows path survives. - Single quotes are exactly as written: no escapes, no expansion.
- Both may span lines.
$NAMEand${NAME}expand to something already defined — earlier in this file, in a file earlier in the cascade, or in the environment. The environment's value is the one that expands, since that is the value the name will have.
A file that does not parse stops the command, and the message names the file, the line and what to do:
error: failed to load the environment files for mode development
caused by: /app/.env:2: `${HSOT}` is not defined; define it earlier, set it in
the environment, or write `\${HSOT}` for a literal dollar
An undefined expansion is an error rather than an empty string, and that is a
decision: https://$HSOT/api silently becoming https:///api is a typo that
reaches production and fails somewhere else entirely.
Naming the files yourself
uf.config.js// @flow
export default {
env: {
files: ["config/shared.env", "config/local.env"],
},
};
Replaces the cascade entirely: those files, in that order, later winning,
relative to the project root, and no .env anything. Empty by default, which is
what selects the four files above.
Inside the project, and only inside it. An absolute path, a .., a ~ and a
symlink that points out are all refused, whichever of the files it is: a
uf.config.js in a repository you have just cloned is not a thing that should
be able to read ~/.aws/credentials — which parses as NAME=value like
anything else — into the environment of every command you then run. See
docs/security.md.
What the variables half does not do
- It does not reload in place.
uf devdoes watch the files the cascade selects, and starts the server again when one of them changes, saying which file it was — so an edit no longer needs you to restart the command by hand. What it will not do is swap the value into a running page. A restart is the honest granularity: a prefixed value reaches the browser by being substituted into the bundle, so a new one has to be substituted again and every module that read it has to be evaluated again.uf build,uf previewanduf startread the files once, which is all a command that ends has to do. - It does not travel to a deployment.
uf build --adapter nodewrites a directory that a host runs with plainnode server.js, and that process is not uf: it reads its environment from the host, which is where a deployment's secrets belong anyway. The values a build needed are already in the bundle; the ones the server needs are the host's to provide. - It is not a secret store. A
.envfile is a file on a disk. uf refuses to serve one over HTTP and refuses to put an unprefixed one in a bundle, and neither of those makes it encrypted.