How to commit a workspace's secrets to its own repo — encrypted — so a clone carries them and
only one private key has to reach each machine. This is the dotenvx provider of the
env: block;
the exact boot behaviour is in environment variables.
Why
A workspace's .env is gitignored, and rightly so — it holds plaintext. But that means the
workspace's secrets don't travel with it: every clone, every restore from a remote, every
second machine starts with an empty environment and a round of copy-pasting tokens.
dotenvx encrypts the values in place. The file keeps its shape — one
KEY=value per line — but each value becomes an encrypted:… ciphertext, with a
DOTENV_PUBLIC_KEY header naming the keypair. That file is safe to commit: cloning the
workspace clones its secrets, and the only thing you distribute separately is the private key,
once per machine.
OpenStation decrypts it at boot, into the workspace's own environment — never into
process.env, and never with dotenvx's interpolation: $VAR in a decrypted value stays
literal and $(cmd) never runs.
Migrating a workspace
From a workspace with a plaintext .env:
cd ~/openstation/notes
grep -qxF '.env.keys' .gitignore || echo '.env.keys' >> .gitignore
bunx @dotenvx/dotenvx encrypt # encrypts .env in place, writes .env.keys
git add -f .env # force past the ignore once; tracked from then on
git add .gitignore
git commit -m "chore: switch to dotenvx — encrypted .env travels with the repo"
encrypt generates a keypair on first run: the public key goes into the .env header, the
private key into a sibling .env.keys. That file must never be committed — hence the grep
line, which puts the ignore entry in place before the key file exists. New scaffolds carry
the entry from creation, and serving a workspace backfills it into an older .gitignore —
but a dev-only workspace scaffolded before the entry existed has had neither, and the recipe
must not depend on which one yours is. The scaffolded settings gate denies reading .env.keys
too. The -f is needed exactly once, because .env stays in .gitignore: git keeps tracking
a file that was force-added, and the ignore entry keeps protecting every workspace that
hasn't migrated.
A workspace scaffolded before this provider existed should also add "Read(.env.keys)" and
"Read(**/.env.keys)" to its settings file's deny list — new scaffolds carry them already.
Then declare the provider in .openstation/openstation.yaml:
env:
provider: dotenvx
Boot with openstation notes dev and read the banner:
2026-08-13T09:12:04.101Z INFO [startup] env: dotenvx /Users/you/openstation/notes/.env (3 applied)
Provider dotenvx, the applied names, and — the part to actually check — no plaintext
warnings. A WARN … env: TELEGRAM_BOT_TOKEN is not encrypted — run 'dotenvx encrypt' before committing means a value was added after the encrypt and is sitting in the file in plaintext.
Distributing the private key
Two places the provider looks, in order:
- The environment:
DOTENV_PRIVATE_KEYin the environment the workspace resolves over — a systemdEnvironmentFile, container env, or your shell. For a.env.<name>file the variable isDOTENV_PRIVATE_KEY_<NAME>(dotenvx's own convention:.env.productionreadsDOTENV_PRIVATE_KEY_PRODUCTION). .env.keysbeside the env file, holding the same variable.
Which channel carries it there is up to you — a secret manager the machine already trusts, an
encrypted dotfiles setup, a one-time paste into the unit's environment file. What matters is
that the key travels outside the repo: the repo carries the ciphertext, the key is the one
secret left, and it unlocks every value at once. A machine that injects DOTENV_PRIVATE_KEY
through the host environment needs no .env.keys at all.
Rotation
Rotating the keypair is the dotenvx CLI's job, not the platform's:
bunx @dotenvx/dotenvx rotate # new keypair, re-encrypts every value
Commit the re-encrypted .env, distribute the new private key, restart. Rotating a single
value is just setting it again — decrypt nothing, run
bunx @dotenvx/dotenvx set KEY value, commit. Either way the platform only ever sees the
committed file and the key: it depends on @dotenvx/primitives for the decryption itself and
shells out to nothing.
The trap: committing plaintext
The failure mode this whole arrangement must survive is the ordinary one — someone edits the
committed .env by hand, adds NEW_TOKEN=xoxb-… in plaintext, and commits. Nothing breaks:
dotenvx tolerates mixed files, and the boot proceeds. What you get instead is a named warning
in the banner, per key, every boot:
WARN [startup] env: NEW_TOKEN is not encrypted — run 'dotenvx encrypt' before committing
The fix is the message: bunx @dotenvx/dotenvx encrypt, commit, and — since the plaintext is
in git history now — rotate the leaked value at its issuer.
Two harder variants are also caught:
- A fully-plaintext file — the provider declared, the encrypt never run — boots with one migration hint rather than a warning per key.
- A private key committed into the
.envitself is stripped from the bag and warned about loudly: remove the line and rotate the keypair. A private key in a committed file is the exact incident the provider exists to prevent.
And two failures that refuse to boot rather than warn, both naming names and never values: a
missing .env (the manifest declares a committed file, so absence means a broken clone),
and encrypted values with no key — the error names DOTENV_PRIVATE_KEY and the
.env.keys path it tried.