Encrypting Secrets in Git with SOPS and age

Photo by woodleywonderworks on flickr
SOPS is a command-line tool that encrypts individual values inside YAML, JSON, ENV, INI, or binary files while leaving key names readable. This lets teams commit secrets to Git as an encrypted file, keep pull request diffs meaningful, and edit values without a manual decrypt-edit-encrypt cycle.
age uses small explicit keypairs generated with a single command and has no configuration options to get wrong, unlike PGP's web of trust and keyserver infrastructure. For a small team encrypting deploy secrets, age is faster to set up and easier to reason about than managing PGP keyrings.
Store the age private key as a masked secret in your CI platform's own secret store, then export it as the SOPS_AGE_KEY environment variable only for the decrypt step. SOPS reads that variable automatically and writes the decrypted output to a runtime file that the deploy step consumes and the job discards afterward.
Yes. The .sops.yaml file at the repository root matches file paths against regular expressions in its creation_rules and assigns different age recipients per match. Keeping staging and production secrets in separate files with separate recipients means a compromised staging key never exposes production values.
Remove their age public key from .sops.yaml and re-run the SOPS update command across every affected file, which re-wraps the data key for the remaining recipients only. This blocks future decryption, but if the person already saved plaintext values locally, you also need to rotate the underlying secret itself.

Photo by woodleywonderworks on flickr
Key Takeaway
SOPS paired with age lets teams encrypt individual secret values inside Git-tracked files instead of managing separate plaintext copies by hand, keeping diffs readable and merges conflict-free. Generate an age keypair, map recipients per environment in .sops.yaml, encrypt with sops, and decrypt in CI via the SOPS_AGE_KEY environment variable, no external secrets manager required.
Every team eventually hits the same wall: a docker-compose file needs a database password, a CI pipeline needs an API token, and nobody wants to paste it into Slack or a shared spreadsheet again. The usual fallback is a plaintext dot-env file that gets copied by hand from one laptop to the next, quietly drifting out of sync and occasionally ending up committed by accident. SOPS paired with age closes that gap by letting the encrypted file itself live in Git, versioned and reviewable, while only people and machines holding the right key can ever see the real values.
This is not a secrets manager in the Vault or AWS Secrets Manager sense. There is no server to run, no API to call at deploy time, and no additional infrastructure to keep patched. SOPS is a command-line editor that encrypts values in place inside a YAML, JSON, ENV, INI, or binary file, and age is the encryption backend that generates the small, explicit keypairs SOPS encrypts those values against. Together they turn secret distribution into a Git workflow: clone the repo, decrypt with your key, done.
A naive approach to secrets in Git is to encrypt the entire file with GPG or a zip password and commit the blob. That works but it destroys the two things that make Git useful in the first place: readable diffs and mergeability. SOPS instead walks the structure of a YAML or JSON document and encrypts only the leaf values, leaving every key name in plain text. The result looks unusual the first time you open it: a normal-looking key on the left, a long base64 string on the right, and a small unencrypted metadata block at the bottom recording which recipients can unlock the file.
Keep secrets grouped by environment in separate files, such as secrets/staging/.env and secrets/production/.env, rather than one giant encrypted file. It shrinks the blast radius if a single age key is ever compromised and keeps production out of a laptop-only staging key's recipient list.
age generates a keypair with a single command. The private key stays local, ideally outside the repository entirely, while the public key becomes a recipient that SOPS encrypts against. Anyone holding the matching private key can decrypt; nobody else can, regardless of how the encrypted file is shared. Unlike PGP, there is no keyserver to publish to and no web of trust to reason about; the public key is just a short string you paste into a config file or hand to a teammate directly.
# generate a keypair
age-keygen -o keys.txt
# Public key: age1qyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqycx3z9d
# install sops (macOS example)
brew install sopsA .sops.yaml file at the repository root tells SOPS which age public keys should be recipients for which paths, matched by regular expression. This is the mechanism that gives you per-environment keys without touching the encrypt command itself: SOPS reads the file path, matches it against creation_rules, and picks the right recipients automatically.
# .sops.yaml at repo root
creation_rules:
- path_regex: secrets/staging/.*\.env$
age: age1staging0000000000000000000000000000000000000000000000000000
- path_regex: secrets/production/.*\.env$
age: age1prod00000000000000000000000000000000000000000000000000000000Once .sops.yaml exists, encrypting a fresh file is a single command, and the resulting output is safe to commit alongside the rest of the code. Editing later does not mean decrypt-edit-re-encrypt by hand; SOPS opens the decrypted content in your configured editor, watches for changes, and re-encrypts automatically the moment you save and close.
sops --encrypt secrets/production/.env > secrets/production/.env.enc
git add secrets/production/.env.enc .sops.yaml
git commit -m "chore: add encrypted production secrets"
# edit later (opens decrypted content in $EDITOR, re-encrypts on save)
sops secrets/production/.env.encThat single detail is what makes SOPS pleasant to use daily instead of merely tolerable. Nobody has to remember a three-step dance every time a connection string changes. The table below lines up the three levels of secret handling most teams pass through, in case it helps to see where SOPS and age sit relative to the alternatives.
| Approach | Git-native | Ongoing cost |
|---|---|---|
| Plaintext .env passed by hand | No, actively dangerous if committed | High: manual redistribution every rotation |
| SOPS with age | Yes, encrypted values live in the repo | Low: one command to encrypt, edit, or rotate |
| Hosted secrets manager (Vault, AWS Secrets Manager) | No, secrets live outside the repo | Medium: infrastructure to run and authenticate against |
Continuous integration needs the same decrypted values the application needs at runtime, but a CI runner should never have a persistent key file sitting on disk. SOPS reads the private key straight from the SOPS_AGE_KEY environment variable, so the natural fit is to store the age private key as an encrypted CI secret and export it only for the duration of the job.
# GitHub Actions step
- name: Decrypt production secrets
env:
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY_PRODUCTION }}
run: |
sops --decrypt secrets/production/.env.enc > .env
docker compose up -dThe pattern holds regardless of which CI platform you use, because the moving pieces are the same three steps every time.
Never echo a decrypted file's contents in a CI log for debugging. Most CI platforms mask values pulled from their own secret store automatically, but a decrypted SOPS output is just a regular file to the runner and will print in full if you cat it or run the process with verbose logging enabled.
SOPS is a fit for secrets that are small, structured, and change with the deploy cadence of the application: database credentials, third-party API keys, signing secrets, and per-environment feature flags. It is not a replacement for a full secrets manager when you need dynamic short-lived credentials, fine-grained access auditing across dozens of teams, or automatic rotation triggered by an external event. Think of SOPS as version-controlled configuration that happens to be encrypted, not as a runtime credential broker.
If you outgrow SOPS, the migration path is usually additive rather than a rewrite: keep SOPS for bootstrap and infrastructure secrets, and move high-churn application secrets to a hosted manager once the team and the secret count justify running one.
Because every file lists its recipients by public key, adding or removing a person from the team is a metadata change, not a re-encryption of every value by hand. Add the new teammate's age public key to .sops.yaml, then run the update command, which re-wraps the existing data key for every listed recipient without touching the encrypted values themselves.
# rotate: add a new team member's key, re-encrypt all files
sops updatekeys secrets/production/.env.enc
# revoke: remove a departing team member's age key from .sops.yaml,
# then re-run updatekeys against every affected fileRevoking access works the same way in reverse: delete the departing person's public key from .sops.yaml and re-run the update command across every affected file. This only blocks future decryption; if that person already has old plaintext values saved locally, rotate the underlying secret itself, not just the SOPS recipient list.