Stop Committing Keys: A Secrets Workflow for Two-Person Teams Using SOPS and age
Every small engineering team goes through the same arc. Day one, the database password lives in settings.py. Month three, someone moves it to a .env file and adds it to .gitignore, which feels like a fix. Month nine, a developer commits .env.production from a different machine where the ignore rule did not apply, and the M-Pesa consumer secret is now in git history forever.
My thesis: small teams do not need HashiCorp Vault, and they do not need to keep living with plaintext .env files passed around on WhatsApp. There is a middle option that takes an afternoon to set up and is genuinely good: encrypted secrets committed to your repository, with one key per human, using SOPS and age.
Here is the whole workflow, in the order you should do it.
Step 0: assume you have already leaked something
Before you build anything, find out what is already in your git history. Deleting a file in a later commit does nothing; the blob is still there and git log -p will happily print it.
# Install gitleaks (check the current release for your platform)
gitleaks version
# Scan the FULL history of a local repo, not just the working tree
gitleaks git /path/to/your/repo --report-format json --report-path leaks.json -v
# Scan a directory that is not a git repo (uploads, server config backups)
gitleaks dir /etc/myapp -v
Note that in gitleaks v8.19 and later the old detect and protect commands were deprecated in favour of git, dir, and stdin. They still work but are hidden from help, so use the current names.
Run the same scan against every repository your team owns, including the abandoned ones. Expect to find things. Everyone does.
Step 1: rotate before you clean up
This is the step people get backwards. They try to rewrite git history first, spend a day fighting git filter-repo, and leave the live credential valid the whole time.
Rotate first. A leaked secret that no longer works is an embarrassment. A leaked secret that still works is an incident. Order of operations:
- Generate the new credential at the provider.
- Deploy it.
- Revoke the old one and confirm it fails.
- Only then decide whether history rewriting is worth it.
For a private repository with a small, trusted team, rewriting history is often not worth the disruption once the secret is dead. For a repository that was ever public, or that a former contractor cloned, treat rewriting as mandatory and assume the clone still exists.
Keep a simple rotation log so you can answer "when did we last rotate the payment API key" without guessing.
Step 2: make the plain .env less dangerous where you still use it
You will still have .env files on servers. Make them behave.
# Correct ownership and permissions - readable by the service user only
sudo chown deploy:deploy /srv/app/.env
sudo chmod 600 /srv/app/.env
# Never let the web server serve it
grep -rn "\.env" /etc/nginx/sites-enabled/
location ~ /\.(env|git|ht) {
deny all;
return 404;
}
And make the ignore rules unambiguous, because .env.production.local is exactly the file that slips through a lazy pattern.
# .gitignore
.env
.env.*
!.env.example
*.pem
*.key
*.agekey
secrets/*.dec.yaml
Commit a .env.example with every key present and every value blank. It is documentation, and it stops the "which variables does this service need" conversation permanently.
Step 3: SOPS plus age, the actual setup
SOPS encrypts values, not whole files. Your YAML keys stay readable, so a pull request diff shows that DB_PASSWORD changed without revealing what it changed to. age is a modern, small file-encryption tool using X25519 keys, and it is far less painful than GPG.
Generate one key per person
mkdir -p ~/.config/sops/age
age-keygen -o ~/.config/sops/age/keys.txt
chmod 600 ~/.config/sops/age/keys.txt
# Your public key - this is the part you share
grep "public key" ~/.config/sops/age/keys.txt
SOPS looks for the private key at $XDG_CONFIG_HOME/sops/age/keys.txt or $HOME/.config/sops/age/keys.txt on Linux by default. You can point elsewhere with SOPS_AGE_KEY_FILE, or supply the key directly with SOPS_AGE_KEY in CI.
The private key never leaves the laptop. The public key goes in the repo. That is the whole trust model, and it is easy to explain to a non-specialist.
Declare who can decrypt what
# .sops.yaml at the repo root
creation_rules:
- path_regex: secrets/prod/.*\.yaml$
age: >-
age1s3cqcks5genc6ru8chl0hkkd04zmxvczsvdxq99ekffe4gmvjpzsedk23c,
age1qe5lxzzeppw5k79vxn3872272sgy224g2nzqlzy3uljs84say3yqgvd0sw
- path_regex: secrets/staging/.*\.yaml$
age: >-
age1s3cqcks5genc6ru8chl0hkkd04zmxvczsvdxq99ekffe4gmvjpzsedk23c,
age1qe5lxzzeppw5k79vxn3872272sgy224g2nzqlzy3uljs84say3yqgvd0sw,
age1jr8sxvkc4h0xrpsq9lxq0m0w0kx5aa8k8lqxg8ur2r2vlyw6f9yqp0nzhv
This file is the access control list for your secrets, in version control, reviewable in a pull request. Staging includes the junior developer's key. Production does not. That distinction is now visible to everyone instead of living in one person's head.
Encrypt, edit, decrypt
# Encrypt an existing file in place, using the rules in .sops.yaml
sops encrypt -i secrets/prod/app.yaml
# Or encrypt to a new file with an explicit recipient
sops encrypt --age age1s3cqcks5genc6ru8chl0hkkd04zmxvczsvdxq99ekffe4gmvjpzsedk23c \
app.yaml > secrets/prod/app.yaml
# Edit: decrypts to a temp file, opens $EDITOR, re-encrypts on save
sops edit secrets/prod/app.yaml
# Read it back
sops decrypt secrets/prod/app.yaml
Commit the encrypted file. It is safe in the repository, and git diff on it is actually informative because the structure is preserved.
Get the secrets into the running process
Two clean patterns. Either render a .env at deploy time and delete it after, or inject directly into the process environment so it never touches disk.
# Pattern A: render dotenv at deploy time
sops decrypt --output-type dotenv secrets/prod/app.yaml > /srv/app/.env
chmod 600 /srv/app/.env
sudo systemctl restart app
# Pattern B: never write to disk
sops exec-env secrets/prod/app.yaml 'python manage.py migrate'
With systemd, pattern A pairs naturally with EnvironmentFile:
[Service]
User=deploy
EnvironmentFile=/srv/app/.env
ExecStart=/srv/app/venv/bin/gunicorn app.wsgi
Restart=always
Step 4: block the next leak at commit time
Policy that depends on people remembering is not policy. Put a hook in front of the commit.
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.24.2
hooks:
- id: gitleaks
pip install pre-commit
pre-commit install
# Emergency escape hatch, to be used with a guilty conscience
SKIP=gitleaks git commit -m "message"
Then run the same scan in CI so the hook cannot simply be bypassed by anyone in a hurry. Local hooks catch mistakes; CI enforces the rule.
Step 5: offboarding becomes a two-command operation
This is the payoff that justifies the whole setup. When a developer leaves, you remove their public key from .sops.yaml and re-key the files. They can no longer decrypt anything new, and the change is a reviewable commit.
# 1. Remove the departing person's age public key from .sops.yaml
# 2. Re-key every encrypted file to the new recipient list
sops updatekeys secrets/prod/app.yaml
sops updatekeys secrets/staging/app.yaml
# 3. Rotate the secrets themselves - they saw the plaintext
git commit -am "Remove departed contractor from prod secrets"
Step three matters. Removing someone's decryption key does not un-see the passwords they already read. Re-keying controls future access; rotation controls past knowledge. Do both.
When to move up to something bigger
| Approach | Good for | Breaks down when |
|---|---|---|
| Plain .env on the server | One server, one person | Two or more people need it, or you must audit access |
| SOPS plus age in git | 2 to 15 people, a handful of environments | You need per-request access logs or dynamic short-lived credentials |
| Cloud secret manager | Teams already all-in on one cloud | Multi-cloud, or you want secrets reviewable in pull requests |
| HashiCorp Vault | Many services, dynamic database credentials, real audit needs | You have nobody to operate it - an unmaintained Vault is worse than SOPS |
Be honest about that last row. Vault is excellent and it is also a system that needs unsealing, upgrading, backing up, and monitoring. If your team is three people shipping an ERP integration, running Vault is a second product you did not want to build.
The rules worth writing on the wall
- A secret that has ever been in git history is compromised. Rotate it, then decide about history.
- Every human gets their own key. Shared keys make offboarding impossible.
- Secrets differ per environment. If staging and production share a database password, you do not have two environments.
- Encrypted secrets belong in the repo. Plaintext secrets belong nowhere except memory and a 600-mode file on the server.
- Rotation is scheduled work, not incident work. Put it in the calendar quarterly.
- Never paste a secret into WhatsApp, Slack, or email. If it happened, that secret is now rotated. No exceptions, no negotiation.
None of this requires budget. It requires an afternoon and the decision to stop treating credential handling as something you will professionalise later. Under section 41 of Kenya's Data Protection Act you are already expected to identify foreseeable risks to personal data and maintain safeguards against them. A database password sitting in a public repository is not a defensible safeguard, and it is a very short conversation with a regulator.