Encrypt .env Files with SOPS and age

Not every project wants a secret manager. A small team, a self-contained deployment, or an early-stage service can get a long way with encrypted files committed alongside the code — provided the encryption keeps diffs reviewable and the key distribution is genuinely manageable. SOPS with age keys does both: values are encrypted individually so keys stay readable, and recipients are public keys rather than a shared password. This page covers the workflow and its limits, extending the .env file management topic.

Problem 1: encrypting the whole file

# ANTI-PATTERN: the diff is one opaque blob, so review is impossible
gpg --symmetric --cipher-algo AES256 .env       # -> .env.gpg, entirely unreadable

A whole-file cipher means every change looks identical in review: one binary blob replaced by another. A reviewer cannot tell whether the commit changed one value or rewrote the file, cannot see which setting was touched, and has no way to spot an accidental deletion. In practice this pushes people to review the plaintext out-of-band, which defeats the point of having the change in version control at all.

Per-value encryption keeps the structure and the keys in plaintext and encrypts only the values. The diff then shows exactly which settings changed, in the right order, with the values opaque — which is precisely the amount of information a reviewer needs.

Whole-file encryption versus per-value encryption in review A whole-file cipher produces a single opaque blob in the diff, while per-value encryption keeps key names and structure readable so a reviewer can see which settings changed. whole file - one binary blob + a different binary blob nothing reviewable per value DATABASE_URL: ENC[...unchanged...] - API_TOKEN: ENC[...old...] + API_TOKEN: ENC[...new...] one setting changed, visibly
The reviewer needs to know which setting changed, not what it changed to.

Problem 2: a shared passphrase as the key

# ANTI-PATTERN: one password, shared, unrotatable in practice
export SOPS_AGE_KEY="the-team-password"     # in a chat message, a wiki, three laptops

A shared secret used to protect other secrets has all the problems of the credentials it protects and none of the tooling. It cannot be revoked for one person, nobody knows how many copies exist, and rotating it means coordinating every holder simultaneously — which means it never gets rotated.

Recipient-based encryption fixes the structure. Each person and each deployment target has its own key pair; the file is encrypted to a list of public keys; adding someone is a re-encryption, and removing someone is a re-encryption without their key. Nobody shares a private key, and the list of recipients in the file’s metadata is a readable statement of who can decrypt it — which is an inventory you otherwise do not have.

Problem 3: the application decrypting at runtime

# ANTI-PATTERN: the running service holds a decryption key
subprocess.run(["sops", "-d", "secrets.enc.env"], capture_output=True)   # inside the app

If the application decrypts, then the application must hold a private key capable of decrypting every value in the file — which is a more valuable credential than any single secret it protects, and it must itself be delivered somehow. The bootstrapping problem has moved rather than been solved, and the application now has a dependency on a decryption tool at runtime.

Decrypt at deployment time instead. The pipeline or the deployment step decrypts into environment variables or a mounted file, and the running process reads ordinary configuration with no key and no tooling. That keeps the decryption capability in one place — the deployment identity — where it can be scoped and audited.

Secure implementation

# .sops.yaml — who can decrypt what, checked into the repository
creation_rules:
  - path_regex: secrets/production\.env$
    # 1. deployment key first, then the humans who may read production
    age: >-
      age1deploy0prod...,
      age1alice...,
      age1bob...
  - path_regex: secrets/staging\.env$
    age: >-
      age1deploy0staging...,
      age1alice...,
      age1bob...,
      age1carol...
# creating and editing — the plaintext never touches the working tree
sops secrets/production.env          # 2. decrypts into an editor, re-encrypts on save
sops updatekeys secrets/staging.env  # 3. after changing recipients in .sops.yaml
# deploy step — decrypt to the environment, never into the image
set -euo pipefail
# 4. the deployment identity's key is the only one present here
export SOPS_AGE_KEY_FILE=/run/deploy-keys/age.key
# 5. exported into the process environment; no plaintext file is written
set -a && eval "$(sops -d --output-type dotenv secrets/production.env)" && set +a
python -m app.config_check           # 6. validate before starting anything
exec gunicorn app:application

The .sops.yaml file is the part worth reviewing carefully, because it is the access-control policy in readable form. Anyone can see which keys can decrypt production, and adding a recipient is a diff that a reviewer can reason about — which is a far better position than a wiki page listing who was given a passphrase two years ago. Keep the deployment key first in each list as a convention, so a missing deployment key is visually obvious.

Note that the deploy step never writes a plaintext file. eval with set -a places the values directly into the process environment, so nothing lands on disk where a subsequent step, a crash dump, or an image layer could capture it. If your platform makes that awkward, decrypt into a tmpfs path and delete after loading, but avoid the working tree entirely — an accidental git add -A after a decryption into the repository is a leak that looks exactly like an ordinary commit.

Where decryption happens The encrypted file travels through the repository and the pipeline, is decrypted by the deployment identity into the process environment, and the running application holds no decryption key. repository ciphertext only deploy step holds the age key process env plaintext, in memory app no key no plaintext file is ever written to the working tree
Decryption belongs to the deployment identity — the application never holds a key.

What this approach cannot do

Encrypted files in a repository have a hard limit worth being explicit about: history is permanent. Anyone who could decrypt the file yesterday can still decrypt every version they cloned, forever, regardless of what you change today. Removing a recipient and re-encrypting protects future commits and nothing else, so genuine revocation means rotating the values themselves — the same requirement as any other credential exposure.

