Advanced

Typed variables inside envless run

Read typed, coerced env from @goenvless/env/server inside an envless run child, with no machine key, no preload, no load() call and no network request.

envless run and envless build hand a child process its decrypted variables through the environment. @goenvless/env normally does its own fetch and decrypt, which costs it an ENVLESS_TOKEN and a decryption credential. Put the two together and the second half of that work disappears: inside a wrapped child, import { env } from '@goenvless/env/server' returns typed, coerced values with no ENVLESS_TOKEN, no ENVLESS_KEY or ENVLESS_PASSPHRASE, no @goenvless/env/register preload, no load() call and no network request at all.

The CLI parent still authenticates and still decrypts. The child needs no credential of its own. It does inherit the launcher's environment as it stands, so anything you exported to authenticate the CLI itself is still visible in there.

Import in Your App says the in-process loader is always machine-authenticated, and outside a wrapped process that is exactly true. This page is the single exception, and it works because the loader never runs at all: the store is seeded from the environment the CLI already built.

The manifest

When envless run or envless build builds the child environment, it writes one extra variable alongside your values, ENVLESS_INJECTED. It is compact JSON: an object keyed by variable name, whose value is that variable's declared metadata, t for type and v for visibility.

ENVLESS_INJECTED
{"DATABASE_URL":{"t":"string","v":"server"},"DB_POOL_SIZE":{"t":"number","v":"server"},"VITE_API_URL":{"t":"string","v":"client"}}

Three things about that payload matter:

  • It carries no values. The plaintext is already in the child's environment under each name, put there by the same merge. The manifest only says which names Envless owns and what it declared them to be.
  • It lists only what actually landed. A name that was decrypted but refused, or dropped for having no value, is absent from both the environment and the manifest.
  • A key is only written when the entry carries it. Every variable in Envless has a type and a visibility, defaulting to string and server, so in practice both keys are present. An entry that reaches the CLI with neither is written as {}, which the runtime reads as type string, visibility server.

--env takes a comma separated list, and those environments merge left to right before the manifest is built, so every name appears once, carrying the metadata of the environment that won.

What the runtime does with it

@goenvless/env keeps its store on globalThis under Symbol.for('goenvless.envless.store.v1'), shared by every entrypoint in the process. The first time anything touches that store, it looks for ENVLESS_INJECTED. If it parses, the runtime reads each listed name straight out of process.env, records the declared type and visibility, and marks the store both loaded and injected.

Marking it loaded is the whole trick. The "not loaded yet" guard is what makes env.X throw before a load, and the seed satisfies it without a fetch. Reading a value is then a synchronous lookup with the usual coercion applied: a number variable goes through Number(), a boolean is true only for the strings true or 1, and everything else stays a string. An unrecognised type in the manifest falls back to string rather than failing.

Generate types once

bash
envless types

Writes envless-env.d.ts next to where you ran it. It needs a token, from envless login or ENVLESS_TOKEN, but no decryption key, because it reads names, types and visibility, never values. Commit the file. See Typed Variables.

Import and read, with nothing else in the file

app.ts
import { env } from '@goenvless/env/server';

const db = new Database({
    url: env.DATABASE_URL,        // string
    poolSize: env.DB_POOL_SIZE    // number, coerced from the declared type
});

No import '@goenvless/env/register', no await load(). Both are still allowed, they simply have nothing to do here.

Launch it wrapped

bash
envless run --env production -- node app.js

The CLI resolves the target, fetches, decrypts, spawns node with the values plus the manifest, and the store seeds itself the first time env.DATABASE_URL is read.

What the child no longer needs

Normally required by the runtimeInside envless run
ENVLESS_TOKEN (ev_sk_ machine key)Not read. The parent already authenticated
ENVLESS_KEY or ENVLESS_PASSPHRASENot read. The parent already decrypted
@goenvless/env/register, or await load() before the first readNot needed. The store is loaded at first access
A .envless file, or ENVLESS_PROJECT / ENVLESS_ENV in the childNot read. Target resolution happened in the parent
A network round trip on bootNone. Nothing is fetched
The on-disk cipher cache under ~/.envless/cacheUntouched, because nothing is fetched to cache

