Reference

Runtime reference

Every entrypoint of the @goenvless/env runtime package: exports, signatures, and the async loading model.

@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.

bash
npm i @goenvless/env@latest

This 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

ImportExportsUse in
@goenvless/envenv, load, encrypt, decrypt, passphraseStrength, ENCRYPTION_PARAMETERSNode scripts, env here is untyped and unfiltered
@goenvless/env/serverenv, optional, load, __overrideNode server code, every variable, typed
@goenvless/env/clientenv, optionalBrowser code, client-visibility only, typed
@goenvless/env/edgeload (also the default export)Edge runtimes, returns a plain record
@goenvless/env/cryptoencrypt, decrypt, encryptWithKey, decryptWithKey, workspaceKey, importWorkspaceKey, passphraseStrength, ENCRYPTION_PARAMETERSCrypto 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/nextwithEnvlessnext.config
@goenvless/env/viteenvlessvite.config
@goenvless/env/eslintthe plugin (default export), rules, configs, metaESLint 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);

Reading 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:

ts
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.stringify
txt
Refusing 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:

ts
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

The 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.

ExportSignature
envUntyped, 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_PARAMETERSThe cryptographic constants object
ts
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:

ts
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.

ExportSignature
envTyped proxy over all variables, coerced by type
optionalThe 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
ts
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" → true

env 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:

ts
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 unset

optional 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:

ts
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.

ExportSignature
envTyped proxy over client-visibility variables only
optionalThe same filtered proxy typed Partial<EnvlessClientEnv>, undefined instead of a throw for a name that is not set
ts
import { env } from '@goenvless/env/client';

fetch(env.NEXT_PUBLIC_API_URL);   // ok, flagged client
fetch(env.STRIPE_SECRET);         // throws, server-only variable

optional 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.

ExportSignature
load(options: EdgeLoadOptions) => Promise<Record<string, string>>, also the default export
ts
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);
    }
};
OptionMeaning
tokenRequired. An Envless machine key
project, environmentRequired. The slugs to load
productOptional. Narrows the lookup when the project sits under a product
passphraseYour workspace passphrase. Resolves the workspace over the API to build the salt
keyA 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
workspaceIdSkips the workspace request on the passphrase path
endpointOverrides the API base URL
fetchThe 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:

txt
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.

ExportSignature
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_PARAMETERSThe cryptographic constants object
ts
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.js

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.

ExportSignature
withEnvless(nextConfig?: NextConfig) => () => Promise<NextConfig>, also the default export
next.config.mjs
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.

ExportSignature
envless() => EnvlessVitePlugin, also the default export
vite.config.ts
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.

ExportPurpose
defaultThe plugin object, carrying rules, configs and meta
rulesThe one rule, keyed no-server-import-in-client, which errors on /server imports
configsHolds recommended, the ready-made flat-config block that enables the rule
metaThe plugin's own { name, version }
eslint.config.js
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:

eslint.config.js
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.