The second limit is granularity. Access is per file, so a person who needs one value gets all of them, and a service that needs one value would too if it decrypted for itself. Splitting into several files by trust boundary helps and does not scale far, because the number of files grows with the cross-product of environments and audiences.

The third is the absence of an audit trail. A secret manager records who read which secret and when; a file in a repository records who cloned it, which is not the same question and is usually not recorded either. If you need to answer “who accessed this credential”, encrypted files cannot give you an answer, and that requirement alone is a sufficient reason to move to a manager. Knowing all three limits in advance is what makes this a deliberate choice for the stage you are at rather than a decision you discover you have outgrown during an incident.

Onboarding and offboarding are where the workflow is felt day to day, and both are short. Onboarding is: the new person generates a key pair, sends their public key, someone adds it to .sops.yaml, runs updatekeys on the files they should read, and commits. Offboarding is the same steps in reverse plus rotation of the values that person could decrypt. Writing both sequences into the repository’s contributing notes takes ten minutes and prevents the failure where somebody’s key is added to production because that was the file the example used.

Gotchas and version-specific behaviour

  • Encrypt per value, not per file, so diffs stay reviewable and a change shows which setting it touched.
  • Never decrypt into the working tree; a stray git add -A afterwards produces a commit that looks entirely ordinary.
  • Removing a recipient protects future versions only — historical commits remain decryptable by anyone who had access.
  • sops updatekeys re-encrypts an existing file after the recipient list changes; editing .sops.yaml alone does nothing.
  • Keep the deployment identity’s key separate from any human key, so a person leaving does not affect deployments.
  • Binary values need base64 encoding before storage, and the decode belongs in the settings model.
  • The encrypted file’s metadata lists its recipients, which makes access review a matter of reading the file rather than trusting a separate document.
  • Editor integrations that decrypt on open are convenient and write plaintext to temporary files, so check where those files live before enabling one.

Production parity checklist

  • Values are encrypted individually; key names and file structure remain in plaintext.
  • .sops.yaml is reviewed like access-control policy, because that is what it is.
  • Deployment identities have their own keys, distinct from every human key, so a person leaving never affects a deployment’s ability to decrypt.
  • Decryption happens in the deploy step, into the environment, never in the application.
  • Removing a recipient is always accompanied by rotating the values they could read.
  • A pre-commit hook rejects any unencrypted secrets file, so a plaintext copy left behind after an edit cannot land in a commit by accident.
  • Onboarding and offboarding steps are written down in the repository, including which files a new key should be added to.
Encrypted files compared with a secret manager Encrypted files need no runtime dependency and suit small teams, while a manager provides per-secret access, centralised rotation and an audit trail that files cannot offer. encrypted files no runtime dependency changes reviewed in the same pull request nothing to operate or pay for good while the team is small a secret manager access granted per secret rotation without a commit an audit trail of every read necessary once either count grows
The switch is driven by people and services, not by how sensitive the values are.

Frequently asked questions

Is committing an encrypted secrets file actually safe?

It is safe against repository access and not against key access. The ciphertext is only as protected as the private keys that can decrypt it, so key distribution and revocation become the thing you have to get right — which is a smaller problem than distributing plaintext, but it is not nothing. The property that makes it workable is that recipients are public keys: nobody shares a private key, adding a person is a re-encryption, and the file’s own metadata records who can read it.

Why encrypt values rather than the whole file?

Because a whole-file cipher produces an unreadable diff, and an unreadable diff means changes are reviewed somewhere else or not at all. Per-value encryption keeps key names and structure in plaintext, so a review shows which settings changed even though the values stay opaque — exactly the information a reviewer needs to judge a change. It also makes conflicts resolvable, which a binary blob never is.

How does revocation work?

Remove the recipient key from .sops.yaml, run sops updatekeys, and commit the re-encrypted file. That protects everything from this commit forward and nothing before it: anyone removed keeps the ability to decrypt every historical version they already cloned. Revocation must therefore be accompanied by rotating the values themselves, which is the same rule that applies to any credential someone has held — the encryption changes nothing about that requirement.

Is this better than a secret manager?

It is simpler and has no runtime dependency, which suits small teams and self-contained deployments where the operational cost of a manager is hard to justify. A manager gives centralised rotation, access granted per secret rather than per file, and an audit trail of every read — three things files fundamentally cannot provide. The decision is driven by the number of people and services rather than by how sensitive the values are, and it is worth revisiting rather than treating as permanent.

How does the application read the values?

It should not read the encrypted file at all. Decrypt at deployment time into the process environment or a mounted file, so the running application sees ordinary configuration and needs neither a decryption key nor the decryption tool. Putting decryption in the application means shipping a key that can read every value in the file, which is a more valuable credential than any single secret it protects and reintroduces the bootstrapping problem the encryption was meant to solve.

Key takeaways

Encrypted files earn their place when a team is small enough that per-file access is acceptable and a secret manager is more operational weight than the situation justifies. The two decisions that make the approach workable are encrypting per value so diffs remain reviewable, and using recipient public keys so nobody shares a private key and the file itself records who can read it.

Keep decryption at the deployment boundary: the pipeline holds a key, exports values into the process environment, and the application runs with no key and no tooling. And be clear-eyed about the limits — history stays decryptable by anyone who ever had access, so removing a recipient must be paired with rotating values; access is per file, so granularity is coarse; and there is no audit trail of reads. When any of those becomes the binding constraint, that is the signal to move to a manager rather than to work around it. The surrounding file-handling practices are on the .env file management topic page.