@goenvless/env is the Envless runtime, the package your application imports. It exposes nine entrypoints: a root import, server/client splits, an edge loader, a standalone crypto surface, a preload hook, and framework plugins. This page documents each one exactly.
npm i @goenvless/env@latestThis is the runtime package, not the SDK: it loads variables into a running application. If you want to administer a workspace from code, creating projects, writing variables or managing members, that is @goenvless/sdk, a separate package, or envless-sdk in Python and Ruby. None of them depends on this one. See CLI, runtime and SDK for the full comparison. The runtime for Python apps is envless-env, for Ruby apps envless-env on RubyGems, and for PHP apps envless/env on Packagist, all with the same loader and the same cache.
This package ships no terminal command. The envless CLI is its own package, @goenvless/cli, installed globally with npm i -g @goenvless/cli@latest.
Entrypoints at a glance
| Import | Exports | Use in |
|---|---|---|
@goenvless/env | env, load, encrypt, decrypt, passphraseStrength, ENCRYPTION_PARAMETERS | Node scripts, env here is untyped and unfiltered |
@goenvless/env/server | env, optional, load, __override | Node server code, every variable, typed |
@goenvless/env/client | env, optional | Browser code, client-visibility only, typed |
@goenvless/env/edge | load (also the default export) | Edge runtimes, returns a plain record |
@goenvless/env/crypto | encrypt, decrypt, encryptWithKey, decryptWithKey, workspaceKey, importWorkspaceKey, passphraseStrength, ENCRYPTION_PARAMETERS | Crypto anywhere, no filesystem |
@goenvless/env/register | (nothing, side effect only) | Preload before your entrypoint. The only entrypoint that runs the loader on import |
@goenvless/env/next | withEnvless | next.config |
@goenvless/env/vite | envless | vite.config |
@goenvless/env/eslint | the plugin (default export), rules, configs, meta | ESLint flat config |
., /server, /next, /vite and /register reach the filesystem: they read a .envless file and the on-disk cache through node:fs. They are Node entrypoints and cannot be imported into an edge runtime. /client, /crypto and /edge carry no node: import at all, which the package build asserts on every bundle it emits.
The async loading model
Variables are fetched as ciphertext and decrypted locally, which is asynchronous. Importing @goenvless/env or @goenvless/env/server starts nothing on its own, and env can only be read after the loader has run, so you have two ways to guarantee that:
import { load, env } from '@goenvless/env/server';
await load();
console.log(env.DATABASE_URL);# runs the loader (top-level await) before your app module evaluates
node --import @goenvless/env/register app.jsReading env before either of those throws Envless env is not loaded yet. Import '@goenvless/env/register' or await load() first.
Framework plugins (/next, /vite) handle this for you: they await load() during config evaluation. In those apps you never call load() yourself.
Loaded values are written into process.env without overwriting anything already set in the ambient environment, so libraries that read process.env directly keep working and real env vars still win there. That last part is about process.env only: the env proxy always hands back the Envless value.
Names that change how a process or its children execute code are 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. Storing one would turn write access to a variable into code execution. They are still decrypted, still readable through env and still present in the record load() returns, and the loader warns on stderr naming every one it refused.
Serialising a server env is refused
JSON.stringify(env) throws on the root entrypoint and on /server. Neither proxy carries a visibility filter, so one call 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. The proxy answers the toJSON lookup that JSON.stringify performs with a throw instead of a snapshot:
import { env } from '@goenvless/env/server';
JSON.stringify(env); // throws
JSON.stringify({ env }); // throws too, stringify walks into the value
res.json(env); // throws, Express serialises it for you
logger.info({ env }); // throws, if the logger serialises with JSON.stringifyRefusing 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 one of the two things the message names. Build the payload out of 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 serialiseThe refusal does not wait for a load. An unloaded server proxy is refused the same way, so a stray JSON.stringify fails identically at boot and in steady state. /client is the one surface that still serialises: its proxy has a visibility filter, so toJSON returns a snapshot of client-visibility variables only, coerced to their declared types, and server names are simply absent from it. That snapshot is the same set the framework plugins already inline into the bundle.
This guard catches the accidental serialisation, it is not a containment boundary. { ...env } and Object.entries(env) still copy values out of an unfiltered proxy, so keep treating the root and /server surfaces as secret material. A variable named toJSON cannot stand in for the guard: the proxy answers that name itself before it ever consults the store, so the guard still throws and the stored value is unreachable through env. The same holds for then and for every name Object.prototype owns, which the proxy reserves as plumbing and hides from in, from Object.keys and from the /client snapshot.
@goenvless/env: root
The default entrypoint. Exposes the env proxy plus the loader and the crypto primitives. Importing it loads nothing on its own, so call load() before you read a variable.
Its env is the untyped surface: envless types never augments it, so it stays a bare [key: string]: string | number | boolean index signature with no autocomplete and no checking. It also carries no visibility filter, so it reads server-only variables just like /server does, and like /server it refuses to be serialised. Prefer /server or /client in application code and keep this entrypoint for scripts.
| Export | Signature |
|---|---|
env | Untyped, unfiltered proxy over your loaded variables |
load | (opts?: { revalidate?: boolean }) => Promise<Record<string, string>> |
encrypt | (plaintext: string, passphrase: string, workspaceUniqueId: string) => Promise<string> |
decrypt | (ciphertext: string, passphrase: string, workspaceUniqueId: string) => Promise<string> |
passphraseStrength | (passphrase: string) => { valid: boolean, error: string | null } |
ENCRYPTION_PARAMETERS | The cryptographic constants object |
import { env, load, encrypt, decrypt, ENCRYPTION_PARAMETERS } from '@goenvless/env';
await load();
const db = new Database(env.DATABASE_URL);
// crypto helpers, encrypt before writing to the API, decrypt what it returns
const ciphertext = await encrypt('postgres://…', passphrase, workspaceUniqueId);
const plaintext = await decrypt(ciphertext, passphrase, workspaceUniqueId);This entrypoint drags the loader in, and the loader reads the filesystem, so the crypto helpers here are Node only. Import them from /crypto when you need the same recipe in a browser or a worker.
ENCRYPTION_PARAMETERS exposes the exact constants so non-JS implementations can match the recipe byte-for-byte:
ENCRYPTION_PARAMETERS
// PBKDF2 · 200000 iterations · SHA-256 → AES-GCM-256
// 12-byte IV · 16-byte salt · namespace "envless:workspace:"
// ciphertext version prefix "v1:"@goenvless/env/server: every variable
Server surface. env exposes all variables, each coerced to its declared type (number, boolean, or string). Use it in API routes, server components, actions, jobs, and scripts, everywhere your code runs on Node.
It pulls the loader, and the loader uses node:fs for the .envless file and the on-disk cache, so this import cannot run on an edge runtime. Next.js middleware and any function you pin to the edge runtime need /edge instead.
Because this env sees everything, JSON.stringify on it is refused. Serialise the record load() returns, or the handful of names you meant to send.
| Export | Signature |
|---|---|
env | Typed proxy over all variables, coerced by type |
optional | The same proxy typed Partial<EnvlessServerEnv>, undefined instead of a throw for a name that is not set |
load | (opts?: { revalidate?: boolean }) => Promise<Record<string, string>> |
__override | (values: Record<string, string>) => EnvOverrideHandle, an object carrying Symbol.dispose |
import { env, load } from '@goenvless/env/server';
await load(); // or preload the process with @goenvless/env/register
const stripe = new Stripe(env.STRIPE_SECRET); // string
app.listen(env.PORT); // number, "3000" → 3000
if (env.FEATURE_X_ENABLED) enableX(); // boolean, "true" or "1" → trueenv throws Missing env var: NAME for a name the store does not hold, which is what you want for something the app cannot run without. For a read that is genuinely allowed to come back empty, use optional instead of guarding with in:
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 the same proxy with one rule relaxed, so values are still coerced by their declared type and the read-only rules still apply. It still throws the not-loaded error before the loader has run, so a misconfigured process fails loudly rather than reading undefined everywhere.
The proxy is immutable. Assigning to env, deleting a name off it, Object.defineProperty, Object.setPrototypeOf, Object.freeze and Object.seal all throw Envless env is read-only. __override is the sanctioned way to layer temporary values, and it is scoped and disposable rather than a mutation.
__override returns a using-disposable for tests. The override is a layer stacked on top of the loaded values, the innermost layer wins, and disposing it pops that layer off:
import { env, load, __override } from '@goenvless/env/server';
await load();
test('feature X on', () => {
using _ = __override({ FEATURE_X_ENABLED: 'true' });
expect(env.FEATURE_X_ENABLED).toBe(true);
});An override always carries a raw string. Coercion still follows the declared type of the loaded variable, so a name the loader never saw reads back as a plain string.
load() memoises a successful load: after the first call every later call hands back the same promise, and a cached environment on disk is reused while it is under 15 minutes old. A rejected load is dropped from the memo instead, so the next call retries rather than replaying the same failure forever. load({ revalidate: true }) skips both the memo and the cache and refetches. It is handy in a SIGHUP handler to rotate values without a full restart.
@goenvless/env/client: client-visibility only
Browser surface. env exposes only variables marked with client visibility in the dashboard. Reading anything else throws. That is a loud failure instead of a silent secret leak. It hydrates synchronously from the payload the framework plugin injects at build time, so there is no load() to await here and no node:fs in the client bundle.
| Export | Signature |
|---|---|
env | Typed proxy over client-visibility variables only |
optional | The same filtered proxy typed Partial<EnvlessClientEnv>, undefined instead of a throw for a name that is not set |
import { env } from '@goenvless/env/client';
fetch(env.NEXT_PUBLIC_API_URL); // ok, flagged client
fetch(env.STRIPE_SECRET); // throws, server-only variableoptional here carries the identical visibility filter, so it is not a way around the split. It only changes the failure mode for a name that is genuinely absent: optional.NEXT_PUBLIC_ANALYTICS_ID reads back undefined rather than throwing, while a server-only name still throws the visibility error either way.
A client bundle never receives a server value in the first place, so that second line throws Missing env var: STRIPE_SECRET. Where both surfaces share one process, a server render or a test, the value is in memory and the visibility filter is what refuses it: STRIPE_SECRET is server only. Import it from '@goenvless/env/server'. Filtered names are hidden from Object.keys(env) and from in as well, not just from reads.
That filter is also why this is the one env you can serialise. JSON.stringify on the root or /server proxy throws, while here it returns the client-visibility variables and nothing else.
Client values are inlined as string literals at build time by the framework plugins, so no decryption key ever reaches the browser. See Server vs Client for the full enforcement story.
@goenvless/env/edge: edge runtimes
An explicit loader for runtimes with no filesystem: Vercel Edge Functions, Cloudflare Workers, Deno Deploy, Next.js middleware. It fetches over the public API with fetch and decrypts with WebCrypto, and the package build refuses to emit this bundle if a single node: import reaches it.
| Export | Signature |
|---|---|
load | (options: EdgeLoadOptions) => Promise<Record<string, string>>, also the default export |
import { load } from '@goenvless/env/edge';
export default {
// `bindings` is the platform's own object here, not the Envless proxy
async fetch(request, bindings) {
const values = await load({
token: bindings.ENVLESS_TOKEN, // machine key, ev_sk_…
project: 'api',
environment: 'production',
passphrase: bindings.ENVLESS_PASSPHRASE
});
return new Response(values.PUBLIC_GREETING);
}
};| Option | Meaning |
|---|---|
token | Required. An Envless machine key |
project, environment | Required. The slugs to load |
product | Optional. Narrows the lookup when the project sits under a product |
passphrase | Your workspace passphrase. Resolves the workspace over the API to build the salt |
key | A base64url 256-bit workspace key. Takes precedence over passphrase and needs no workspace lookup, but cannot open a value pinned to a non-default KDF cost, since a raw key cannot be re-derived |
workspaceId | Skips the workspace request on the passphrase path |
endpoint | Overrides the API base URL |
fetch | The fetch implementation to use, so a worker never has to touch a global |
It returns a plain record of decrypted strings and nothing else. It does not populate the env proxy, does not write process.env, does not coerce declared types, and does not filter by visibility, so treat everything it returns as a server secret. It caches nothing and memoises nothing either, so unlike load() on the Node side every call fetches and decrypts again. A variable stored empty comes back as an empty string rather than being dropped, and a value that will not decrypt fails naming it, Could not decrypt STRIPE_SECRET.
Each missing input is refused with the message that says what to pass:
Pass a machine key as { token }. An edge runtime has no .envless file and no config to read one from.
Pass { project } and { environment }. An edge runtime has no .envless file to resolve them from.
Pass { passphrase } or { key }. Without one the values cannot be decrypted.
The workspace could not be resolved, so the passphrase cannot be turned into a key. Pass { workspaceId } explicitly.@goenvless/env/crypto: crypto anywhere
The encryption recipe on its own, with no loader and no filesystem behind it, so it runs in a browser, a worker, or Node. Use it to seal a value before writing it through the API, or to open what the API returns.
| Export | Signature |
|---|---|
encrypt | (plaintext: string, passphrase: string, workspaceUniqueId: string) => Promise<string> |
decrypt | (ciphertext: string, passphrase: string, workspaceUniqueId: string) => Promise<string> |
encryptWithKey | (key: CryptoKey, plaintext: string) => Promise<string> |
decryptWithKey | (key: CryptoKey, ciphertext: string) => Promise<string> |
workspaceKey | (passphrase: string, workspaceUniqueId: string) => Promise<CryptoKey> |
importWorkspaceKey | (material: string) => Promise<CryptoKey>, base64url, 256-bit |
passphraseStrength | (passphrase: string) => { valid: boolean, error: string | null } |
ENCRYPTION_PARAMETERS | The cryptographic constants object |
import { workspaceKey, encryptWithKey, decryptWithKey } from '@goenvless/env/crypto';
const key = await workspaceKey(passphrase, workspaceUniqueId); // derive once
const ciphertext = await encryptWithKey(key, 'postgres://…'); // reuse for every value
const plaintext = await decryptWithKey(key, ciphertext);The salt is bound to the workspace, so a passphrase that is right for one workspace opens nothing in another. Deriving the key once and passing it around is the cheap path: every encrypt and decrypt call runs the 200000-iteration KDF again.
encrypt always seals at that cost and writes a v1: envelope, and so does every other Envless writer: the dashboard, the CLI, every SDK and this package all emit v1: at 200000, and no shipped code path produces a v2: envelope. Reading one is supported anyway. v2:<iterations>: pins a value to a different cost, decrypt re-derives at the count it names, and a count outside 100000 to 1000000 is refused as Unsupported variable format.
decryptWithKey has no deriver behind it, so any pinned count other than the default 200000 is refused whatever key you hand it, even one you derived at that exact count. It throws This value pins 300000 PBKDF2 iterations, which the supplied key was not derived with. Decrypt it with the passphrase instead, so the key is derived at the cost the value was written with. Open a pinned value with decrypt and the passphrase.
@goenvless/env/register: preload hook
A side-effect-only module. Importing it eagerly runs the loader with a top-level await, so every variable is populated before your application code evaluates. Nothing is exported.
node --import @goenvless/env/register app.jspreload = ["@goenvless/env/register"]ESM only, by design. require() cannot block on the loader, so the CommonJS build refuses to run and says so: require('@goenvless/env/register') cannot block, so it would hand control back before a single variable is loaded. Preload the ESM entry instead: node --import @goenvless/env/register, or await load() from '@goenvless/env' before anything reads process.env.
This is the simplest option for plain servers, scripts, and CI commands where you don't want an explicit await load() at the top of your entrypoint.
@goenvless/env/next: Next.js plugin
withEnvless(nextConfig?) returns an async Next config function. It awaits load() (filling process.env for server and build code) and inlines client-visibility variables into config.env so they reach client chunks.
| Export | Signature |
|---|---|
withEnvless | (nextConfig?: NextConfig) => () => Promise<NextConfig>, also the default export |
import { withEnvless } from '@goenvless/env/next';
export default withEnvless({
// your existing Next config
});It also writes one extra key into config.env, ENVLESS_CLIENT_ENV, the JSON payload of client values and their metadata that /client hydrates from. And because Next inlines every NEXT_PUBLIC_ name it finds in process.env whatever Envless marks it, server-visibility variables carrying that prefix are deleted from the build process instead, with a warning naming them. Flag them client in Envless if they belong in the browser, or rename them.
If loading fails the plugin throws rather than building with a hole in the config. See the Next.js guide for App Router / Edge specifics.
@goenvless/env/vite: Vite plugin
envless() returns a Vite plugin. During config resolution it awaits load() and defines import.meta.env.<NAME> for client-visibility variables only. Server variables never enter client chunks.
| Export | Signature |
|---|---|
envless | () => EnvlessVitePlugin, also the default export |
import { defineConfig } from 'vite';
import { envless } from '@goenvless/env/vite';
export default defineConfig({
plugins: [envless()],
});It also defines __ENVLESS_CLIENT_ENV__, the payload /client hydrates from, and strips server-visibility variables whose names carry a bundler prefix, VITE_ or whatever envPrefix you configured, out of the build process with a warning.
No VITE_ prefix required on your own names. Visibility is driven by the dashboard flag. See the Vite guide.
@goenvless/env/eslint: ESLint plugin
Ships one rule, no-server-import-in-client, which errors on an import of @goenvless/env/server. A configs.recommended preset wires it up in one line.
| Export | Purpose |
|---|---|
| default | The plugin object, carrying rules, configs and meta |
rules | The one rule, keyed no-server-import-in-client, which errors on /server imports |
configs | Holds recommended, the ready-made flat-config block that enables the rule |
meta | The plugin's own { name, version } |
import envless from '@goenvless/env/eslint';
export default [
envless.configs.recommended,
];The rule catches static imports, export ... from re-exports, and dynamic import() of that exact specifier. It reports Importing @goenvless/env/server from client-side code leaks server-only variables into the client bundle, import from @goenvless/env/client instead.
The preset carries no file scoping, so on its own the rule fires in every file it lints, server files included. Point it at your client code with the clientGlobs option, and any file that does not match is skipped:
import envless from '@goenvless/env/eslint';
export default [
{
plugins: { '@goenvless/env': envless },
rules: {
'@goenvless/env/no-server-import-in-client': ['error', {
clientGlobs: ['app/**/*.client.tsx', 'src/components/**'],
}],
},
},
];This is the lint layer of the server/client split: even if types are ignored, the build strips server values from client chunks, and this rule catches the mistake before it ships.
How coercion and filtering work
Types come from the dashboard
Each variable's type (string | number | boolean) is set in the dashboard, or with envless var set --type. A variable with nothing declared is a string on the server side. envless types generates the matching declarations for /server and /client. Run it whenever the schema changes. See Typed Variables.
env coerces on read
Values are stored as strings. The env proxy coerces on every read: number runs Number(value), boolean is true for "true" and "1" and false for anything else, everything else stays a string. Nothing is memoised behind the read, so a later load() or an override is visible at once.
/client filters by visibility
Only client-visibility variables are readable on the /client proxy. Anything else throws on access and is hidden from enumeration, and the bundler never inlines it into a client chunk.