What is Envless
Envless is an end-to-end encrypted secrets manager that replaces .env files. It encrypts every variable on your device before upload, so the server only ever stores ciphertext, then versions, audits and syncs them across local, staging, production and CI through a CLI, typed SDKs for TypeScript, Python and Ruby, and a REST API.
AES-256-GCM
Authenticated encryption, in your browser and in the CLI. Every value is encrypted before it leaves your machine.
200,000 iterations
PBKDF2-SHA256 behind every workspace key, derived from a passphrase Envless never receives. See the workspace key.
0 bytes of plaintext
Stored on Envless servers, by design. Decryption only ever happens on your device, in memory.
The path a variable takes
Your machine
You add DATABASE_URL or STRIPE_SECRET_KEY in the dashboard or the CLI. The value is encrypted right there with the workspace key, and the key never leaves.
Envless
The API receives ciphertext only. That is what gets versioned, what roles gate access to, and what the audit trail records.
Your app, CI or agent
envless run -- bun start, envless sync, or import { env } from '@goenvless/env/server' fetches the ciphertext and decrypts it in memory, on the machine that needs it.
Put differently, Envless is a sync layer for environment variables. You manage variables in the dashboard; your machines pull them down, decrypted locally. There are three ways to do that pull, and which one you pick depends on where the code runs.
Three ways to sync
Import in your app
The runtime, @goenvless/env. Preload @goenvless/env/register or await load(), then import { env } from '@goenvless/env/server' for typed variables, decrypted in memory, with no plaintext on disk.
Sync to .env files
The CLI, @goenvless/cli. envless sync writes .env.<slug> for tools that expect a file (Docker, python-dotenv, Rails, shell).
Wrap a process
The CLI again. envless run -- <cmd> injects variables into a single child process, and nothing is persisted. Names that would change how the child executes code, like NODE_OPTIONS or PATH, are refused.
When to pick which
| Mode | Where it lives | Use when |
|---|---|---|
| Import | In your app code | You control the runtime and want types, plus a proxy that throws on a missing or server-only name. Node and Bun, or Python with envless-env; on an edge runtime use @goenvless/env/edge. |
| Sync | Files on disk (.env.<slug>) | Something else needs a file: Docker Compose, python-dotenv, Rails, source .env in a shell. |
| Run | Child process env | You want zero footprint (no files, no imports) for a single command or CI step. |
They're not mutually exclusive. A typical setup uses import in app code, sync for the docker-compose dev stack, and run in CI.
What makes them work
The first two pieces below are shared by all three modes, with one exception: the @goenvless/env/edge loader has no filesystem, so it takes the same binding and key material as arguments instead. The third one shapes what the runtime import exposes, and only the runtime import: sync and run hand over every variable whatever its visibility.
The .envless file
A small JSON file you commit to your repo. It tells Envless what to fetch (workspace, product, project, and one or more environments) and contains no secrets.
{
"workspace": "acme",
"product": "payments",
"project": "my-app",
"environments": ["development", "staging"]
}product is optional and only needed when the same project slug exists in more than one product of the workspace.
Created by envless link. Safe to commit and share.
The workspace key
Envless is end-to-end encrypted. The server stores ciphertext only; decryption happens on your machine with a key derived from a workspace passphrase.
Set the passphrase once per workspace
Either in the dashboard or via envless passphrase set. The key is derived with PBKDF2 (200,000 iterations, SHA-256) → AES-GCM-256. It is the only key to every secret in the workspace and it is never sent to Envless, so it cannot be reset or recovered: minimum 16 characters, and generate it with a password manager. See Choosing a passphrase.
Derived key is cached locally
Saved at ~/.envless/config.json (mode 0600). Used by the CLI commands that decrypt, and by nothing else.
Server never sees plaintext
Sync, runtime fetch, and dashboard view all decrypt locally. Rotating the passphrase invalidates every cached key.
The cached key is a CLI convenience, and only the CLI reads it. Whenever @goenvless/env has to load for itself, your laptop included, it wants an ENVLESS_TOKEN holding an ev_sk_ machine key, plus the decryption credential through the environment, either ENVLESS_KEY (the base64url raw workspace key exported from the dashboard's CI / Machine Access page under Settings at /settings/ci-access) or ENVLESS_PASSPHRASE, which the loader derives locally the same way. On machines (CI, containers, production) the CLI has no cached key either, so it takes the same variables. See Keys and Production & CI.
One case skips all of that. Inside a process launched by envless run or envless build, the CLI has already decrypted the values and hands them to the child through an ENVLESS_INJECTED manifest. @goenvless/env seeds itself from that manifest on first access, so import { env } from '@goenvless/env/server' returns typed, coerced values with no ENVLESS_TOKEN, no ENVLESS_KEY or ENVLESS_PASSPHRASE, no /register preload, no load() call and no network request. See Typed variables under run.
Server vs client split
Every variable has a visibility flag, either server (default) or client. The runtime, @goenvless/env, splits its application surface in two:
import { env } from '@goenvless/env/server'; // all variables
import { env } from '@goenvless/env/client'; // client-flagged onlyServer secrets can't end up in a client bundle. That's enforced by types, runtime, bundler, and lint. See Server vs Client.
Those two are the whole application surface. The bare @goenvless/env entry is neither of them: it is untyped and unfiltered, so it hands back server values as well. It is there for load(), encrypt(), decrypt(), passphraseStrength() and ENCRYPTION_PARAMETERS, not for reading variables in application code, and it drags the Node-only loader in with it.
When you want the crypto without the loader, import @goenvless/env/crypto. It is pure Web Crypto with no node: built-ins and no Buffer, so it runs anywhere, and it exports encrypt, decrypt, encryptWithKey, decryptWithKey, workspaceKey, importWorkspaceKey, passphraseStrength and ENCRYPTION_PARAMETERS.
/server fetches nothing on import. Nothing loads until you preload @goenvless/env/register or await load(), and reading a name before that throws. /client needs no load call, because it seeds itself from the payload the Next or Vite plugin inlined at build time.
/server also reaches the loader, which reads .envless and the on-disk cache through node:fs, so it only runs on Node and Bun. The bare . entry and the /next, /vite and /register entrypoints inherit the same constraint. On an edge runtime or a Worker, call load() from @goenvless/env/edge with { token, project, environment } plus either passphrase or key, and optionally product, workspaceId, endpoint and fetch. It resolves a plain record you own: it writes nothing into process.env and keeps no cache.
How a sync actually flows
┌────────────────────────────────────────┐
│ Dashboard (you manage variables here) │
└──────────────────┬─────────────────────┘
│ encrypted blobs
▼
┌────────────────────────────────────────┐
│ Envless API (ciphertext only) │
└──────────────────┬─────────────────────┘
│ fetch (auth: env_cli_ or ev_sk_ token)
▼
┌────────────────────────────────────────┐
│ Your machine │
│ .envless ← binding │
│ ~/.envless/config.json ← CLI key │
│ ENVLESS_KEY/PASSPHRASE ← runtime │
│ │
│ decrypt locally, then: │
│ • import { env } (runtime) │
│ • write .env.<slug> (sync) │
│ • inject into child (run) │
└────────────────────────────────────────┘One pipeline above the bottom box; three ways out of it. The runtime import is the only mode that caches: it keeps the ciphertext under ~/.envless/cache and reuses it for 15 minutes before it refetches. sync, run and @goenvless/env/edge always fetch.
Twenty-six names never reach a process environment. Twenty-four of them change how a process or its children execute code, so storing one would turn write access to a variable into code execution: 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 and PHP_INI_SCAN_DIR. The last two are Envless channels: ENVLESS_CLIENT_ENV, which the Next and Vite plugins use to hand the client payload to the browser, and ENVLESS_INJECTED, the manifest envless run writes so a child process can seed its typed store without credentials. They are still decrypted, still readable off env, and still present in the record load() resolves; the runtime just refuses to write them into process.env, and run refuses to hand them to the child. Both warn when they drop one. A child process env is therefore the ambient environment merged with the decrypted values minus those twenty-six names, not a plain merge of the two.
The runtime narrows it further. A name already present in process.env that the loader does not own is left alone, so a variable exported in your shell or set by your platform wins over the fetched value. Names the loader did set on an earlier load() and that are gone from the new fetch are deleted. Whatever it skips is still on env, so read it from there rather than from process.env.
Consuming without an Envless package: Virtual Envs
Sometimes the consumer can't install any of the three packages: a tool in another language, a build step you don't control, a runtime with no package manager. A serverless or edge function is usually not one of those, since @goenvless/env/edge and @goenvless/env/crypto run there. For the consumers that really are stuck, a member who can update the workspace (owners and admins by default) can turn on Virtual Envs (Settings → /settings/virtual-envs) and then expose specific environments one at a time.
An exposed environment serves its encrypted variables as self-describing JSON at a branded, guessable URL:
GET <api-base>/exposed/<workspace-slug>/<product-slug>/<project-slug>/<environment-slug>/versions/latest
GET .../versions/<versionId> # pin a version by its id or its label (v3)
GET .../versions # list published versions (metadata only)The response (format: "envless.encrypted-export/v1") carries the ciphertext values plus the KDF and cipher params (PBKDF2 200,000 / SHA-256 / AES-GCM-256, 12 byte IV) and the KDF salt, which is base64 of the first 16 bytes of SHA-256 over envless:workspace:<workspace-unique-id>. Each value is either v1:base64(iv|ciphertext) or v2:<iterations>:base64(iv|ciphertext), and a v2: value pins its own iteration count, which replaces the 200,000 in the bundle for that value alone. The bundle never carries the key or the passphrase, and in it workspace, product, project and environment appear as slugs only, with no unique ids and no display names; the version carries its id, label, note, variable count and creation time. In JavaScript, feed each value to @goenvless/env/crypto's decrypt() or @goenvless/sdk's decryptValue(), which both take the ciphertext, the workspace passphrase, and the workspace unique id they derive the salt from, and both re-derive at the pinned cost when they meet a v2: value. In Python, decrypt_value() from envless-sdk does the same. Anywhere else, reimplement the recipe from the parameters in the bundle.
Publishing the shape without the values: example links
A second, much quieter public feed serves an environment's variable names with every value blank, the file you would otherwise hand-write as .env.example. It is opt-in per environment (Environment settings → Example link) and, unlike Virtual Envs, needs no workspace-level switch. Private products can never publish one.
GET <api-base>/example/<workspace-slug>/<product-slug>/<project-slug>/<environment-slug>/versions/current
GET .../versions/latest # the newest published version
GET .../versions/<versionId> # pin a version by its id or its label (v3)
GET .../versions # list published versions (metadata only)The response (format: "envless.env-example/v1") carries no ciphertext and no decryption block, because there is nothing to decrypt: each variable is name, an empty value, plus type, visibility, required and secret. Opening the URL in a browser returns a copyable page instead of JSON, and ?format=env returns a raw .env.example. See Example links.
The URL is guessable, not secret. It is protected only by the opt-in exposure gate plus the encryption itself. The bundle never contains plaintext, the key, or the passphrase, and a private product's environment can't be exposed. Because the feed is public, the strength of the workspace passphrase is what protects an exposed environment. See Choosing a passphrase.
Beyond syncing: API and SDK
Envless also ships a REST API and programmatic SDKs for TypeScript, @goenvless/sdk, for Python and Ruby, both published as envless-sdk, and for PHP, envless/sdk. None of them is for getting variables into your app, that is the runtime's job. They're for advanced workflows like building tools on top of Envless: writing custom dashboards, scripting workspace/project/variable management, integrating with internal platforms, or automating bulk operations across many projects.
~99% of apps never need these. If you're just trying to use your variables (import them, sync them to a file, or wrap a process), stick with the three modes above. Reach for the API or SDK only when you're building on top of Envless rather than using it.
API Reference
Raw HTTP endpoints for everything the dashboard does: projects, environments, variables, members. Values are returned as ciphertext.
TypeScript SDK
The @goenvless/sdk package wraps those endpoints in typed namespaces and exposes programmatic encryptValue() / decryptValue(). It's the same crypto the CLI and dashboard use.
Python SDK
envless-sdk is the same client for Python, sync and async, with encrypt_value() / decrypt_value() on the same crypto.
Ruby SDK
envless-sdk is the same client for Ruby, typed with RBS, with Envless.encrypt_value / Envless.decrypt_value on the same crypto.
PHP SDK
envless/sdk is the same client for PHP, typed for PHPStan and Psalm, with Envless::encryptValue() / Envless::decryptValue() on the same crypto.
Three packages, three jobs. @goenvless/cli is the terminal binary, @goenvless/env is the runtime your app imports, and @goenvless/sdk is the API client, with envless-sdk as its Python twin on PyPI and its Ruby twin on RubyGems, and envless/sdk as its PHP twin on Packagist. The runtime's Python twin is envless-env, also on PyPI, and its PHP twin is envless/env, also on Packagist. See CLI, runtime and SDK.
Glossary
Quick reference for the resource model you'll see in the dashboard and the .envless file:
| Term | What it is |
|---|---|
| Workspace | Top-level container. Holds products, members, and one passphrase-derived encryption key. Usually one per company. |
| Product | A group of related projects (payments, marketing) inside a workspace. Can be marked private, which fences it to members you grant access to. |
| Project | A single app or service (my-api, marketing-site) inside a product. Holds environments. Project slugs are unique per product, not per workspace. |
| Environment | A named bucket of variables, typically development, staging, production. |
| Variable | A key/value pair. Has a name, encrypted value, type, required flag, and visibility. The name matches ^[A-Za-z_][A-Za-z0-9_]*$, is 1 to 100 characters, is stored with exactly the casing you give it, and must be unique in its environment without regard to case, so api_key collides with an existing API_KEY. Only type and visibility reach the runtime proxy, which coerces number and boolean and hides server names from /client. See Typed Variables. |
Going deeper
The encryption model on this page is covered end to end, with the exact key-derivation parameters and the test vectors that pin them, on the Envless site.