openstation

Guides

Secrets

Share encrypted workspace credentials with dotenvx.

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:

  1. The environment: DOTENV_PRIVATE_KEY in the environment the workspace resolves over — a systemd EnvironmentFile, container env, or your shell. For a .env.<name> file the variable is DOTENV_PRIVATE_KEY_<NAME> (dotenvx's own convention: .env.production reads DOTENV_PRIVATE_KEY_PRODUCTION).
  2. .env.keys beside 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 .env itself 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.

View Markdown source on GitHub ↗