Portable Secrets in Git with SOPS and age: A Practical Quickstart
.env files are convenient, but they mix two very different kinds of configuration:
APP_PORT=8080
LOG_LEVEL=debug
DATABASE_HOST=localhost
OPENAI_API_KEY=sk-...
STRIPE_SECRET_KEY=sk-...
Most of that file isn’t secret. Encrypting all of it hides useful configuration and makes Git diffs much less useful.
There’s a better arrangement. Keep the plain .env local and gitignored, and commit an encrypted .env.sops alongside it. In that committed copy, ordinary configuration stays readable and only values you explicitly mark as secrets get encrypted. age identities control who can decrypt them, and adding a new identity is treated as an authorization change rather than a side effect of saving a file. The result looks roughly like this:
APP_PORT=8080
LOG_LEVEL=debug
DATABASE_HOST=localhost
# sops:encrypt
OPENAI_API_KEY=ENC[AES256_GCM,...]
# sops:encrypt
STRIPE_SECRET_KEY=ENC[AES256_GCM,...]
The repository stays understandable without exposing credentials.
What SOPS and age each do
SOPS is a secrets-file editor and encryption tool maintained under CNCF. It supports structured formats including YAML, JSON, dotenv, and INI. age is a small public-key encryption tool.
They solve different halves of the problem, stacked on top of each other:
flowchart TD
S["SOPS<br/>understands the .env file<br/>decides which values are encrypted"]
A["age<br/>controls who can decrypt the SOPS data key"]
E["encrypted secrets"]
S --> A --> E
You need both, and it helps to keep the split straight when debugging.
An age identity has two parts. The public recipient (age1abc123...) is safe to share and commit. The private identity (AGE-SECRET-KEY-1...) never goes in Git.
Repository layout
my-project/
├── .env # local plaintext runtime config; gitignored
├── .env.example # optional template; committed
├── .env.sops # partially encrypted copy; committed
├── .sops.yaml # SOPS policy + age recipients; committed
├── .gitignore
├── Makefile
└── scripts/
├── secrets-encrypt
├── secrets-authorize
└── secrets-check
Keeping .env and .env.sops as separate files is deliberate:
.env
local working/runtime copy
plaintext
never committed
.env.sops
Git copy
non-secret values readable
secret values encrypted
The separation means Docker Compose, Make, dotenv libraries, your IDE, and your application code never need to know SOPS exists.
Install and generate an identity
On macOS:
brew install sops age yq
sops --version
age --version
yq --version
yq isn’t strictly required to use SOPS, but the scripts later in this post use it to read the recipient list out of .sops.yaml.
Create the standard SOPS age directory and generate an identity into it:
mkdir -p ~/.config/sops/age
chmod 700 ~/.config/sops/age
age-keygen -o ~/.config/sops/age/keys.txt
chmod 600 ~/.config/sops/age/keys.txt
age-keygen prints the public recipient (Public key: age1abc123...), which you’ll need in a moment. The private identity stays at ~/.config/sops/age/keys.txt. Back it up through a secure channel such as a password manager, and never put it in Git.
Mark secrets explicitly
The safest way to decide what’s secret is to make the decision explicit rather than inferred:
APP_PORT=8080
LOG_LEVEL=debug
DATABASE_HOST=localhost
# sops:encrypt
OPENAI_API_KEY=sk-...
# sops:encrypt
STRIPE_SECRET_KEY=sk-...
# sops:encrypt
DATABASE_URL=postgres://user:password@db.example.com/app
Anything carrying a # sops:encrypt comment is secret; everything else stays plaintext in the committed .env.sops. This beats guessing from variable names — DATABASE_URL above holds a password even though nothing in its name says SECRET or PASSWORD.
Configure selective encryption
Create .sops.yaml:
creation_rules:
- path_regex: '\.env\.sops$'
age:
- age1abc123...
encrypted_comment_regex: 'sops:encrypt'
encrypted_comment_regex tells SOPS to encrypt the value attached to a preceding or same-line comment matching sops:encrypt, and leave everything else alone. The age public key here is safe to commit.
For several authorized machines or developers, list each recipient:
creation_rules:
- path_regex: '\.env\.sops$'
age:
- age1MAC...
- age1DESKTOP...
- age1SERVER...
encrypted_comment_regex: 'sops:encrypt'
Each machine keeps its own private identity.
Then keep the plain .env out of Git:
.env
.env.*
!.env.example
!.env.sops
Run git status before your first commit and confirm .env doesn’t show up as untracked.
Encrypting and decrypting
Because .env.sops doesn’t end in the usual dotenv extension, you have to tell SOPS the format explicitly. --filename-override .env.sops makes SOPS evaluate the creation rule against the committed filename:
sops encrypt \
--filename-override .env.sops \
--input-type dotenv \
--output-type dotenv \
.env > .env.sops
Given this input:
APP_PORT=8080
LOG_LEVEL=debug
DATABASE_HOST=localhost
# sops:encrypt
OPENAI_API_KEY=sk-example
# sops:encrypt
STRIPE_SECRET_KEY=sk-example
the committed .env.sops comes out roughly like this — the exact metadata representation depends on your SOPS version and the dotenv serializer:
APP_PORT=8080
LOG_LEVEL=debug
DATABASE_HOST=localhost
# sops:encrypt
OPENAI_API_KEY=ENC[AES256_GCM,data:...,type:str]
# sops:encrypt
STRIPE_SECRET_KEY=ENC[AES256_GCM,data:...,type:str]
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\n...
sops_age__list_0__map_recipient=age1abc123...
sops_encrypted_comment_regex=sops:encrypt
sops_mac=ENC[AES256_GCM,data:...,type:str]
sops_version=3.13.3
The sops_age__list_N__map_recipient lines matter later. They are the file’s actual recipient list, stored in the clear, and everything below is built on being able to read them without holding any key.
The payoff is that diffs stay readable:
-LOG_LEVEL=info
+LOG_LEVEL=debug
FEATURE_X_ENABLED=true
OPENAI_API_KEY=ENC[...]
You can review a configuration change without decrypting anything. Commit both files:
git add .env.sops .sops.yaml
git commit -m "Add SOPS-managed environment"
Decrypting back is symmetric, and again needs the explicit types:
sops decrypt \
--input-type dotenv \
--output-type dotenv \
.env.sops > .env
chmod 600 .env
What comes out is an ordinary plaintext .env that your application reads the way it always has.
You can also skip the file entirely and inject the decrypted environment straight into a process:
sops exec-env \
--input-type dotenv \
.env.sops \
'./my-app'
Nothing ever touches disk in plaintext:
flowchart TD
G["Git"]
F[".env.sops<br/>non-secret values already readable<br/>secret values encrypted"]
S["SOPS + age"]
P["process environment"]
A["application"]
G --> F --> S --> P --> A
That’s tidier for production, but a local .env is convenient enough during development that either model is reasonable.
Alternative: encrypt by variable name
SOPS also supports encrypted_regex, which matches on the variable name instead of a comment:
creation_rules:
- path_regex: '\.env\.sops$'
age:
- age1abc123...
encrypted_regex: '(_SECRET|_PASSWORD|_TOKEN|_API_KEY|_PRIVATE_KEY)$'
With that rule, these are all encrypted automatically:
APP_PORT=8080
LOG_LEVEL=debug
OPENAI_API_KEY=sk-...
DATABASE_PASSWORD=secret
GITHUB_TOKEN=ghp_...
Convenient, but it silently misses things:
DATABASE_URL=postgres://user:password@host/db
REDIS_URL=redis://:password@host
DSN=https://credential@example.com/...
That’s why explicit # sops:encrypt annotations are the better default. A naming regex is worth adding later as extra linting, but it shouldn’t be the only source of truth unless your naming policy is genuinely strict.
Give each machine its own identity
You can share one private identity across machines, but separate ones are far easier to revoke. Generate an identity per machine — laptop, desktop, build server — and list their public recipients together:
creation_rules:
- path_regex: '\.env\.sops$'
age:
- age1MAC...
- age1DESKTOP...
- age1SERVER...
encrypted_comment_regex: 'sops:encrypt'
The private keys never leave their respective machines, apart from secure backup.
Policy and reality are two different things
This is the part that’s easy to miss. Two files hold two different facts:
.sops.yaml
│
└── who SHOULD be allowed
.env.sops
│
└── who IS currently able to unwrap this file's data key
An existing .env.sops carries its own SOPS metadata recording the age keys that can unwrap that particular file’s data key. Editing .sops.yaml does nothing to the recipients baked into a file that’s already encrypted.
That gap is annoying at first and then turns out to be a useful security control.
Suppose .env.sops currently allows age1ALICE... and age1BUILD..., and someone changes .sops.yaml to also request age1NEW.... The next time anybody encrypts .env, that new recipient would quietly gain access to every secret in the file. Nobody reviews that, because it looks like an ordinary secrets edit.
So don’t let ordinary encryption approve access changes. Before writing .env.sops, compare the recipients already in the file against the ones .sops.yaml asks for, and stop if the second set is larger:
flowchart TD
F[".env.sops<br/>actual recipients"]
Y[".sops.yaml<br/>desired recipients"]
D{"diff"}
C["continue"]
S["STOP"]
F --> D
Y --> D
D -->|no additions| C
D -->|new recipient| S
Three commands, with different privileges
Splitting the operations makes the distinction enforceable:
make secrets-encrypt
make secrets-authorize
make secrets-check
make secrets-encrypt updates encrypted values without expanding access. If it notices a new recipient, it refuses:
$ make secrets-encrypt
ERROR: SOPS recipient authorization changed.
Existing:
age1ALICE...
age1BUILD...
Added:
+ age1NEW...
Normal secret encryption cannot grant a new recipient access.
Run:
make secrets-authorize
to explicitly review the authorization change.
The invariant that buys you: editing secrets can never silently expand who can decrypt them.
make secrets-authorize is where recipient changes actually happen, and it’s interactive on purpose:
$ make secrets-authorize
SOPS authorization change
Added:
+ age1NEW...
Removed:
- age1OLD...
Adding a recipient gives the holder of its private
key access to every encrypted secret in .env.sops.
Type "yes" to authorize:
Only after that approval should the script update the file’s key metadata, using SOPS’s key-management update operation. A noninteractive process shouldn’t be able to approve a new recipient on its own.
make secrets-check is read-only. It verifies that the desired recipients match the actual ones, and optionally that every known-sensitive variable carries its # sops:encrypt marker:
$ make secrets-check
✓ .env.sops is valid SOPS dotenv
✓ configured recipients match file recipients
✓ no unexpected recipients
✓ secret annotations are valid
Local checks first, CI second
Plenty of new projects have no CI at all, which is a good reason not to make CI the primary security boundary. Run the policy checks locally, inside secrets-encrypt itself, so an unsafe change fails on the developer’s machine before it’s ever pushed:
flowchart TD
D["Developer"]
M["make secrets-encrypt"]
C{"local policy checks"}
E["encrypt"]
F["FAIL"]
D --> M --> C
C -->|safe| E
C -->|unsafe| F
When CI does arrive, have it run the same command:
flowchart LR
G["Git change"]
L["local check"]
C["CI check"]
G --> L
G --> C
At that point CI is defense-in-depth rather than the only thing standing between you and an accidental authorization change.
The Makefile
Keep the developer-facing interface small:
.PHONY: secrets-decrypt secrets-encrypt secrets-authorize secrets-check
secrets-decrypt:
sops decrypt \
--input-type dotenv \
--output-type dotenv \
.env.sops > .env
chmod 600 .env
secrets-encrypt:
./scripts/secrets-encrypt .env .env.sops
secrets-authorize:
./scripts/secrets-authorize .env.sops
secrets-check:
./scripts/secrets-check .env.sops
Decrypting is the one operation with no policy attached, so it stays inline. The other three carry rules, and rules belong in scripts.
The one comparison that matters
The three commands differ only in what they’re allowed to do about a single comparison, so that comparison is the part worth showing.
The recipients a file actually allows are sitting in its metadata in plaintext. Reading them needs no private key at all, which is what lets this run anywhere:
actual() { sed -n 's/^sops_age__list_[0-9]*__map_recipient=//p' "$1" | sort -u; }
The recipients policy asks for come from .sops.yaml:
desired() { yq -r '.creation_rules[0].age' .sops.yaml | grep -oE 'age1[0-9a-z]+' | sort -u; }
scripts/secrets-encrypt runs both and refuses when they disagree:
added=$(comm -13 <(actual .env.sops) <(desired))
removed=$(comm -23 <(actual .env.sops) <(desired))
if [ -n "$added$removed" ]; then
echo "ERROR: SOPS recipient authorization changed."
echo "Run: make secrets-authorize"
exit 1
fi
That’s the entire invariant. Note that it refuses on removals too — a dropped recipient is still an access change, and silently locking a teammate out is its own kind of bad day.
Only once that passes does it encrypt, and even then into a temporary file:
sops encrypt \
--filename-override .env.sops \
--input-type dotenv \
--output-type dotenv \
.env > .env.sops.tmp
# ...verify the result decrypts and carries the expected recipients...
mv .env.sops.tmp .env.sops
A known-good encrypted file should never be destroyed by a half-working encrypt. One caveat if you write that verification yourself: SOPS’s dotenv serializer drops blank lines, so a decrypted file is never byte-identical to the original and a naive round-trip check fails every time.
scripts/secrets-authorize makes the same comparison and reaches the opposite conclusion — this is the one place allowed to act on it:
# ...print the added/removed diff...
[ -t 0 ] || { echo "refusing to authorize noninteractively."; exit 1; }
printf 'Type "yes" to authorize: '
read -r reply
[ "$reply" = "yes" ] || exit 1
sops updatekeys --yes --input-type dotenv .env.sops
The [ -t 0 ] test is the load-bearing line. Without it, any CI job could approve a new recipient by piping in yes.
sops updatekeys is the right primitive for the last step: it rewraps the file’s existing data key for the new recipient set without ever writing decrypted values to disk. Unlike sops encrypt, it re-derives the rule from the real path, so it needs no --filename-override.
scripts/secrets-check is just the same comparison with no write path at all — it reports and exits non-zero. Since it only reads plaintext metadata, it’s safe in CI and in a pre-commit hook.
Day-to-day
After cloning, decrypt once:
make secrets-decrypt
Then work normally — edit .env, run the app, ignore SOPS entirely. When configuration or secrets change:
make secrets-encrypt
git diff -- .env.sops
git add .env.sops
git commit
The diff shows your config changes in the clear while secret values stay encrypted.
Adding a new secret is the same flow. Write it with its marker:
PAYMENTS_ENDPOINT=https://payments.example.com
# sops:encrypt
PAYMENTS_API_KEY=secret-value
run make secrets-encrypt, and the committed file keeps the endpoint readable while the key becomes ENC[...]. No authorization change is involved, because the recipients already authorized for the file can decrypt the new value too.
Adding and removing people
Adding a machine or developer starts on their side — they generate an identity:
age-keygen -o ~/.config/sops/age/keys.txt
and send you only the public recipient, which you add to .sops.yaml:
age:
- age1MAC...
- age1DESKTOP...
- age1NEW...
Then run the explicit operation and review exactly what changed:
make secrets-authorize
git add .sops.yaml .env.sops
git commit -m "Authorize new SOPS recipient"
Removing someone is the same in reverse: drop them from .sops.yaml, run make secrets-authorize, and their key can no longer decrypt future versions of the file.
That last word matters. Removing a recipient can’t make someone forget secrets they already decrypted. If the machine or person may have retained a value, rotate the underlying credential as well — the API key, the password, the token. Cryptography controls future access to the file; rotation handles what’s already out.
The portability boundary
A new machine needs two things: the Git repository, and an authorized age private identity. Git carries everything else — .env.sops, .sops.yaml, .env.example, the Makefile, the scripts, the application. What Git must never carry is the AGE-SECRET-KEY-1... half.
That private identity has to arrive some other way: a password manager, a hardware-backed secret store, secure provisioning, or a careful manual transfer. It’s the root of trust for the whole scheme, and it’s the one thing that can’t live next to the data it protects. A repository holding both the encrypted secrets and the key to decrypt them isn’t really protecting anything.
Put together, the split looks like this:
Git repository
│
├── .sops.yaml
│ ├── age public recipients
│ └── selective encryption policy
│
├── .env.sops
│ ├── APP_PORT=8080
│ ├── LOG_LEVEL=debug
│ ├── OPENAI_API_KEY=ENC[...]
│ └── SOPS metadata
│
├── .env.example
├── Makefile
└── scripts/
├── secrets-encrypt
├── secrets-authorize
└── secrets-check
Developer machine
│
├── ~/.config/sops/age/keys.txt
│ private identity
│
└── .env
├── normal config
└── plaintext secrets
gitignored
What to keep in mind
A handful of these are worth remembering after you’ve forgotten the rest of the setup:
- Never commit the plaintext
.envor an age private identity. - Mark secrets explicitly with
# sops:encrypt; don’t rely on secret-looking variable names. - Leave non-secret configuration readable, so diffs stay reviewable.
- Normal encryption must never silently add recipients.
- Run the recipient and policy checks locally even when there’s no CI to run them.
- When previously authorized access may have been compromised, rotate the credential — don’t just remove the recipient.
The goal was never to “encrypt .env.” The better model is this:
flowchart TD
G["Git stores configuration"]
N["normal values<br/>readable"]
S["secret values<br/>SOPS encrypted"]
A["age recipients"]
X["explicit access"]
G --> N
G --> S
S --> A --> X
Git stores your configuration honestly — ordinary values readable, secret values encrypted — and access to those secrets stays an explicit, auditable decision instead of a side effect of whoever last ran the encrypt command.