Article Information
Category: Development
Published: October 14, 2026
Author: Chris de Gruijter
Reading Time: 8 min
Tags

Rotate Once, Correct Everywhere: Encrypting My Secrets Folder with SOPS and age
Published: October 14, 2026
I work on two machines, a desktop PC and a laptop, and the secrets I look after span several separate workspaces: agency clients, freelance clients and my own projects. That adds up to roughly 120 secret files. Keeping them out of my project repos was never the hard part. Keeping both machines in agreement was.
This post covers how I fixed that with SOPS and age: an encrypted secrets folder in its own private git repo, a three-command workflow, guard rails that refuse anything that is not ciphertext, and a key backup plan I have actually tested. It is hygiene and hardening work, not a fire drill.
The problem: hand copies drift
My secrets have always lived in one store outside every project, with a separate folder per workspace: agency clients, freelance clients and personal projects. Credentials from one workspace must never mix with another, and the folder boundary is how I keep them apart. Each folder holds several kinds of secrets: local .env files for development, deploy secrets for Cloudflare Workers, service-account files, and marketing and API keys for analytics, ads, SEO tooling and email delivery. Projects reach their files through symlinks, so nothing secret ever sits inside a project repo (I described that layout in my secure vibe coding setup). None of what follows changed that.
The weak spot was sync. When I added a new key or rotated an old one, getting the new value to the second machine meant copying files by hand. Hand copies drift. The laptop would quietly keep an old value, or miss a new file entirely, and nobody noticed until a deploy or a script failed weeks later. That is when I started saying this out loud: a rotation is only done when every machine has the new value.
So the goal was simple to state. Rotate once, be correct everywhere, using the same git workflow I use for everything else. And if a machine dies, have a recovery path I have actually walked through, not one I assume works.
The tools: SOPS and age
SOPS (Secrets OPerationS, source on GitHub) is a CNCF project that started life at Mozilla. It encrypts the values in a structured file (dotenv, JSON, YAML) and leaves the keys readable, so a git diff tells you which secret changed, never what it changed to. For files that are not key/value shaped, SOPS also has a binary mode that encrypts the whole thing.
age ("actually good encryption", by Filippo Valsorda, on GitHub) is the key backend I paired it with. It is small and modern, with no configuration to get wrong. Each machine gets its own keypair. The public key is a short string you can paste anywhere; the private key is a single line in a file.
An encrypted dotenv line looks like this (placeholders only):
API_KEY=ENC[AES256_GCM,data:...,iv:...,tag:...,type:str]The name stays readable, the value is opaque, and the tag lets SOPS detect tampering when it decrypts.
The design: ciphertext in git, plaintext where it always was
The secrets folder itself became a private git repo, but only ciphertext is tracked. Every plaintext file gets an encrypted sibling right next to it, named <file>.enc.<mode>, where the mode is env, json or bin. The plaintext stays on disk, ignored by git, and is still exactly what the projects read through their symlinks. My deploy scripts did not change.
secrets/
client-a/
.env # plaintext, ignored by git
.env.enc.env # SOPS dotenv ciphertext, tracked
deploy-secrets.json # plaintext, ignored
deploy-secrets.json.enc.json
personal/
service-account.json
service-account.json.enc.json
shell-env.sh
shell-env.sh.enc.bin # binary mode keeps exact bytesEach file is encrypted to every machine's public key, so each machine decrypts with its own private key. No shared secret travels between them. Adding a third machine means adding its public key to the config, re-keying the files with sops updatekeys, and pushing. The encryption rules in that config are set per workspace folder, so a workspace could later get its own key without redesigning anything.
The daily workflow
I edit the plaintext exactly as before. Then a few small helper scripts take over:
# on the machine where I changed something
secrets-status # which plaintext files drifted from their ciphertext
secrets-reencrypt-changed # re-encrypt only those
git commit && git push
# on the other machine
git pull
secrets-decryptFor a single rotation there is secrets-rotate FILE KEY, which changes one value without echoing it to the terminal. That reflects a rule I hold firmly: decrypted data only ever goes to files, never to a terminal. Terminals get logged, scrolled back and screen-shared.
How I verified the migration
I did not want to trust that 120 files survived the trip. Every file was encrypted, decrypted again into memory and compared with the original. For dotenv and JSON that comparison is a parsed one, because SOPS normalises formatting; for binary files it is a straight byte compare. Once a file passed, the decrypted output replaced the plaintext one time. From then on plaintext and ciphertext are byte-identical, and every later check is a plain hash comparison.
I also did one client first as a pilot, including a deploy dry run, before the rest followed store by store.
Guard rails: defence in depth
Encrypting files is the easy part. The real question is what stops a plaintext file from ending up in a commit on a tired Friday. I stacked several independent layers, so no single mistake gets through:
- A default-deny .gitignore. Everything is ignored except
*.enc.*and three config files. A new plaintext file anywhere in the tree is ignored automatically. - A pre-commit hook that only accepts provable ciphertext. It checks the SOPS metadata, checks that every value is a complete encrypted token (a hand-typed fake
ENC[...]wrapper is rejected), and actually decrypts the file to verify its integrity tag. Anything else is refused. - A pre-push hook that re-checks every commit being pushed. So a commit made with hooks disabled still cannot reach GitHub.
- gitleaks on every commit and push, in every repo and in GitHub Actions, with an allowlist so narrow that only a complete SOPS token on its own line passes.
- A self-test script that I run regularly and that fails when a plaintext file differs from its ciphertext or when ciphertext is left uncommitted.
I then had the whole setup reviewed twice by a separate reviewer: an AI security-review agent running in a fresh context, with no knowledge of my reasoning. Neither review found critical or high issues. The medium findings were fixed the same day. Two examples: my first pre-push hook only checked the newest commit instead of all of them, and my first gitleaks allowlist was broader than it needed to be. Exactly the kind of thing you miss when you review your own work.
Key backups: the part people skip
Here is the uncomfortable truth about this setup: if I lose every age private key, the ciphertext is unrecoverable. The whole system is only as safe as the key backups.
So the private key lives in three places, each with a different way to fail:
- An offline, LUKS-encrypted USB stick.
- My password manager (Bitwarden, as a secure note with a hidden field).
- A printed copy in a safe.
A backup you have never restored is a hope, not a backup. I verified each electronic copy by restoring it and decrypting a test file with it. Recovery from total loss is now short and boring: install age, sops and git on any Linux box, restore the key, clone the repo, decrypt. Minutes, not a weekend.
The honest trade-off: that key is also the single point of compromise. Git history stays decryptable forever, so if a copy of the key were ever exposed, the right response would be rotating every secret, not just swapping the key. That is why the copies are few, offline where possible, and deliberately boring.
Lessons I would pass on
- SOPS normalises files. It drops blank lines in dotenv and reformats JSON. Decide up front what "identical" means for you, then let the decrypted output become the plaintext once.
- dotenv mode is for flat KEY=VALUE only. Multi-line values such as PEM keys belong in JSON or binary mode.
- Shell-sourced env files with
exportlines and functions are best kept in binary mode, which preserves exact bytes. - Hooks are not cloned with a repo.
core.hooksPathis local config, so every helper script refuses to run until the hooks are wired up. - Never test hooks in the real repo. Use a scratch repo. I learned this when a
git reset --hardafter a test commit removed a plaintext file from disk; the pre-migration snapshot brought it back. - Verify your downloads. sops ships SHA256 checksums, and age ships Sigsum transparency-log proofs. Use them.
- Keep a plaintext snapshot before migrating, encrypted or offline, and get rid of it once you are done.
What else I tightened along the way
The migration was part of a broader round of routine hygiene. gitleaks now runs on every commit and push in all my own repos and in CI, with a shared rule set tested against a synthetic suite of realistic-looking fake secrets. Deploy scripts scan the public build output for secrets before uploading. Cloudflare R2 access moved from keys to bindings wherever possible, so there are fewer long-lived credentials. Key rotation is routine rather than an event, and internal documentation sites sit behind an access layer. My AI-agent setup got the same treatment: agents may never paste secret values into docs, chats or commits, and output that could contain one is masked.
Closing thought
None of this is exotic. SOPS and age are small, well-documented tools, and the design is just "ciphertext in git, plaintext where it always was". What made the difference was treating sync and recovery as first-class problems instead of chores. I now rotate a value once, push, pull on the other machine, and know both are correct. If you juggle secrets across several workspaces and more than one machine, it is worth an afternoon.
Frequently Asked Questions
Is it safe to store SOPS-encrypted secrets in a git repository?
Yes, as long as only ciphertext is tracked and your private keys are protected. SOPS encrypts every value with authenticated encryption, so the repo shows which keys exist and when they changed, but not their values. Keep the repo private anyway, and add hooks that reject anything that is not valid SOPS ciphertext.
Why use age instead of PGP with SOPS?
age is smaller, has no configuration to get wrong, and its keys are short strings that are easy to copy, back up and audit. For a personal or small-team setup with one keypair per machine, it removes most of the friction that PGP key management brings.
How do I add a new machine to a SOPS and age setup?
Generate an age keypair on the new machine, add its public key to the recipients in your SOPS config, run sops updatekeys on the encrypted files so they are re-encrypted to the new recipient as well, and push. The new machine can then pull and decrypt with its own private key.
What happens if I lose my age private key?
If every copy of every private key that can decrypt the files is lost, the ciphertext cannot be recovered. That is why key backups matter more than anything else in this setup. Keep several copies with different failure modes, such as an encrypted offline USB stick, a password manager and a printed copy, and test restoring each one.
Should I use SOPS dotenv mode or binary mode for .env files?
Use dotenv mode for flat KEY=VALUE files, because it keeps key names readable in diffs. Use binary mode for files that are sourced by a shell and contain export lines, functions or multi-line values, because binary mode preserves the exact bytes. Multi-line values such as PEM keys can also go in JSON mode.
Do I still need a secret scanner like gitleaks if everything is encrypted?
Yes. Encryption only protects files that actually get encrypted. A scanner on every commit and push, with a narrow allowlist for complete SOPS tokens, catches the mistakes encryption cannot: a plaintext file added by hand, a value pasted into the wrong repo, or a hook that was not wired up on a fresh clone.