/

envs GitHub

Switch between .env variants (local, staging, production) and sync them across machines via age encryption.

bashenvencryptionagesecuritycli
curl -fsSL https://raw.githubusercontent.com/dmythro/envs/main/envs -o ~/.local/bin/envs chmod +x ~/.local/bin/envs envs setup

I keep the same project checked out on a laptop and a desktop, and every time I switched machines I lost ten minutes to .env. Which variant was this one on? Did the staging database URL ever make it across, or is it still sitting on the other machine? The one file every tool needs is the one file you can’t commit — so it ends up existing in exactly one place, and never the one you’re sitting at.

envs is the bash script that ended that. It encrypts .env variants with your existing SSH key and drops them in a folder that already syncs.

Examples

envs push staging    # encrypt the current .env, store it as "staging"
envs use production  # decrypt "production" over .env
envs diff staging    # compare local .env against the stored variant
envs list            # every variant for this repo
envs projects        # every repo that has variants

That’s the whole tool. No daemon, no shell hook, no wrapper around your dev command — .env stays a plain file that everything reads the normal way.

Why it exists

The alternatives all wanted something I wasn’t willing to give. Committing encrypted secrets to the repo means the ciphertext is in the history forever, and rotating anything means a commit. A secrets service means a subscription, a login, and a network round trip before my dev server starts. Both are the right answer for a team; both are absurd for one person moving between two machines they own.

What I actually needed was a filing cabinet: named variants, per project, encrypted, in a folder my machines already sync. That’s a hundred lines of bash, not a platform.

How it’s different

ToolModel
envsnamed variants in synced storage, encrypted to your SSH key, outside the repo
dotenvxencrypt .env in place and commit the ciphertext with the code
SOPSencrypt structured files with KMS/age/PGP, usually committed alongside the code
direnvauto-load and unload variables per directory — activation, not storage
1Password CLIinject secrets from a hosted vault at runtime

Use something else if you’re on a team. This is the honest limitation: one SSH key means no revocation, no rotation workflow, and no audit trail. If someone leaves, there is nothing to turn off. For a team, SOPS with per-member age recipients or a real vault is the correct tool and I’d use it too. envs is scoped to secrets that are already only yours.

direnv isn’t a competitor at all — it answers “when is this loaded”, not “where does it live”. They compose fine.

What’s inside

Projects identify themselves by git remote

Variants are stored under a directory named after the normalised git remote URL, so the same repo cloned to a different path on a different machine resolves to the same folder without configuration. Change the remote and envs mv <old-id> re-points it.

Overwrite protection

envs use will clobber your local .env, so it first hashes the file and compares it against what was last pushed. Local edits you never pushed produce a warning instead of silent data loss:

envs use staging
# .env has unpushed changes — push them first, or re-run with --force

Two knobs

ENVS_STORE_DIR   # default: ~/Library/Mobile Documents/com~apple~CloudDocs/envs
ENVS_SSH_KEY     # default: ~/.ssh/id_ed25519

Point ENVS_STORE_DIR at Syncthing, Dropbox, a mounted share, or a git repo you keep private — the script does not care what provides the sync.

Design notes

age instead of GPG. GPG can do this, and I tried. Its pinentry and agent model is unreliable in non-interactive contexts — passphrase prompts appear where you didn’t expect them, and agent caching makes failures inconsistent between runs. age has no daemon, reads the key file directly, and fails with an error you can read. For a script that runs inside other scripts, boring beats powerful.

Your SSH key, not a new one. age accepts SSH keys as recipients, so there is no keygen step, no new secret to back up, and no “where did I put the envs key” problem on the third machine. Setup is one command because there is genuinely nothing to set up.

Not the macOS Keychain. The Keychain is the obvious place for this on a Mac, and it lost on practicalities rather than possibility: items live behind a security API instead of being files, syncing is tied to iCloud Keychain specifically, and none of it exists on Linux. A synced folder of .age blobs is a plain filesystem — you can ls it, point it at any sync provider, and it behaves the same on both systems.

Encrypted even though the sync service encrypts. iCloud with Advanced Data Protection is end-to-end encrypted already, so this layer is redundant on paper. I want it anyway: the provider never holds readable secrets, and anyone who reaches that folder — a support session, a shared Mac, a future me being careless with permissions — sees opaque .age blobs. This buys confidentiality, and a corrupted blob fails decryption instead of decrypting wrong — but age does not authenticate the writer: whoever controls the folder can still delete a file or replace it with a validly encrypted one, so the provider stays trusted for keeping the right files in place. Defence in depth is cheap when it costs one age invocation.

Status

Live and doing its job. It is deliberately finished software — a single bash script, portable across macOS and Linux, idempotent, with no roadmap I feel strongly about. The most likely future change is more storage backends, and only if the current one starts annoying me.

Details

By
Dmytro Klymenko
Built with
age — encryption
Started
Updated

Other projects