On-disk cache
After the first successful fetch, the entrypoints that can reach a filesystem (., /server, /next, /vite, /register) write the encrypted bundle to an on-disk cache under:
~/.envless/cache/one entry per workspace + product + project + environment, named <workspace>__<product>__<project>__<environment>.json, with the workspace prefix present only when a .envless file names one and _ standing in for a project with no product. What's stored are the ciphertext values the API returned, never decrypted ones; variable names, types and visibility sit beside them in the clear. Decryption happens in memory on every load. That's why the cache is safe at rest: the decryption key is never written to it. The directory is mode 0700 and each file is mode 0600.
Writing the cache is best effort. If the write throws, the failure is swallowed and the load still succeeds, the next boot simply fetches again. An entry whose stored workspace doesn't match the one resolved now is treated as a miss, so a cached bundle is never served across workspaces. @goenvless/env/edge writes nothing and reads nothing: it has no filesystem, so every call fetches.
The related warning you see in the terminal comes from envless passphrase set, and it is about the derived key it caches in ~/.envless/config.json, not about this ciphertext cache. Silence it on trusted hosts and CI with ENVLESS_NO_INSECURE_CACHE_WARNING set to 1, true, yes or on.
On subsequent boots the loader reads the cached ciphertext instead of re-fetching, as long as the entry is less than 15 minutes old, then decrypts it in memory, so repeat starts skip the variables request while still requiring the key to read anything.
A hit is only fully offline when you decrypt with ENVLESS_KEY, which is imported as a raw key. With ENVLESS_PASSPHRASE the key is derived from a workspace salt, so before the loader even looks at the cache it resolves the workspace over the API and then runs PBKDF2 at 200,000 iterations. That call happens on every load, hit or miss.
The 15 minute window
Every cache entry carries the cachedAt timestamp of the fetch that wrote it. An entry counts as fresh while its age is under 15 minutes; anything older, anything stamped in the future, and anything with an unreadable timestamp counts as a miss and sends the loader to the API.
The window is a hard cut, not a grace period. There is no stale-while-revalidate and no fallback to an expired entry: once the entry ages out, the fetch has to succeed. If it fails, load() rejects with
Could not load variables for <project>/<environment>: <reason>and nothing is populated, so on a first boot env.X keeps throwing
Envless env is not loaded yet. Import '@goenvless/env/register' or await load() first.and process.env is left untouched. A failed load is dropped from the memo as it rejects, so the next load() call retries the fetch rather than replaying the failure. If an earlier load had already succeeded, the store keeps the values it already holds, so env.X goes on returning the previous ones.
Cache lifecycle
| Event | Behavior |
|---|---|
| First load | Fetch ciphertext from the API, write the cache, decrypt in memory. |
A second load() in the same process | Returns the memoized promise. No disk read, no network. |
| Next boot | Read cached ciphertext while the entry is under 15 minutes old (no variables request; ENVLESS_PASSPHRASE still costs one workspace call), decrypt in memory. |
| Entry older than 15 minutes | Refetch. The expired entry is never served as a fallback, so a failed fetch fails the load. |
| Ephemeral CI runner | Cache doesn't persist between jobs, so each job fetches fresh. |
| Entry written under another workspace | Treated as a miss, so the loader fetches. |
| Passphrase / key rotated | Cached ciphertext no longer decrypts with the new key, so the loader falls through to the API for the re-encrypted bundle. If that one doesn't decrypt either, load() rejects with Could not decrypt <NAME>. Check ENVLESS_KEY or ENVLESS_PASSPHRASE. |
load({ revalidate: true }) | Skips the memo and the cached entry, refetches, then overwrites that entry with the fresh ciphertext. |
| Variable changed in dashboard | Picked up on the next fresh fetch: revalidate, clear the cache, or restart once the cached entry has aged past 15 minutes. |
Refreshing variables
The cache favors speed, so a change you make in the dashboard isn't picked up until the loader fetches again. Force a fresh fetch with revalidate:
import { load } from '@goenvless/env/server';
process.on('SIGHUP', () => {
load({ revalidate: true }).catch(error => console.error('[envless] reload failed', error));
});load({ revalidate: true }) skips the memo and the on-disk cache, refetches the latest ciphertext, writes it back over the cache entry, and repopulates the env proxy. Catch the rejection as above: a bare load() in a signal handler turns a failed refresh into an unhandled rejection.
It also writes the refreshed values into process.env, with limits. A name that isn't there yet is added, a name a previous load() put there is updated, a name that was already set from somewhere else is never overwritten, and a name an earlier load wrote that is missing from the set this load returned is deleted.
A variable whose name would change how the process or its children execute code is decrypted and handed back to you, but deliberately never written into process.env: 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. Those are named in an [envless] warning on stderr instead, and you still read their values through env.X. So revalidate is handy for rotating values without a full restart, as long as what reads them is env, not process.env.
Dev mode
The framework adapters (@goenvless/env/next, @goenvless/env/vite) call load() once, when the framework resolves its config at dev-server startup. They do not hold a live connection to the API and don't hot-reload variables mid-session. To pick up a dashboard change in development, restart the dev server so the config re-evaluates and load() runs again.
Production
In production the cache is read at boot and isn't revalidated on its own. Rotate a variable, clear the cache or wait out the 15 minute window, restart the process, done. If you need to refresh without a restart, call load({ revalidate: true }) from a SIGHUP handler or a webhook, as above.
Cold-start cost
| Scenario | Cost |
|---|---|
Cache hit with ENVLESS_KEY | Local read + in-memory decrypt, no network |
Cache hit with ENVLESS_PASSPHRASE | One workspace call for the salt, PBKDF2 at 200,000 iterations, then the local read and decrypt |
| Cache miss | One variables request per 100 variables (the page size), then decrypt |
@goenvless/env/edge | Fetches on every call, one request per 100 variables, plus one workspace call when you pass passphrase without workspaceId, then decrypts. There is no on-disk cache on the edge: reading it needs node:fs, which is exactly why ., /server, /next, /vite and /register cannot run on an edge runtime. |
| Client values inlined at build time | Zero: withEnvless and the Vite plugin bake the client-visibility variables into the bundle as literals |
Clearing the cache
The cache is written by @goenvless/env, but the command that clears it ships in the CLI.
The envless command ships in @goenvless/cli. Install it with npm i -g @goenvless/cli@latest.
envless cache clear # clears the current project/env
envless cache clear --all # clears the entire cacheenvless cache clear targets the product, project and environment resolved from --product / --project / --env flags, then the ENVLESS_PRODUCT / ENVLESS_PROJECT / ENVLESS_ENV env vars, then the .envless file. There is no --workspace flag, and none is needed: the match runs on the tail of the file name, so the entry is removed under every workspace prefix. --env takes a comma separated list, and a .envless file that names several environments clears each of them. When no product is resolved it clears every product's copy of that project + environment, so a stale entry can never survive. Clearing something that isn't there still exits 0; only a run that resolves no project or environment and has no --all exits 1. Or just delete ~/.envless/cache/; the next boot rebuilds it.
Writes you make through the CLI clear the matching entry on their own, so you rarely need this after one: envless push, envless var set, envless var rm, envless version restore, envless env delete and envless project delete all invalidate it, and envless logout wipes the whole directory.