The boot-time loader has no edge build. @goenvless/env, @goenvless/env/server, @goenvless/env/register, @goenvless/env/next and @goenvless/env/vite all reach the same loader, which statically imports node:fs, node:path and node:os and reads real files: ./.envless, ~/.envless/config.json, and the cipher cache under ~/.envless/cache/. A Worker has no filesystem and no home directory, so none of those can run inside one. Of the nine entrypoints the package ships, two carry variables into a Worker: @goenvless/env/client, which hydrates from a payload your bundler already inlined, and @goenvless/env/edge, which is fetch and Web Crypto only and does load at runtime. @goenvless/env/crypto runs there too, but it is encrypt and decrypt helpers with no transport, so it carries no variables on its own.
What is importable where
| Entrypoint | Imports node:fs / node:os | Inside a Worker |
|---|---|---|
@goenvless/env | ✅ | ❌ cannot import |
@goenvless/env/server | ✅ | ❌ cannot import |
@goenvless/env/register | ✅ | ❌ cannot import |
@goenvless/env/next | ✅ | build step only (Node) |
@goenvless/env/vite | ✅ | build step only (Node) |
@goenvless/env/client | ❌ none | ✅ reads a payload the bundler already inlined |
@goenvless/env/edge | ❌ none | ✅ fetches and decrypts in the isolate |
@goenvless/env/crypto | ❌ none | ✅ encrypt and decrypt helpers, no fetching |
@goenvless/env/eslint | ❌ none | n/a, an ESLint plugin your config loads |
Those are the nine entrypoints the package exports. @goenvless/env/client, @goenvless/env/edge and @goenvless/env/crypto are the three the build marks browser safe, and it fails if any of them ever picks up a node: import, a Buffer, a process.binding, a __dirname or a __filename. /eslint has no Node built-ins either, but it is a lint rule your ESLint config loads, never something application code imports. /client never fetches and never decrypts: it hydrates synchronously from a JSON payload your bundler injected at build time, and it exposes client-visibility variables only. That is the shape of the build-time story. The values are resolved on a Node machine during the build, and the edge reads them back. /edge is the other half: it fetches ciphertext from the API and decrypts it in the isolate, with no visibility filter and no build step.
envless build
The workflow on this page is a binary, so start by installing the package that ships it. envless lives in @goenvless/cli, which is the terminal command and nothing else: its only export is ./package.json, so there is nothing to import from it, and installing @goenvless/env gives you no envless command. The two packages are independent, and everything from here to the /edge section below needs only @goenvless/cli:
npm i -g @goenvless/cli@latestenvless build [target] -- <command>envless build decrypts your variables and runs <command> with them injected into its environment, exactly like envless run. It resolves the target the same way sync does: --project / --env flags first, then ENVLESS_PROJECT / ENVLESS_ENV, then the .envless file. When it falls back to a .envless that lists more than one environment it warns and takes the first, so pass --env to choose.
[target] is an informational label only (it shows up in output and logs). It is optional, and it does not narrow what gets injected: the variables are the same whatever target you name.
One class of name never reaches the child process. 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 then deliberately left out of the child environment, with a warning naming it, because those names change how a process or its children execute code. The runtime holds the same line from the other side: @goenvless/env decrypts those names, hands them back through env, and refuses to write them into process.env. Neither package gives you a flag to override it.
Cloudflare Workers
Run the wrapper from your shell or your CI job. Do not put it in the [build] hook of wrangler.toml: wrangler runs that command as part of wrangler deploy, so a build command that itself calls wrangler deploy re-enters wrangler.
envless build cloudflare -- wrangler deployThat gives wrangler the decrypted variables in its own environment. Worker bindings, though, come from your wrangler config, from wrangler secret put, and from .dev.vars, so hand each value to one of those explicitly. wrangler secret put reads the value from stdin:
envless build cloudflare -- sh -c '
echo "$STRIPE_SECRET" | wrangler secret put STRIPE_SECRET
echo "$DATABASE_URL" | wrangler secret put DATABASE_URL
wrangler deploy
'For local development, write the plaintext and point wrangler at it. envless sync emits dotenv-formatted .env.<slug> files, which is the format .dev.vars uses:
envless sync --env development
cp .env.development .dev.vars.dev.vars holds plaintext secrets. Add it to .gitignore next to your .env.* entries.
sync warns when git does not ignore a file it just wrote. Pass --require-gitignore to turn that into a refusal, and note that it is all or nothing: the check runs over every target environment up front, before a single variable is fetched and before anything is decrypted, and one unignored .env.<slug> refuses the entire run. Nothing is written for any environment, the message names each unignored file, and the command exits 1. It does not skip the offending environment and carry on with the others. The check only trips on a definite "not ignored" from git, so an inconclusive answer (no git binary, or a directory that is not a repository) lets the run proceed. It covers the .env.<slug> files sync writes, not the .dev.vars copy you make afterwards.
Inside the Worker, read the binding Cloudflare hands you:
export default {
async fetch(req: Request, env: Env) {
return new Response(env.GREETING);
},
};Loading at runtime with /edge
If you need values that change without a redeploy, @goenvless/env/edge is the entrypoint for it. It is fetch and Web Crypto only, so it runs inside a Worker, and its load() fetches ciphertext from the API and decrypts it in the isolate:
import { load } from '@goenvless/env/edge';
let variables: Record<string, string> | null = null;
export default {
async fetch(req: Request, env: Env) {
variables ??= await load({
token: env.ENVLESS_TOKEN,
key: env.ENVLESS_KEY,
project: 'billing-api',
environment: 'production',
});
return new Response(variables.STRIPE_SECRET ? 'ok' : 'missing');
},
};token, project and environment are required, plus either key or passphrase. Miss the key material and it throws Pass { passphrase } or { key }. Without one the values cannot be decrypted. A raw key is imported directly, so it skips both the workspace lookup and the 200,000 iteration PBKDF2 derivation a passphrase costs on every cold start. It cannot re-derive, though: a value written at a pinned non-default PBKDF2 cost can only be opened by the passphrase path, and a key fails it with Could not decrypt NAME. product, workspaceId, endpoint and a custom fetch are the remaining options.
load() returns a plain record of every variable in that environment, server and client visibility alike, with a variable that has no value coming back as an empty string. It does not populate process.env, it does not feed the env proxy exported by @goenvless/env or @goenvless/env/server, and it keeps no cache of its own, so hold the result yourself and reuse it across requests rather than fetching per request.
Being a plain object rather than a proxy also makes it the one shape you can serialise. The env from /server and from the root @goenvless/env refuses JSON.stringify outright, throwing 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. The record above carries no such guard, which is exactly why it is yours to hold, and why Response.json(values) would hand every secret to the caller. Return the field you meant to return.
Fetching at runtime with the SDK
@goenvless/env/edge reads variables and nothing else. When a Worker needs the rest of the API surface, @goenvless/sdk is a separate package, installed with bun add @goenvless/sdk, that is also fetch and Web Crypto only, so it runs inside a Worker too:
import { createClient, importWorkspaceKey, decryptWithKey } from '@goenvless/sdk';
export default {
async fetch(req: Request, env: Env) {
const client = createClient({ token: env.ENVLESS_TOKEN });
const key = await importWorkspaceKey(env.ENVLESS_KEY);
const variables = await client.variables.listAll('billing-api', 'production');
const stripe = variables.find(v => v.name === 'STRIPE_SECRET');
const secret = stripe?.value ? await decryptWithKey(key, stripe.value) : null;
return new Response(secret ? 'ok' : 'missing');
},
};This is a different package from @goenvless/env with a different API. It costs an API round trip, and the decryption key lives in the Worker as a secret, so cache the result across requests rather than fetching per request.
Vercel Edge Functions
Same setup as the Next.js guide. withEnvless writes every client-visibility variable into config.env, along with an ENVLESS_CLIENT_ENV payload that @goenvless/env/client hydrates from. Next inlines config.env as string literals into every runtime, including Edge, so /client works there with no filesystem and no fetch:
import { env } from '@goenvless/env/client';
export const runtime = 'edge';
export async function GET() {
return Response.json({ msg: env.NEXT_PUBLIC_GREETING });
}withEnvless inlines client-visibility variables only, so a server-visibility variable is not in the payload at all and reading it through /client throws Missing env var: NAME. Flag anything an edge route needs as client in the dashboard so it gets inlined at build time, or load it in the route with @goenvless/env/edge. @goenvless/env/server is not an option there: it pulls in node:fs through the loader.
Netlify Edge Functions
envless build netlify -- netlify buildThe build wrapper behaves identically to any other target: netlify build runs with the decrypted variables in its environment, and whatever your framework inlines at build time reaches the bundle. Netlify Edge Functions run on Deno and read Netlify's own environment through Netlify.env.get(), so values a function reads at runtime have to be in Netlify's env store, set from the dashboard or with netlify env:set.
Node / Bun vs Edge
| Node / Bun | Edge / Workers | |
|---|---|---|
@goenvless/env/register | ✅ loads on preload, the only entrypoint that loads itself | ❌ imports node:fs and node:os |
@goenvless/env and /server | ✅ live once an awaited load() has run | ❌ imports node:fs and node:os |
@goenvless/env/client | ✅ | ✅ build-time payload, client-visibility only |
@goenvless/env/edge | ✅ | ✅ fetch and Web Crypto only |
@goenvless/sdk | ✅ | ✅ fetch and Web Crypto only |
| Rotate without redeploy | ✅ restart the process, though the runtime serves its own 15 minute file cache first, so clear it with envless cache clear or call load({ revalidate: true }) | ✅ with /edge or @goenvless/sdk, ❌ with build-time inlining |
| Decryption key present | ✅ on the runtime, from ENVLESS_KEY or ENVLESS_PASSPHRASE | ✅ during the build, or as a Worker secret for /edge and the SDK |
That 15 minute cache under ~/.envless/cache is the runtime's, not the binary's. @goenvless/env writes a file there after every successful fetch and reads it back in place of the next one. envless cache clear, from @goenvless/cli, only deletes those files, it never fills them.
For most apps, build-time injection is fine. It matches how Vercel and Cloudflare handle their own env vars. If you want the boot-time loader instead, run the code on a Node/Bun runtime and preload /register, the one runtime entrypoint that loads on its own. Read the values back through @goenvless/env/server, the env that envless types, another @goenvless/cli command, writes declarations for. The root . export is not typed: it is an index signature of string | number | boolean, and importing it loads nothing until you await its load(). Neither one filters by visibility, only /client does. /register also copies the values into process.env, with two carve-outs: the names listed above are decrypted and then refused, and a name the process environment already carries is left as it is rather than overwritten.