# Encrypting Secrets in Git with SOPS and age

> How to keep API keys and database credentials safely inside a Git repository using SOPS and age, with per-environment keys and CI decryption.

URL: https://www.matthewswong.com/en/blog/sops-age-encrypted-secrets-git/

- Author: Matthews Wong (https://www.matthewswong.com/en/about/)
- Published: 2026-07-08
- Updated: 2026-07-08
- Category: Security

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.

## Why encrypt values instead of whole files

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.

A pull request reviewer can see that DATABASE_PASSWORD changed without ever seeing the old or new value.

Two engineers can add different keys to the same file in separate branches and merge without a conflict, because the unchanged lines stay byte-identical.

The file keeps looking like a normal env or config file structurally, so existing tooling that inspects key names still works.

> 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.

## Setting up age keys and the SOPS configuration

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.

A .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.

## Daily workflow: encrypt, commit, edit

Once .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.

That 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

## Decrypting in CI without storing a key file

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.

The pattern holds regardless of which CI platform you use, because the moving pieces are the same three steps every time.

Store the age private key as a masked secret in the CI platform's own secret store, scoped to the branch or environment that needs it.

Export it as SOPS_AGE_KEY for the decrypt step only, so it never lands in a build log or an intermediate file.

Decrypt to a runtime file such as .env immediately before the process that consumes it starts, and let that file get discarded when the job container exits.

> 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.

## What belongs in SOPS and what does not

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.

Good fit: a handful of environment variables per deploy target that a small team edits a few times a month.

Good fit: infrastructure-as-code repositories where Terraform variables or Kubernetes secrets need to travel with the code that references them.

Poor fit: thousands of dynamically issued database credentials that need per-request audit trails, which calls for a real secrets manager with an API.

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.

## Key rotation and revoking access

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.

Revoking 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.

## Sources

- SOPS documentation: age identities
- getsops/sops on GitHub
- FiloSottile/age on GitHub

> **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.

## Source links

- https://getsops.io/docs/usage/identities/age/
- https://github.com/getsops/sops
- https://github.com/FiloSottile/age

## FAQ

### What is SOPS used for?

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.

### Why use age instead of PGP with SOPS?

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.

### How do I decrypt SOPS secrets in a CI pipeline?

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.

### Can I use different age keys for staging and production?

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.

### How do I revoke someone's access to encrypted secrets in Git?

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.

---

- HTML version: https://www.matthewswong.com/en/blog/sops-age-encrypted-secrets-git/
- Bahasa Indonesia: https://www.matthewswong.com/id/blog/sops-age-encrypted-secrets-git/
- Site index for agents: https://www.matthewswong.com/llms.txt
- Sitemap: https://www.matthewswong.com/sitemap.xml
- API description (OpenAPI): https://www.matthewswong.com/openapi.json
