The runtime mode decrypts your variables in-process and hands your code a typed env object. It also populates process.env with every name that is safe to set, so libraries that read it directly keep working. No .env files on disk, no dotenv step, no process.env.X typos that silently return undefined.
Writing Python? envless-env is this runtime for Python, with the same loader, cache and rules, and integrations for Django, Flask, FastAPI and pydantic-settings. Writing Ruby? envless-env on RubyGems is the same runtime for Rails, Rack, Sinatra and Hanami. Writing PHP? envless/env on Packagist is the same runtime for Laravel, Symfony and PHP-FPM.
Import surfaces
@goenvless/env ships nine entry points. Three of them expose the env object, and those are the ones you choose between for reading variables:
import { env } from '@goenvless/env'; // all vars, untyped
import { env } from '@goenvless/env/server'; // all vars, fully typed
import { env } from '@goenvless/env/client'; // client-visibility vars only| Import | Sees | Typed by envless types? | Use in |
|---|---|---|---|
@goenvless/env | every variable | ❌ never | quick scripts on Node or Bun, plus load() |
@goenvless/env/server | every variable | ✅ EnvlessServerEnv | server code on Node or Bun: API routes, RSC, jobs, CLIs |
@goenvless/env/client | client-flagged only | ✅ EnvlessClientEnv | browser / 'use client' code |
The other six hand you something that isn't an env object:
| Import | What it is |
|---|---|
@goenvless/env/register | side-effect preload, the only entry that runs the loader on import |
@goenvless/env/edge | load(options) for runtimes with no filesystem. Returns a plain record. See Edge runtimes |
@goenvless/env/crypto | the standalone crypto surface: encrypt, decrypt, encryptWithKey, decryptWithKey, workspaceKey, importWorkspaceKey, passphraseStrength, ENCRYPTION_PARAMETERS |
@goenvless/env/next | the withEnvless config wrapper |
@goenvless/env/vite | the envless() plugin |
@goenvless/env/eslint | the ESLint plugin that stops /server imports reaching client files |
For autocomplete and type-checking, import from /server (or /client). The root entry point is declared as a bare [key: string]: string | number | boolean index signature and envless types never augments it, so nothing autocompletes and no misspelling is caught. It also applies no visibility filter, so it reads server-only variables exactly like /server. It does re-export encrypt(), decrypt(), passphraseStrength() and ENCRYPTION_PARAMETERS, but it pulls node:fs in alongside them. Import /crypto when you want those primitives on their own: it is pure Web Crypto, with no Node built-ins and no Buffer, and it is the only one of the two that also gives you encryptWithKey, decryptWithKey, workspaceKey and importWorkspaceKey. See Server vs Client for how the split is enforced.
Loading is asynchronous
The store starts empty. Envless has to fetch ciphertext from the API and decrypt it locally before env has anything in it, and that's an async operation. Reading env.X before the load finishes throws:
Envless env is not loaded yet. Import '@goenvless/env/register' or await load() first.Importing @goenvless/env or @goenvless/env/server loads nothing by itself, so there are two ways to make sure the load has happened first.
/client is the exception. It hydrates synchronously at import from the payload the framework adapter inlined at build time, so client code never calls load() and never needs /register. It doesn't even export load. The root entry, /server, /next, /vite and /register all pull in the loader, which reads the .envless file and the on-disk cipher cache through node:fs, so none of those five can be bundled for Cloudflare Workers, Vercel Edge or Netlify Edge. Only /client, /edge and /crypto are built free of Node built-ins. On the edge, /client gives you the values it was seeded with at build time, and /edge fetches and decrypts at request time.
Eager preload with /register (recommended)
@goenvless/env/register is a side-effect module that runs the loader with a top-level await. It exports nothing, and it is the only entry point that runs the loader on import. Put it first at your entry point, or inject it from the launcher:
// Runs before anything below it, env is populated by the time your code runs.
import '@goenvless/env/register';
import { env } from '@goenvless/env/server';
const db = new Database(env.DATABASE_URL);node --import @goenvless/env/register app.jspreload = ["@goenvless/env/register"]Or await load() before you read
import { env, load } from '@goenvless/env/server';
await load(); // fetch + decrypt + populate process.env
console.log(env.DATABASE_URL);load() is memoised. Repeated calls resolve the same load exactly once, and a load that rejects clears the memo so the next call retries. The on-disk cipher cache is reused only while it is under 15 minutes old, otherwise the loader refetches. Pass load({ revalidate: true }) to force a fresh fetch, bypassing both the in-memory memo and the on-disk cipher cache.
Frameworks hide this step for you. withEnvless (Next.js) and the envless() Vite plugin both await load() during config evaluation, so you never call it by hand.
Serialising env throws
The env object from the root entry point and from /server cannot be turned into JSON. Neither applies a visibility filter, so one JSON.stringify would copy every decrypted secret into the output, and the usual destinations for that output are a props payload sent to the browser, a log line, or an error report on its way to a third party. It is refused instead:
import { env } from '@goenvless/env/server';
JSON.stringify(env); // throws
JSON.stringify({ env }); // throws, stringify walks into the value
res.json(env); // throws, Express serialises it for youRefusing to serialise the Envless env, because it holds decrypted secrets and this would copy every one of them into the output. Read the names you need, or call load() for a plain record you own.Do what the message says. Build the payload from the names you actually meant to send, or serialise the plain Record<string, string> that load() hands back, which is a copy you own rather than the live proxy:
import { env, load } from '@goenvless/env/server';
const payload = { region: env.AWS_REGION, tier: env.PLAN_TIER }; // name what leaves
const record = await load(); // a plain object, yours to serialise/client is unaffected and serialises normally. Its proxy filters by visibility, so the JSON it produces holds client-flagged variables only, the same set the framework plugin already inlined into the bundle. The throw doesn't wait for a load either: JSON.stringify on an unloaded server env is refused exactly the same way.
This stops the accidental serialisation, it is not a containment boundary. { ...env } and Object.entries(env) still copy values out of the root and /server surfaces, so keep treating both as secret material. Full details in the runtime reference.
Edge runtimes
@goenvless/env/edge is the entry point for runtimes with no filesystem. It exports load(options), also available as the default export, and does the whole round trip at request time: fetch the ciphertext over the public API, import or derive the key, decrypt, and return a plain Record<string, string>.
It behaves nothing like the in-process loader. It never writes process.env, it has no in-memory memo and no on-disk cipher cache, and it reads nothing from .envless, so everything it needs arrives as an argument. envless types doesn't augment the record either, and the record holds every variable in the environment with no visibility filter, so treat it as server-side like /server.
import { load } from '@goenvless/env/edge';
export default {
async fetch(request: Request, bindings: Record<string, string>): Promise<Response> {
const vars = await load({
token: bindings.ENVLESS_TOKEN, // ev_sk_… machine key
project: 'checkout-api',
environment: 'production',
key: bindings.ENVLESS_KEY // or passphrase: bindings.ENVLESS_PASSPHRASE
});
return new Response(vars.STRIPE_SECRET ? 'ok' : 'missing');
}
};| Option | Required | Purpose |
|---|---|---|
token | ✅ | ev_sk_ machine key. Omit it and load() throws Pass a machine key as { token }. An edge runtime has no .envless file and no config to read one from. |
project | ✅ | project slug. Missing either this or environment throws Pass { project } and { environment }. An edge runtime has no .envless file to resolve them from. |
environment | ✅ | environment slug |
product | product slug, when the project slug exists in more than one product | |
passphrase | one of the two | workspace passphrase; the key is derived from it |
key | one of the two | base64url raw workspace key, imported directly. Checked first, so it wins when both are passed |
workspaceId | skips the workspace lookup that a passphrase would otherwise need before deriving | |
endpoint | override the API base URL | |
fetch | supply the runtime's fetch |
Pass neither passphrase nor key and it throws Pass { passphrase } or { key }. Without one the values cannot be decrypted. Pass a passphrase on a token whose workspace can't be resolved and it throws The workspace could not be resolved, so the passphrase cannot be turned into a key. Pass { workspaceId } explicitly.
Because nothing is cached, every load() is a fresh fetch and a fresh decrypt. Hold the returned record for the life of the isolate if you'd rather pay that once.
Boot populates process.env
When the load resolves, decrypted values are written into process.env, but without clobbering variables that are already set. An ambient var from your shell or platform wins, matching dotenv semantics. That means anything reading raw process.env (Prisma, the AWS SDK, third-party libraries) just works, and you can still override any single value at the process level.
One set of names is refused outright. A variable called 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 or ENVLESS_INJECTED is decrypted and readable through env, but never written into process.env, because those names change how the process and its children execute code. The loader prints an [envless] warning naming each one it skipped.
run behaves the opposite way on precedence. With envless run, decrypted values override same-named ambient vars. The in-process loader (load() / /register) lets ambient win; run lets Envless win. Both refuse the same list of code-executing names when they build an environment.
Setup
Install Envless
npm i @goenvless/env@latestThe envless CLI is a separate package and it is the only one that ships the binary. Install it globally so the envless command is on your PATH:
npm i -g @goenvless/cli@latestLink the project
envless login
envless linklink writes a .envless file that tells the loader which project and environment to fetch. (You can skip the file and set ENVLESS_PROJECT / ENVLESS_ENV instead. See below.)
Generate types
envless typesWrites envless-env.d.ts into the directory you run the command from. No decryption key needed. It reads only names, types, and visibility. Commit it; it contains no secrets. See Typed Variables.
Provide machine credentials
The in-process loader authenticates with a machine key, not your interactive login. Set two variables in the environment:
export ENVLESS_TOKEN=ev_sk_xxxxxxxxxxxx # from dashboard → Keys
export ENVLESS_KEY=xxxxxxxxxxxx # from Settings → CI / Machine AccessENVLESS_TOKEN fetches the ciphertext; ENVLESS_KEY (base64url raw workspace key) decrypts it locally. You can substitute ENVLESS_PASSPHRASE for ENVLESS_KEY if you'd rather derive the key from the workspace passphrase.
Preload and run
node --import @goenvless/env/register app.jsOn boot the loader fetches the encrypted bundle, decrypts it with ENVLESS_KEY, exposes env, and fills process.env with every name it is allowed to set.
What the loader reads
| Variable | Purpose |
|---|---|
ENVLESS_TOKEN | ev_sk_ machine key that authenticates the ciphertext fetch. Required. |
ENVLESS_KEY | base64url raw AES-256 workspace key (32 bytes); the bundle is decrypted locally. Required unless ENVLESS_PASSPHRASE is set. |
ENVLESS_PASSPHRASE | alternative to ENVLESS_KEY; the key is derived from it locally. ENVLESS_KEY wins when both are set. |
ENVLESS_PRODUCT | product slug, when the project slug exists in more than one product. Falls back to .envless. Optional. |
ENVLESS_PROJECT | project slug to fetch. Falls back to .envless. |
ENVLESS_ENV | environment slug to fetch. Falls back to the first entry in .envless. |
ENVLESS_DISABLE_UPDATE_NOTICE | any non-empty value silences the one-time check for a newer published @goenvless/env. The check only runs when stdout is a terminal, so CI and production never make it. Optional. |
ENVLESS_TOKEN must be an ev_sk_ machine key. With the variable unset the loader falls back to the token envless login cached on this machine, and since that is an interactive session token it fails with ENVLESS_TOKEN must be a machine key (ev_sk_…). Create one in the Envless dashboard. The in-process loader is always machine-authenticated.
What you can delete
- Your
.env/.env.local/.env.developmentfiles. The loader never reads them, and anything already in the environment when the load resolves still wins as an override. dotenvanddotenv-clifrompackage.json.- Hand-rolled
process.env.X!casts andundefinedchecks. Names, types and visibility live in Envless, and reading a variable that isn't set throws instead of returningundefined. Envless does not validate values, so keept3-envorenvalidif you rely on the checks they run. .env.*entries in.gitignore.
What it looks like in practice
import '@goenvless/env/register';
import { env } from '@goenvless/env/server';
const db = new Database({
url: env.DATABASE_URL, // string
poolSize: env.DB_POOL_SIZE, // number, coerced from the variable's type
});
const stripe = new Stripe(env.STRIPE_SECRET);
if (env.FEATURE_X_ENABLED) { // boolean, coerced from the variable's type
enableFeatureX();
}Coercion follows the type you set on each variable in Envless. A number variable is passed through Number(), a boolean variable is true only for the strings true or 1, and everything else stays a string. Nothing beyond that is checked, so a number variable holding abc reads back as NaN.
Every property is autocompleted from envless-env.d.ts. Misspell one and TypeScript fails the build. Read one that isn't set and it throws Missing env var: NAME at the point you read it, never a silent undefined.
When a read is genuinely allowed to come back empty, import optional instead. Both /server and /client export it alongside env, and it is the same proxy with one rule relaxed:
import { env, optional } from '@goenvless/env/server';
const stripe = new Stripe(env.STRIPE_SECRET); // throws if unset
if (optional.SENTRY_DSN) initSentry(optional.SENTRY_DSN); // undefined if unsetoptional is typed Partial<EnvlessServerEnv>, so your generated names come back with | undefined and TypeScript makes you handle the empty case. Values are still coerced by their declared type, the /client view still refuses server-only names, and it still throws the not-loaded error before the loader has run, so a misconfigured process fails loudly rather than reading undefined everywhere.
Framework-specific setup
Next.js
App Router, Pages Router, Edge.
Vite
SPAs, SvelteKit, Remix-on-Vite, and more.
Node
Express, Fastify, Hono, scripts.
Bun
Preload hook or direct import.
Workers / Edge
@goenvless/env/edge on Cloudflare, Vercel Edge, Netlify Edge.