Everything in the left column is back in play on the standalone runtime path, where your app boots on its own and the loader has to do the work itself. That path needs an ev_sk_ machine key in ENVLESS_TOKEN plus ENVLESS_KEY or ENVLESS_PASSPHRASE, and a session token from envless login is refused there with ENVLESS_TOKEN must be a machine key (ev_sk_…). Create one in the Envless dashboard. Choosing run moves that whole credential set from the application process into the launcher.

load(), /register and the framework plugins

load() inside a seeded process resolves the values already in the store and makes no request, so calling it, or preloading @goenvless/env/register, is a no-op rather than an error:

ts
import { env, load } from '@goenvless/env/server';

await load();                 // resolves the injected record, no fetch, no credentials

That is why the framework wrappers work unchanged under a wrapped build. withEnvless and the envless() Vite plugin both await load() during config evaluation, so under envless build -- vite build the config evaluation finds the values already there and never authenticates. The CLI parent is the only thing in the pipeline holding a token or a key. The client payload they inline is filtered by the visibility the manifest carried, so a variable marked client in Envless reaches the bundle and a server one does not.

load({ revalidate: true }) deliberately escapes the seed. It skips the injected shortcut and runs a real fetch and decrypt, so in a wrapped child with no credentials it throws Missing ENVLESS_TOKEN. Set it to an Envless machine key (ev_sk_…) to load variables. Use plain load(), or nothing at all, unless you genuinely want a fresh pull mid-process and have supplied a machine key for it.

Limits worth knowing

The store holds what the manifest listed, and nothing else. Reading a name that Envless did not inject throws Missing env var: NAME even when that name is sitting in process.env, because an ambient variable the run did not inject is never claimed. Read those through process.env as you always did.

If nothing at all was adopted, the store stays empty and unloaded, and the first read throws the usual Envless env is not loaded yet. Import '@goenvless/env/register' or await load() first. That happens when the environment has no variables, when the manifest is not valid JSON, or when every name it lists is gone from the environment by the time the store is first touched. A malformed manifest is ignored quietly rather than crashing the process.

A variable that reaches the CLI without a value at all is skipped with Variable <name> has no value, skipping it. and never appears in the manifest. That is a guard rather than something you will normally meet, because a variable saved empty in Envless arrives as an empty string, which does appear and reads back as "".

There is no exported way to ask whether you were seeded. The store tracks it internally, but none of the nine @goenvless/env entrypoints expose that flag, so the behavioural test is the only one available: the read works without a load.

__override() from /server still layers on top of the seeded values, so temporary overrides in tests behave exactly as they do under a normal load.

ENVLESS_INJECTED cannot arrive through a stored variable

The manifest name is on the refused list, the twenty-six names Envless never writes into a process environment: 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. Create a variable called ENVLESS_INJECTED in your workspace and it is fetched, decrypted, then dropped before the child environment is built, with a warning naming it. It is also left out of the manifest the CLI writes, so a stored value can neither replace the real manifest nor add itself to it.

The refusal holds on the reading side too. Even a manifest that lists NODE_OPTIONS or PATH is not honoured: the runtime skips those names while seeding, so they never enter the store however they were named.

This is not a signature. ENVLESS_INJECTED is an ordinary environment variable, so anything that can already set the environment of your process can write one, and the runtime will adopt the names it lists. Two things bound the damage: the manifest carries no values, so it can only point at plaintext already present in that same environment, and no name on the refused list is ever adopted. What it cannot do is reach you through a variable someone stored in Envless.

When this is the right shape

Wrapped + typed

The application process needs no credential and no key of its own, and your code still reads env.DATABASE_URL with autocomplete and coercion. The launcher is the only thing that ever authenticates.

Standalone runtime

A process that boots on its own, in a platform that starts it for you. It needs an ev_sk_ machine key and a decryption credential in its own environment, and a preload or an await load() before the first read.

Next steps