The store starts empty. The Python runtime has to fetch the ciphertext and decrypt it before env holds anything, and reading env.X before that raises NotLoadedError:
Envless env is not loaded yet. Import 'envless_env.register' or call load() first.Importing envless_env or envless_env.server loads nothing by itself. There are two ways to make sure the load has happened before your code reads a variable.
Import envless_env.register first
envless_env.register loads when it is imported and exports nothing. Make it the first import of your entry point, the module your server or worker imports before anything else, and every later import sees a loaded environment.
import envless_env.register # noqa: F401
from envless_env.server import env
database_url = env.DATABASE_URLIf the load fails, the import raises the same LoadError that load() would, so a misconfigured process stops at boot instead of serving requests with a hole in its configuration.
Or call load() yourself
from envless_env.server import env, load
record = load()
debug = env.DEBUGload() returns a plain dict[str, str] of every decrypted value, a copy you own and may serialise. It is memoised: later calls return the same values without fetching again, and calls from several threads wait for one load instead of starting their own. A load that raises is not memoised, so the next call tries again.
In asyncio or trio code, await aload() does the same work in a worker thread, so the event loop keeps running while the key is derived and the values are fetched.
from envless_env.server import aload
async def startup() -> None:
await aload()The credentials it reads
| Variable | Purpose |
|---|---|
ENVLESS_TOKEN | An ev_sk_ machine key that authenticates the fetch. Required. |
ENVLESS_KEY | The base64url raw workspace key, 32 bytes. The values are decrypted locally with it. Required unless ENVLESS_PASSPHRASE is set |
ENVLESS_PASSPHRASE | The workspace passphrase, from which the key is derived. ENVLESS_KEY wins when both are set |
ENVLESS_PRODUCT | The product slug, when the project slug exists in more than one product. Falls back to .envless |
ENVLESS_PROJECT | The project slug. Falls back to .envless |
ENVLESS_ENV | The environment slug. Falls back to the first environment in .envless |
ENVLESS_API_ENDPOINT | Overrides the API base URL, https://api.envless.cloud by default |
ENVLESS_DISABLE_UPDATE_NOTICE | Any non-empty value silences the one-time check for a newer envless-env on PyPI. The check only runs when stdout is a terminal |
Get the machine key from Keys in the dashboard and the raw workspace key from machine access. With ENVLESS_TOKEN unset, the loader falls back to the token envless login stored on this machine, and since that is a session token it fails with ENVLESS_TOKEN must be a machine key (ev_sk_…). Create one in the Envless dashboard. The standalone loader is always machine authenticated.
The .envless file is the one envless link writes into your project folder. The loader reads it from the current working directory, so start your process from the folder that holds it, or set the three slugs as variables instead.
What a load does
Resolve the target and the key
The token, then the product, project and environment, then the key. With ENVLESS_PASSPHRASE, the loader asks the API which workspace the token belongs to, because the key is derived from the passphrase and a salt bound to that workspace. With ENVLESS_KEY there is no request at this step.
Read the cache, or fetch
A cache entry under 15 minutes old is decrypted in memory and nothing is fetched. Otherwise the ciphertext is fetched, 100 variables per request, and written back to the cache.
Decrypt and fill the process environment
Every value is decrypted in memory. If one will not decrypt the load fails naming it, and nothing is populated. On success, env holds every value, and os.environ gains every name it is allowed to take.
The process environment
Decrypted values are written into os.environ without overwriting anything already set, so a variable from your shell or platform wins there, the way python-dotenv behaves by default. Libraries that read os.environ directly, such as boto3 or a database driver, keep working. The env object always hands back the Envless value.
Names that change how a process or its children execute code are never written into os.environ: 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, whatever their case. A stored PYTHONPATH would otherwise decide what every child Python process imports: a subprocess, a multiprocessing worker, the development server's reloader. They are still decrypted and readable through env, and the loader logs a warning on the envless_env logger naming each one it skipped. With no logging configured, Python prints it to stderr, and -W error cannot turn it into a failed load.
Inside envless run
When envless run or envless build starts your process, the CLI has already decrypted the values into the environment and written a manifest, ENVLESS_INJECTED, naming each variable with its type and visibility. The first time anything touches the store, the Python runtime adopts those names straight from os.environ: no ENVLESS_TOKEN, no key, no register import and no request.
envless run --env production -- python app.pyload() then returns the injected values without fetching. load(revalidate=True) deliberately escapes the seed and runs a real load, so it needs a machine key like any standalone load. See typed variables inside envless run for the manifest in detail. It is the same for both runtimes.
Reloading
load(revalidate=True) skips the memo and the cache, fetches again, and replaces the values in env. In os.environ, a name a previous load wrote is updated, a name that has disappeared is removed, and a name that was already set from somewhere else is still left alone.
import logging
import signal
from types import FrameType
from envless_env import LoadError
from envless_env.server import load
def reload(signum: int, frame: FrameType | None) -> None:
try:
load(revalidate=True)
except LoadError:
logging.getLogger(__name__).exception('envless reload failed, keeping the old values')
signal.signal(signal.SIGHUP, reload)Catch the error inside the handler. A signal handler runs in the middle of whatever the main thread was doing, so an exception that escapes it lands in your server loop.
The cache
After a fetch, the ciphertext is written to ~/.envless/cache/, one file per product, project and environment, prefixed with the workspace when .envless names one, in the same format @goenvless/env writes, so a Node and a Python app on one machine share it. Only ciphertext is stored, never a decrypted value, in a folder with mode 0700 and files with mode 0600. Writing it is best effort: a failed write never fails the load. An entry older than 15 minutes, stamped in the future or written under another workspace is ignored. With ENVLESS_KEY, a load from the cache makes no request at all. With ENVLESS_PASSPHRASE it still resolves the workspace over the API and derives the key first. envless cache clear removes the entry for the current product, project and environment, and envless cache clear --all removes every one. Caching and reloads covers the details, which are identical in both runtimes.
Servers, workers and scripts
| Process model | Where the load happens |
|---|---|
Gunicorn with --preload | Once in the master, before it forks. Every worker inherits the loaded env and os.environ, so the whole server loads once |
Gunicorn without --preload, Uvicorn with --workers | In each worker as it imports your app. The first fetch writes the cache, and later workers read it |
| Celery | In each worker as it imports your Celery app, so put import envless_env.register at the top of that module, or of your Django settings |
| Scripts and notebooks | Call load() at the top. In Jupyter and IPython, await aload() works in a cell too |
When a load fails
Every failure raises LoadError, with the API or network error as its __cause__ where there is one. The store and os.environ are left as they were, so after a failed reload env keeps the values of the last load that worked.
| Message | Cause |
|---|---|
Missing ENVLESS_TOKEN. Set it to an Envless machine key (ev_sk_…) to load variables. | No token in the environment or in the CLI config |
ENVLESS_TOKEN must be a machine key (ev_sk_…). Create one in the Envless dashboard. | A token that is not an ev_sk_ machine key, such as your envless login session, refused before any request |
Missing ENVLESS_PROJECT. Set it to your project slug, or add a .envless file. | No project slug |
Missing ENVLESS_ENV. Set it to your environment slug, or add a .envless file. | No environment slug |
Set ENVLESS_KEY (a workspace key) or ENVLESS_PASSPHRASE to decrypt in machine mode. | Neither decryption credential |
ENVLESS_KEY is invalid: Invalid 256-bit key: got 16 bytes. | A raw key of the wrong length |
ENVLESS_PASSPHRASE is set but the workspace could not be resolved to derive the key. | The API answered the workspace lookup without a workspace id |
Invalid API key. | The workspace lookup on the passphrase path failed: the API's own message, with no prefix |
Could not load variables for api/production: Project not found. | The API refused the request, or never answered |
Could not decrypt STRIPE_SECRET. Check ENVLESS_KEY or ENVLESS_PASSPHRASE. | A value the key cannot open |