I work on two main devices: a desktop PC and a laptop. To avoid burning out by working from home all the time, I sometimes work from a coffee shop — fresh scenery, good intentions, until the project needs a new feature that requires a key or sensitive data… stuck. Keys are random strings no normal human can memorize, and all the related variables pile up in .env. The classic story: I forget to update .env on the laptop, and only find out when the app errors out mid-session.
The fix: sync .env across devices securely. Not by sending it over WhatsApp or Discord (never do that!), but by encrypting it first, committing it to git, and decrypting it on the target device. The tools: SOPS and age.
This article focuses on Windows (winget + PowerShell), but I’ve included macOS/Linux commands too, since the workflow is cross-platform.
What are SOPS and age? #
SOPS (Secrets OPerationS, by Mozilla) is a tool for encrypting secret files — but instead of turning the whole file into one blob, it encrypts only the values, and it understands file formats (JSON, YAML, INI, dotenv). The file structure stays readable; only the contents become ciphertext.
age is a modern, simple encryption tool designed as a day-to-day PGP replacement. It uses a key pair: a public key (for encrypting) and a private key (for decrypting). In SOPS, age acts as the key holder.
How they work together:
- SOPS asks for the age public key (from
.sops.yaml) to encrypt.env→ the result is.env.enc. .env.encgets committed to git;.envgoes into.gitignore.- On another device, just
git pull, and SOPS uses the local age private key to decrypt.env.enc→ a ready-to-use.env.
The private key never touches git. Safe, and the workflow is seamless.
Full Tutorial #
1. Install SOPS and age #
Windows (winget):
winget install FiloSottile.age
winget install Mozilla.sopsmacOS (brew):
brew install age sopsLinux (Debian/Ubuntu):
sudo apt install age
# sops: download the binary from GitHub releases2. Generate an age key #
On Windows, create the key storage folder first, then generate:
mkdir "$env:APPDATA\sops\age" -Force
age-keygen -o "$env:APPDATA\sops\age\keys.txt"age1xxxxxxxxxxxxx...). That’s what goes into .sops.yaml. The key inside keys.txt is the private key — never commit or share it.
%APPDATA%\sops\age\keys.txt on Windows, but at ~/.config/sops/age/keys.txt on macOS/Linux. If you sync the same project between Windows and Mac/Linux, the key path differs per OS — that’s normal and fine. The key just needs to exist locally on each device.
3. Copy the private key to another device #
If the second device runs a different OS, copy the key file’s contents to the matching path there:
- From Mac/Linux:
~/.config/sops/age/keys.txt→ to Windows:%APPDATA%\sops\age\keys.txt - From Windows:
%APPDATA%\sops\age\keys.txt→ to Mac/Linux:~/.config/sops/age/keys.txt
How to move it safely? Through a password manager (Bitwarden, KeePass, etc.) or offline media. Never over chat.
4. Create .sops.yaml at the project root
#
This file is identical on every OS:
creation_rules:
- path_regex: \.env(\.enc)?$
age: age1xxxxxxxxxxxxx...path_regex tells SOPS which files to encrypt with that age key — in this case .env and .env.enc.
5. Encrypt .env
#
The basic command is sops -e .env > .env.enc. BUT — here’s a lesson I learned the hard way — on Windows SOPS tends to misdetect the .env file format, so you must use explicit format flags:
sops -e --input-type dotenv --output-type dotenv .env > .env.encCheck the result: .env.enc now contains ciphertext plus SOPS metadata (including your age key fingerprint).
6. Decrypt .env.enc
#
sops -d --input-type dotenv --output-type dotenv .env.enc > .env7. Helper scripts so you never mistype #
Since the explicit format flags are long and easy to forget, I keep two PowerShell scripts at the project root. encrypt.ps1:
sops -e --input-type dotenv --output-type dotenv .env > .env.enc
if ($LASTEXITCODE -eq 0) {
Write-Host "✅ .env.enc updated"
} else {
Write-Host "❌ Encrypt failed, check the error above" -ForegroundColor Red
}decrypt.ps1 (unlike my first version, which was misspelled decypt 😅):
sops -d --input-type dotenv --output-type dotenv .env.enc > .env
if ($LASTEXITCODE -eq 0) {
Write-Host "✅ .env decrypted"
} else {
Write-Host "❌ Decrypt failed, check the error above" -ForegroundColor Red
}8. Set up git properly #
# .gitignore
.envOnly .env.enc and .sops.yaml go into git. Never commit .env or keys.txt.
Anti-CRLF: to keep the \x0d issue (see Troubleshooting) from ever happening, add a .gitattributes at the project root:
*.env.enc text eol=lfThis forces git to store .env.enc with LF line endings, regardless of OS.
Tips & Tricks #
-
Back up the private key in a password manager. If you lose the key,
.env.encis unreadable forever. Store the contents ofkeys.txtin Bitwarden/KeePass — not in git, not in chat. -
Works for many devices at once. Just add all the age public keys to
.sops.yaml:creation_rules: - path_regex: \.env(\.enc)?$ age: - age1publicKeyDevice1... - age1publicKeyDevice2... -
Not just
.env. SOPS also understands JSON, YAML, and INI. Want to encryptconfig.yamlorsecrets.json? Just adjustpath_regex. -
Key rotation is easy. Generate a new key, update
.sops.yaml, then re-runsops -eon all files — SOPS rewrites the metadata with the new key. -
Works in CI/CD. In pipelines, feed the age key through a secret store (GitHub Actions secrets, etc.) and decrypt before building. No secret ever leaks into the repo.
-
The habit of using
.envfor secrets is also something I touch on in my home server learning path — same principle: never hardcode secrets. -
If you work across multiple devices, also make sure your GitHub SSH keys are ready on every OS and your git profiles don’t get mixed up — so the whole workflow runs smoothly end to end.
FAQ #
Is it safe to commit .env.enc to a public repo?
Yes, as long as the age private key isn’t committed along with it. The contents of .env.enc are ciphertext — without the private key, nobody can read them, including you (if you lose your key 😅).
What if I lose the private key?
Existing .env.enc files become unreadable. The fix is prevention: back up the key in a password manager from the start. If it’s already lost, generate a new key, update .sops.yaml, and re-encrypt all .env files from a device that still has the plaintext.
Why do key paths differ between OSes?
That’s SOPS’s built-in default (%APPDATA%\sops\age on Windows vs ~/.config/sops/age on Linux/macOS). No need to unify them — just place the key at the matching path on each device.
Can I encrypt for multiple devices at once?
Yes. Add all age public keys as a list in .sops.yaml — every device holding a matching private key can decrypt the same file.
Why the explicit --input-type dotenv flags?
Because SOPS doesn’t recognize the .env extension by default, so it can guess the wrong format (JSON, for example) — that’s where the “invalid character” error comes from. Explicit flags tell SOPS exactly to treat the file as dotenv.
Troubleshooting #
| Error | Cause | Fix |
|---|---|---|
parsing time "...\x0d": extra text: "\x0d" |
CRLF line endings on Windows — a \r (\x0d) character sticks to SOPS’s timestamp metadata |
Add *.env.enc text eol=lf to .gitattributes, or convert the file with dos2unix |
Error unmarshalling input json: invalid character 'P' looking for beginning of value |
SOPS misdetects the format — it tries to parse a dotenv file as JSON | Always use explicit format flags: --input-type dotenv --output-type dotenv |
error loading config: no matching creation rules found |
Filename doesn’t match path_regex, or .sops.yaml isn’t found from the directory where you run sops |
Adjust the regex (e.g. \.env(\.local)?$), or run sops from the project root where .sops.yaml lives |
Wrapping Up #
The “forgot to update .env on the laptop” problem is a classic in multi-device workflows. With SOPS + age, your .env gets versioned in git alongside your code — but stays locked tight. After a one-time setup, the ritual is just two commands: encrypt.ps1 before committing, decrypt.ps1 after pulling. Done.
Hit a similar issue, or have a cooler approach of your own? Drop it in the comments 🚀