Architecture
envless is a thin orchestrator over two external binaries
(age-keygen and sops) and the filesystem. There is no daemon, no
cache, no in-memory state across invocations. Every command reads from
disk, shells out, and exits.
The whole point is to replace .env with something that survives
multiple agents, multiple teammates, and shared logs — without growing
into a SaaS. This page narrates that design, then walks the lifecycle
of a single secret from init to exec.
The .env problem
.env files were designed for one human, one machine, one trust
boundary. AI agents shatter all three.
What .env assumes
- One person uses the secrets. No granular access control. Whoever has the file has everything.
- The machine is trusted. Plaintext on disk is fine because only you read it.
- You distribute out-of-band. Slack DMs, password managers, “ping me when you onboard.”
Those assumptions held when codebases had three developers and zero autonomous processes touching the file. They no longer hold.
What changed
- Multiple agents per developer. Background sessions, scheduled jobs, delegated panes. Each may need different scopes.
- Logs in shared contexts. Agent transcripts, asciinemas,
screen-shares. A single
cat .envbecomes a public broadcast. - CI/CD shares the same file. GitHub Actions secrets diverge from
local
.env, drift accumulates, prod breaks. - Rotation is impossible at scale. Rotating one API key means updating N humans, M agents, K CI environments. Most teams skip.
What envless does differently
.env (plaintext, gitignored) → secrets/*.env.enc (encrypted, committed)shared password / file → per-identity age keypairsout-of-band onboarding → PR adds pubkey to recipients"hope it doesn't leak" → recipient list is the access-control planerotation requires N updates → rotation re-encrypts to current recipientsThe substrate is two well-audited primitives —
age (file encryption) and
sops (per-value encryption with recipient
lists). envless is the agent-facing ergonomics layer on top.
Why not just use sops directly?
You can. We did for a while. Three issues kept biting:
- No
process.envbridge.sops exec-envexists but only handles dotenv format awkwardly.envless execis the same idea, polished. - No identity bootstrap. sops assumes you already have an age
key. Most devs do not.
envless initsolves it in one command. - No migration story. Going from
.envtosecrets/dev.env.encis a half-dozen manual steps.envless migrate .envdoes it idempotently.
envless is sops with the rough edges removed and an opinion about
how agents fit in.
Data flow
flowchart LR user[User / Agent] cli[envless CLI] ident[".envless/identity.key<br/>(age secret key)"] recip[".envless/recipients<br/>(age public keys)"] enc["secrets/<env>.env.enc<br/>(sops-encrypted dotenv)"] sops[sops binary] age[age-keygen binary] child[Child process<br/>process.env populated] user -->|exec / set / get| cli cli -->|init| age age --> ident cli -->|read recipients| recip cli -->|encrypt KV| sops sops --> enc cli -->|decrypt| sops sops --> cli cli -->|fork + execve| child ident -. SOPS_AGE_KEY_FILE .-> sops
Components
| Component | Role | Code |
|---|---|---|
zig/src/main.zig | argv entry, version flag | 21 LOC |
zig/src/cli/*.zig | hand-rolled subcommand dispatcher (no cobra) | ~580 LOC |
zig/src/store.zig | filesystem layout, KV operations | 400 LOC |
zig/src/sops.zig | shells out to sops, dotenv roundtrip | 334 LOC |
zig/src/execenv.zig | builds env array, spawns child | 266 LOC |
zig/src/envparse.zig | parses .env (quotes + comments) | 157 LOC |
Single static binary at zig-out/bin/envless (~150 KB stripped on
aarch64-linux, ReleaseSmall). No runtime, no GC; one dependency
(libc). Cross-compiled by zig build release for the four release
targets.
On-disk layout
your-repo/├── .envless/│ ├── identity.key # age secret key — gitignored│ └── recipients # age public keys — committed├── secrets/│ ├── dev.env.enc # sops-encrypted, committed│ └── prod.env.enc└── .gitignore # auto-appended on migrate.envless/identity.key is never written into a commit. envless init
permissions it 0600 and the repo .gitignore ships with the path
already excluded. See CLI reference → File formats
for the byte-level format of each file.
Why two binaries (age + sops)?
age provides the cryptographic primitive: file-level encryption
against a list of recipients. sops adds per-key encryption with
metadata (data keys, MACs, recipient lists) and a clean roundtrip for
dotenv format. envless does not re-implement either. The trust
surface is whatever trust you already place in age and sops — both
well-audited and narrowly scoped. See
Security → Cryptography
for the formal primitives.
Lifecycle of a secret
The zig/src/e2e.zig file is the executable specification for what
envless does. The stages below narrate that spec.
Stage 1 — envless init
envless init# → INIT identity=.envless/identity.key pubkey=age1...- Creates
.envless/with mode0700. - Shells out to
age-keygen -o .envless/identity.key. - Chmods the identity to
0600. - Scans the key file for the
# public key:marker. - Writes
.envless/recipientscontaining that one pubkey.
Idempotent. Re-running init with an existing identity is a no-op.
Stage 2 — envless set KEY (stdin → encrypted file)
echo "sk-test-xyz" | envless set OPENAI_API_KEY# → SET env=dev key=OPENAI_API_KEY- Reads value from stdin. Trailing
\nis stripped. - Calls
store.Read("dev")— decrypts the existingsecrets/dev.env.encif present, else returns an empty map. - Merges the new KV into the map.
- Calls
store.Write("dev", merged):- Renders dotenv format (sorted keys,
KEY=VALUE\n, no quoting). - Writes to a temp file in
secrets/. - Shells out to
sops encrypt --input-type dotenv --output-type dotenv --age <recipients> <tmpfile>. - Writes sops stdout to
secrets/dev.env.enc. - Removes the tempfile.
- Renders dotenv format (sorted keys,
Stage 3 — envless list (keys only)
envless list# → OPENAI_API_KEYDecrypts, sorts keys, prints to stdout. Values never touch stdout.
Same code path as get, with the value column stripped.
Stage 4 — envless exec -- CMD
envless exec -- node server.jsWhat happens (per zig/src/execenv.zig):
- Decrypts the env file via
Store.read(env). - Merges the secrets map into the current
std.process.EnvMap. Secrets override matching parent vars. - Sorts the merged
KEY=VALUElist for determinism. - Spawns the child with
std.process.Child.spawnAndWait, settingchild.env_mapto the merged map. - Stdin/stdout/stderr are inherited.
- On non-zero exit, returns
RunResult{ .non_zero = code }which the CLI propagates viastd.process.exit(code). See Exit codes.
The child process is unaware that the credentials were ever encrypted.
It just reads process.env.OPENAI_API_KEY like any other variable.
Stage 5 — envless migrate FILE
envless migrate .env# → MIGRATE src=.env env=dev keys=3# → REMOVE .env- Reads
.envand runszig/src/envparse.zigover it (handles quoted values and trailing# comments). - Merges parsed entries into the env (last-write-wins on the env key).
- Writes the encrypted file via the same path as
set. - Appends the source filename to
.gitignoreif not already present. - Removes the plaintext source unless
--keepis set.
End to end
These stages are exactly what TestE2E_InitSetExecRoundtrip (in
zig/src/e2e.zig) verifies on every CI run: init → set → exec → child
sees the secret in process.env. If anything in this lifecycle drifts,
that test goes red. The test lives in the repo and is
treated as the oracle for behavior.
What envless does not try to do
- Replace a real KMS for cloud-native deploys. If you have AWS KMS, use it.
- Be a password manager. 1Password, Bitwarden, etc. own that surface.
- Run a server. There is no server. There will never be a server.
- Have a free tier and a paid tier. There is no tier. It’s a binary.
See Why envless → When NOT to use for the explicit non-goals.