NAME
Too Many Machines, One Sealed File
DATE
TAGS
There is a .envrc in nearly every project I touch. For years the secrets part was sops, and sops plus direnv works fine together. One line in the .envrc:
export DATABASE_URL="$(sops -d --extract '["DATABASE_URL"]' secrets.enc.yaml)"
Walk into the directory and the values are there. The encrypted file sits next to the code and travels with it.
The part sops does not solve is the key. Every machine that should read the file needs the private key on disk, and I work with way too many machines. Two laptops, one workstation, a handful of homelab nodes, a couple of VPS boxes for testing. Each one got its own copy of the age key in a plaintext file, in a directory that backups and dotfile syncs sweep through. Losing one machine meant rotating every secret it could read. That is the leak the encryption was supposed to prevent.
The plugin detour
A TPM 2.0 chip fixes the key at rest. The key gets generated inside the chip and cannot leave it, so there is no file to copy, sync, or leak. age-plugin-tpm does this well and I ran it for a while.
Then a release changed the recipient format. Newer versions write age1tag recipients by default, the internal tpm-ecc scheme got replaced by the standardized p256tag, and old identities need to be piped through age-plugin-tpm -y to migrate. Old files still open but warn until they are updated. That is all documented and the migration works. The annoying part was that the plugin version installed on a machine decided whether something written on another machine loaded, warned, or needed migrating. Mine are never in sync, so it kept breaking in small ways.
There is also still the shape of the thing. A key, a plugin version, and sops and direnv config pointing at both, per machine. The encryption stopped being the problem and the key handling did not.
What I wanted was one file per repo, committed anywhere, public repos included, with git doing the syncing and a key that never exists as a file anywhere. fuu is where that landed.
One file per repo
The vault is a single .fuu.toml per repository. Names and values are sealed, and each vault’s key derives from my one account passphrase under that vault’s own salt. The account itself is sealed to the TPM of every machine I use, so a copied vault is useless anywhere else and no key material ever sits on disk. That makes the syncing boring in the way I wanted. Commit the file, push, pull on the next machine.
Trust is explicit. A vault only loads from a folder this machine has trusted, and the first load anywhere else is one fuu trust and a yes. There is no trust on first use, so a stranger’s repository cannot hand me values, and one of my own vaults copied into another folder does not load there quietly.
On a fresh machine it looks like this:
fuu init # first machine, creates the account and prints its passphrase once
fuu set DATABASE_URL postgres://localhost/mydb
fuu set API_KEY s3cret
fuu login # every further machine, asks for that passphrase
Loading is direnv style, from a hook in the shell rc file:
eval "$(fuu hook bash)" # zsh and fish have their own variant
fuu: loading ~/project/.fuu.toml
fuu: export +DATABASE_URL +API_KEY
Walk out of the directory and the values unload again. fuu run npm start covers the cases where I skip the hook entirely. fuu edit opens the vault as plain TOML in $EDITOR and writes back only what changed, so a git diff shows exactly which line moved.
When something leaks
If it was only the values of one vault, fuu rotate gives that vault a fresh key under the same passphrase. Commit the new .fuu.toml and push, the other machines pull, and that is the whole story.
If the passphrase itself leaked, or a machine with the account on it is gone, it is fuu rotate --passphrase instead. That prints the new passphrase and reseals every vault trusted on the machine I run it on. Then I commit and push those files, and the order matters on the other machines: pull first, then fuu login with the new passphrase. Login brings the account to that machine and moves along any vault that was only checked out there, so a clone that sat there unopened starts working the moment the account arrives.
Medium security, on purpose
Staging credentials, internal API tokens, dev database URLs. That is what lives in my vaults. The high value stuff goes straight into pipeline variables and deployment environments and never touches a repo or my shell. Even the medium tier is worth not leaking into a dotfiles backup, and that is the bar this clears.
The tradeoffs are real. The account passphrase is printed once at the first fuu init and it is the only way to join a machine or get back from a cleared TPM. Nobody can recover it for you, so it still lives in my password manager. There is no software fallback, a machine without a TPM 2.0 cannot open a vault at all. And this is a single person tool. Sharing a vault means handing over that passphrase, and rotation is never retroactive: fuu rotate --passphrase cuts a lost machine off from future values, not from what it already read, so the credentials at their issuers need rotating too.
It replaced the sops and direnv combo for everything below the crown jewels.