The auth model
On a developer machine, envless login caches an API token at ~/.envless/config.json, and envless passphrase set caches the workspace key into the same file, under workspaceKeys. CI runners and production hosts have neither, so they configure Envless entirely through environment variables, and that takes two separate credentials:
ENVLESS_TOKEN: anev_sk_API key that authenticates the request to fetch your encrypted bundle. The server only ever returns ciphertext, so the token alone can't read a value.ENVLESS_KEYorENVLESS_PASSPHRASE: the material that decrypts that ciphertext locally. It never leaves the runner and is never sent to the API.
Plus the selectors that say what to fetch:
ENVLESS_PRODUCT: the product slug, only needed when the project slug is ambiguous (falls back to.envless).ENVLESS_PROJECT: the project slug (falls back to a committed.envlessfile).ENVLESS_ENV: the environment slug (also falls back to.envless).
Machine & CI access is the full reference for these variables: where each one comes from, how to export ENVLESS_KEY from the dashboard, and how rotation works. This page is the platform-by-platform recipe book.
The full env-var set
| Variable | Required | Purpose |
|---|---|---|
ENVLESS_TOKEN | ✅ | ev_sk_ API key that authenticates the ciphertext fetch. From the Keys page. |
ENVLESS_KEY | one of these | base64url raw AES-256 workspace key that decrypts locally. From Settings → CI / Machine Access. |
ENVLESS_PASSPHRASE | one of these | Workspace passphrase. The key is derived from it instead. |
ENVLESS_PRODUCT | ⬜ (or .envless) | Product slug. Only required when the project slug exists in more than one product, which the API answers with 409 Conflict until you name one. |
ENVLESS_PROJECT | ✅ (or .envless) | Project slug to fetch. |
ENVLESS_ENV | ✅ (or .envless) | Environment slug to fetch. |
Set one decryption credential. If both are set, ENVLESS_KEY wins and the passphrase is ignored. Prefer ENVLESS_KEY: it's the raw key, so the loader skips both the 200,000 round PBKDF2 derivation and the extra API call a passphrase needs to resolve the workspace before it can build the salt.
GitHub Actions
These env vars tell Envless what to fetch and how to decrypt it, but something in the job still has to do the fetching. Either the CLI wraps your build, or your build loads Envless itself.
jobs:
deploy:
runs-on: ubuntu-latest
env:
ENVLESS_TOKEN: ${{ secrets.ENVLESS_TOKEN }}
ENVLESS_KEY: ${{ secrets.ENVLESS_KEY }}
ENVLESS_PROJECT: my-app
ENVLESS_ENV: production
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
# the envless binary ships in @goenvless/cli, which a runner does not have until you install it
- run: npm i -g @goenvless/cli@latest
- run: envless run -- npm run buildenvless run exits with the child's own exit code, so a failing build still fails the job. A command it cannot start at all exits 127, and a child killed by a signal exits 128 plus the signal number.
An ev_sk_ token carries its own workspace scope, so the CLI does not ask for --workspace here. A user token from a cached envless login would.
Drop both CLI steps if the build already loads Envless on its own, through a withEnvless Next config or a node --import @goenvless/env/register entrypoint. Those paths need @goenvless/env in your package.json, nothing global, and npm ci installs it.
If .envless is committed and checked out, you can drop ENVLESS_PROJECT / ENVLESS_ENV and let the file supply them, but setting them explicitly is unambiguous and survives a checkout that omits the file.
Vercel
Wrap your Next config
Use the real config plugin so the encrypted bundle is loaded during next build:
import { withEnvless } from '@goenvless/env/next';
export default withEnvless({
// your existing Next config
});Add the credentials to the project
In your Vercel project's environment variables, set ENVLESS_TOKEN, ENVLESS_KEY, ENVLESS_PROJECT, and ENVLESS_ENV. withEnvless awaits load() before it hands the config back, writes the decrypted values into process.env for server and build code, and copies only the variables you marked client into config.env, next to an ENVLESS_CLIENT_ENV payload that @goenvless/env/client reads.
Server-visibility variables never reach config.env. Any one of them whose name starts with NEXT_PUBLIC_ is also deleted from process.env for the rest of the build, with a warning naming it, because Next would otherwise inline it into the client chunks on the strength of the prefix alone.
Keep the filesystem entries off the edge
@goenvless/env/server pulls in node:fs, node:os and node:path, because the loader reads .envless and the on-disk cache. The root . entry, /next, /vite and /register go through that same loader, so all five carry those builtins. An Edge Function and middleware.ts have none of them, so none of those five entries can run there.
Edge runtimes use @goenvless/env/edge instead. Its load() takes { token, project, environment }, one of key or passphrase, and the optional product, workspaceId, endpoint and fetch. It imports no Node builtins, keeps no cache, and returns a plain record rather than touching process.env. @goenvless/env/crypto is pure Web Crypto as well, so encrypt, decrypt, workspaceKey and passphraseStrength are available on the edge too. See Workers & Edge.
Scope previews
For preview deployments, set ENVLESS_ENV=staging (or whichever environment you mirror previews against) on Vercel's Preview scope.
Docker
FROM node:20-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
# nothing in this stage loads Envless on its own, so the CLI has to wrap the build
RUN npm i -g @goenvless/cli@latest
COPY . .
ENV ENVLESS_PROJECT=my-app ENVLESS_ENV=production
RUN --mount=type=secret,id=envless_token \
--mount=type=secret,id=envless_key \
ENVLESS_TOKEN=$(cat /run/secrets/envless_token) \
ENVLESS_KEY=$(cat /run/secrets/envless_key) \
envless run -- npm run build
FROM node:20-slim
WORKDIR /app
COPY --from=build /app .
# ENVLESS_TOKEN + ENVLESS_KEY provided at runtime by the platform
ENV ENVLESS_PROJECT=my-app ENVLESS_ENV=production
CMD ["node", "--import", "@goenvless/env/register", "server.js"]docker build \
--secret id=envless_token,env=ENVLESS_TOKEN \
--secret id=envless_key,env=ENVLESS_KEY \
-t myapp .The token and key are mounted as build-time secrets, so they never enter the image layers.
Drop the global install and the envless run -- wrapper if your build already loads Envless itself, through withEnvless or the Vite plugin. Without one of the two, the build stage runs with the credentials in its environment and no variables, because setting ENVLESS_TOKEN does not by itself fetch anything.
The runtime stage needs the preload for the same reason. --import @goenvless/env/register runs await load() before server.js executes its first line, and /register is the only entry that fetches and decrypts on import.
Fly.io, Railway, Render
Set ENVLESS_TOKEN and ENVLESS_KEY as platform secrets, and ENVLESS_PROJECT / ENVLESS_ENV as plain env vars. Build and run as normal. node --import @goenvless/env/register fetches and decrypts at boot, before your entry file runs.
Nothing else loads on its own. A process that neither preloads /register nor awaits load() starts with the credentials in its environment and an empty store, and the first read off env throws Envless env is not loaded yet. Import '@goenvless/env/register' or await load() first.
Kubernetes
env:
- name: ENVLESS_TOKEN
valueFrom:
secretKeyRef:
name: envless
key: token
- name: ENVLESS_KEY
valueFrom:
secretKeyRef:
name: envless
key: key
- name: ENVLESS_PROJECT
value: my-app
- name: ENVLESS_ENV
value: productionThese arrive as plain env vars, so the image still has to load. The --import @goenvless/env/register preload in the container's CMD is what turns them into variables at pod startup. For zero-downtime rotation, pair with an ExternalSecret operator to roll the token/key secrets.
Choosing the environment
Envless resolves the project and environment in this order:
- The
--project/--envflag (CLI commands only). - The
ENVLESS_PROJECT/ENVLESS_ENVenv var. - The committed
.envlessfile.
The product resolves the same way, through --product, then ENVLESS_PRODUCT, then the product key in .envless. The environment falls back to the first entry in the file's environments array, and envless run warns when it picks one out of several.
In CI, set ENVLESS_PROJECT and ENVLESS_ENV explicitly. Relying on the file is brittle across checkouts.
What ships to the runtime
| Component | Dev | CI build | Production runtime |
|---|---|---|---|
.envless | committed | committed | committed |
envless-env.d.ts | committed | committed | committed |
ENVLESS_TOKEN | CLI only, from the cached login | required | required |
ENVLESS_KEY / ENVLESS_PASSPHRASE | CLI only, from the cached key | required | required |
| Encrypted bundle | fetched on demand | fetched at build | fetched at boot |
| Plaintext values | in the child process env, or process.env under the loader | in process.env, client ones also inlined into client chunks | in process.env |
Those two dev cells belong to the CLI. The @goenvless/env loader has neither fallback: it rejects any token that is not an ev_sk_ machine key, with ENVLESS_TOKEN must be a machine key (ev_sk_…), and it never consults the cached workspaceKeys. A node --import @goenvless/env/register process therefore needs ENVLESS_TOKEN plus ENVLESS_KEY or ENVLESS_PASSPHRASE on a laptop exactly as it does on a runner.
The loader does not clobber the runner. A name the platform already set in process.env keeps its value, and Envless only fills in the ones that are absent or that it wrote on a previous load. Rename the variable in Envless, or unset it on the platform, when the two disagree. envless run is the other way round: it hands the child the runner's own environment with the decrypted values layered over it, so there an Envless variable wins.
Only the @goenvless/env loader fills the on-disk cache. It writes the ciphertext to ~/.envless/cache and reuses it for 15 minutes before refetching, which is what makes a restart cheap. The CLI never reads or writes that cache, and envless run fetches every time. It only ever deletes from it: envless cache clear (--all wipes the whole directory), plus an automatic invalidation after envless push, envless var set, envless var remove, envless version restore, envless env delete and envless project delete. Caching & Reloads covers the window in detail.
A variable whose name would change how a process executes code is decrypted but deliberately not written into process.env, and not passed to the child of envless run either. The list is fifteen names, matched on the whole name and not by prefix, and without regard to case, so path and node_options are refused exactly like PATH and NODE_OPTIONS: NODE_OPTIONS, NODE_PATH, NODE_REPL_EXTERNAL_MODULE, ELECTRON_RUN_AS_NODE, LD_PRELOAD, LD_LIBRARY_PATH, LD_AUDIT, DYLD_INSERT_LIBRARIES, DYLD_LIBRARY_PATH, DYLD_FRAMEWORK_PATH, PATH, BUN_INSPECT, BUN_INSPECT_CONNECT_TO, RUBYOPT, RUBYLIB, BUNDLE_GEMFILE, GEM_HOME, GEM_PATH, PYTHONPATH, PYTHONHOME, PYTHONSTARTUP, PYTHONUSERBASE, PHPRC, PHP_INI_SCAN_DIR, ENVLESS_CLIENT_ENV and ENVLESS_INJECTED. Both the loader and the CLI print a warning naming the ones they refused. Under the loader the values are still in the store, so env from @goenvless/env/server reads them normally, they simply never become process environment.
Going deeper
The same machine-credential pattern applies to anything unattended, including a coding agent running on a developer machine.