Managing secrets with SOPS: encrypted .env files you can actually commit

Published 21 July 2026 · Agata Raap

The worst version of this story is short. You add a .env to a repo that has a slightly wrong .gitignore, you run git add . at midnight, and you push. Bots scrape public pushes within minutes, and the fix isn’t git rm — the secret is in the history, on every clone and every fork, forever. The only real fix is rotating every credential in that file.

The quieter version is worse because nobody notices it. Plaintext .env files sit in a dozen project folders on your laptop, get swept into a backup, get copied to a new machine, get pasted into a DM when a collaborator needs them. No breach, no alert — just credentials slowly spreading.

SOPS fixes a specific slice of this: it makes the file safe to commit. Here’s how it works, how to wire it into GitHub and Gitea, and — honestly — the part of the problem it doesn’t touch.

What SOPS actually does

SOPS encrypts the values in a config file and leaves the keys alone. That single design decision is why it’s pleasant to live with: your diffs still say which variable changed.

service: billing
region: eu-central-1
password: ENC[AES256_GCM,data:Kq/D/+rSig==,iv:zcwqDerGpizc5B8WXi6ThsmGsHM4y985CawnLHXibNI=,tag:++yemj/Ivt2VqZVexUhTPg==,type:str]
STRIPE_KEY: ENC[AES256_GCM,data:U07a/b29FCDEDVw=,iv:EOeagTYwlZsQXwhFy9GNq9CALSX/IPKSSjXI+GQaGPI=,tag:xFFHY6D8D8XHpJ3F3k2NyA==,type:str]

service and region were never secret, so they stayed readable. The two that mattered are ciphertext.

Underneath is envelope encryption. SOPS generates one random data key, encrypts each value with it using AES-256-GCM, then wraps that data key once per recipient — an age public key, a PGP fingerprint, a cloud KMS ARN, whatever you listed. It also writes a MAC over the whole file, so someone quietly swapping one encrypted value for another gets caught on decrypt.

How SOPS envelope encryption wraps one data key for many recipients Flow diagram. Your plaintext .env goes to a randomly generated data key, which encrypts each value with AES-256-GCM, producing an encrypted .env that is safe to commit. Below, the data key is wrapped separately for three kinds of recipient: an age public key, a PGP fingerprint, and a cloud KMS. Any one of them can unwrap it, and none of them live in the repository. your .env plaintext random data key AES-256-GCM per value encrypted .env keys readable, values ENC[…] age public key age1z009…qk PGP fingerprint 6398…6483 cloud KMS AWS · GCP · Azure any one of these unwraps the data key — none of them live in the repo
One data key, wrapped once per recipient. Adding a teammate re-wraps the key; it never re-encrypts your secrets.

SOPS came out of Mozilla and now lives under the CNCF as getsops/sops. It handles YAML, JSON, .env, INI and binary files. Everything below was run against sops 3.13.2.

The age setup, in about five minutes

age is the modern option and the one I’d start with. A keypair is one command and the public key is one line — no keyring, no agent, no expiry dates.

brew install sops age          # or your package manager of choice
age-keygen -o key.txt

That prints your public key and writes the private key to key.txt. The public key is the thing you share; key.txt never goes anywhere near the repo.

Now tell SOPS which files to encrypt and for whom, in a .sops.yaml at the repo root:

creation_rules:
  - path_regex: \.env$
    age: age1z009kj3ymf596e4p5jt50gq0mx9rs7g4dyd3e5uumhavxp2qvafqmsskqk

Encrypt in place:

sops encrypt -i .env

Your .env now looks like this — same variable names, ciphertext values, plus a block of SOPS metadata at the bottom recording which recipients can open it:

DATABASE_URL=ENC[AES256_GCM,data:sMePUpxoIO48AR6iy1XNAzN8mx4mJZa/63kLI2HcvtuCbHSMQy7t0Rn/Mdc=,iv:Yne3pJYFvoaArKUNCSbgKjsQ586JbQlkir7DCmbXfAk=,tag:+/PNfE9wu7pbB/oSEAjHvA==,type:str]
STRIPE_SECRET_KEY=ENC[AES256_GCM,data:NE1xVHs4+pGyAoZDO5Xm75uHAA/+,iv:7QXQl+iVjxGHg8G/wCq7LAVLpiSfLY1thXzsZ5z3QKg=,tag:VYizcMP7zcl6EfXOq+/Awg==,type:str]
APP_ENV=ENC[AES256_GCM,data:r0nOEIC2GsO4Lw==,iv:Nxoerz3l98n65B3L7enX5RhcFd3e6pkec1CglBo57T0=,tag:Rd7FCz78vd9sY9t6bJUc5w==,type:str]
sops_age__list_0__map_recipient=age1z009kj3ymf596e4p5jt50gq0mx9rs7g4dyd3e5uumhavxp2qvafqmsskqk
sops_mac=ENC[AES256_GCM,data:UwHqFDWOEWvMPE+9lvb0w1JqdvFz…,type:str]
sops_version=3.13.2

That file is safe to commit. To get your secrets back, point SOPS at the private key:

export SOPS_AGE_KEY_FILE=~/.config/sops/age/key.txt

sops decrypt .env                     # print plaintext to stdout
sops .env                             # open decrypted in $EDITOR, re-encrypt on save
sops exec-env .env 'npm start'        # run a command with the secrets in its env
sops exec-file .env './deploy.sh {}'  # or as a temporary decrypted file

sops exec-env is the one to build a habit around. The secrets exist in the environment of that one child process and nowhere on disk — no decrypt-to-.env, no cleanup step you’ll forget.

Mind the quoting: exec-env and exec-file take the command as a single argument, not as a -- separated list. sops exec-env .env ./deploy.sh is fine because it’s one word; anything with a space needs quotes, and sops exec-env .env -- npm start just errors with missing file to decrypt.

If you’re encrypting YAML where most of the file is ordinary config, encrypted_regex keeps the boring parts readable:

creation_rules:
  - path_regex: secrets\.yaml$
    encrypted_regex: "^(password|token|key|.*_KEY)$"
    age: age1z009kj3ymf596e4p5jt50gq0mx9rs7g4dyd3e5uumhavxp2qvafqmsskqk

That’s what produced the readable service/region example earlier.

The PGP path, and when it’s still the right one

SOPS has supported PGP since the beginning, and the config looks the same — a fingerprint instead of an age recipient:

creation_rules:
  - path_regex: \.env$
    pgp: 6398762E755180E760088B7BDCDDA785CE556483

Everything else is identical. sops encrypt -i .env shells out to gpg to wrap the data key, and sops decrypt asks gpg to unwrap it — which means your usual passphrase or smartcard prompt appears at decrypt time.

Get your fingerprint with:

gpg --list-keys --fingerprint your@email.com

One trap worth naming, because the error message is unhelpful. Your PGP key must have an encryption subkey. A modern signing-only Ed25519 key fails with:

Could not generate data key: failed to encrypt new data key with master key …
gpg: skipped: Unusable public key

Check with gpg --list-keys --with-colons <fp> — you want a sub line carrying the e capability. If it’s missing, add one:

gpg --quick-add-key <fingerprint> cv25519 encr never

So when should you pick PGP over age? Three honest cases:

  1. Your team already has GPG keys. Reusing published keys and existing fingerprint verification beats distributing a new kind of key over Slack.
  2. You want the private key on hardware. This is the one where PGP genuinely wins: a key on a YubiKey or smartcard can’t be copied off a stolen laptop, and gpg handles that transparently. age has no equivalent story out of the box.
  3. Your org tooling is already OpenPGP. Signed commits, signed releases, encrypted mail — one key type is less to explain.

Otherwise, age. A single-line public key and no keyring to debug is worth a lot on a Tuesday.

If you’re generating a PGP key for the first time and want to see what one looks like before committing to gpg, I keep a browser-based PGP key generator on this site — everything runs locally, nothing is uploaded. Use it for learning, low-stakes keys, and inspecting keys you’ve been sent. For the key that unlocks production credentials, generate it with gpg --full-generate-key or directly on hardware. A key generated in a browser tab is fine crypto in an environment you control less than your own machine, and it’s not where a production root of trust should be born.

Living with it in git

The one ergonomic gap: git diff on an encrypted file is noise, because every ciphertext blob changes when any value does. SOPS ships a fix. Add to .gitattributes:

.env diff=sopsdiffer
secrets.yaml diff=sopsdiffer

And register the driver once per machine:

git config diff.sopsdiffer.textconv "sops decrypt"

Now diffs are readable again:

 service: billing
 region: eu-central-1
-password: hunter2
+password: newpassword
 STRIPE_KEY: sk_live_abc

Note what that implies: your terminal is now printing plaintext secrets during git diff. That’s the trade, and it’s usually the right one — but don’t run it on a shared screen.

Two more commands worth knowing:

sops updatekeys .env    # re-wrap the data key after editing .sops.yaml recipients
sops rotate -i .env     # generate a brand new data key and re-encrypt every value

updatekeys is what you run after adding or removing a teammate. It reads the current .sops.yaml, shows you the diff to the recipient list, and re-wraps. rotate is heavier: a fresh data key, which is what you want if you suspect the old one leaked.

Finally, the obvious one. .gitignore still matters:

key.txt
*.agekey
.env.local

CI: GitHub Actions

The job needs three things: the sops binary, the private key from repository secrets, and a command wrapped in sops exec-env.

I install SOPS by curling the release binary rather than using a marketplace action. It’s one line, it has no supply-chain surface beyond the SOPS release itself, and — the actual point — it makes this workflow portable to any runner.

Put your private age key (the whole key.txt contents, or just the AGE-SECRET-KEY-... line) into Settings → Secrets and variables → Actions as SOPS_AGE_KEY.

name: deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install sops
        run: |
          curl -sSfL -o /usr/local/bin/sops \
            https://github.com/getsops/sops/releases/download/v3.13.2/sops-v3.13.2.linux.amd64
          chmod +x /usr/local/bin/sops

      - name: Deploy with decrypted secrets
        env:
          SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
        run: sops exec-env .env ./deploy.sh

SOPS_AGE_KEY takes the key material directly, so there’s no temp file to write or clean up. The secrets exist only inside the deploy.sh process.

For PGP instead of age, swap the last step to import the private key first:

      - name: Deploy with decrypted secrets
        env:
          GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
        run: |
          echo "$GPG_PRIVATE_KEY" | gpg --batch --import
          sops exec-env .env ./deploy.sh

That means the exported private key sits in a CI secret, which is a meaningfully bigger blast radius than an age key doing one job. If you go this route, use a dedicated CI-only key that’s a recipient on nothing else, so revoking it costs you one updatekeys run.

CI: Gitea Actions

Gitea Actions runs GitHub-compatible workflow YAML on act_runner, so the file above works essentially as-is. Three differences worth knowing on self-hosted:

Secrets live elsewhere. Repository Settings → Actions → Secrets (also available at org and user level). Referenced identically: ${{ secrets.SOPS_AGE_KEY }}.

uses: resolves against a configurable host. Gitea’s [actions] DEFAULT_ACTIONS_URL decides where a non-qualified action like actions/checkout@v4 is fetched from — github (the default) pulls from github.com, self pulls from your own instance and never touches the internet. If your runner is air-gapped or you’ve set self, either mirror actions/checkout locally or fully qualify the URL. This is exactly why the curl install matters: a run: step has no such resolution problem.

Your runner image may be leaner. The default act_runner images are smaller than GitHub’s ubuntu-latest. If curl or gpg isn’t present, install it in the same step, and pin runs-on to a label your runner actually registered.

jobs:
  deploy:
    runs-on: ubuntu-latest      # must match a label your act_runner registered
    steps:
      - uses: actions/checkout@v4

      - name: Install sops
        run: |
          apt-get update && apt-get install -y curl    # leaner images may need this
          curl -sSfL -o /usr/local/bin/sops \
            https://github.com/getsops/sops/releases/download/v3.13.2/sops-v3.13.2.linux.amd64
          chmod +x /usr/local/bin/sops

      - name: Deploy with decrypted secrets
        env:
          SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
        run: sops exec-env .env ./deploy.sh

The encrypted file itself doesn’t care. One .env in the repo, one recipient list, and both CI systems decrypt it the same way.

One SOPS-encrypted file flowing through GitHub Actions and Gitea Actions Pipeline diagram. Your Mac encrypts with sops encrypt, the git repo holds ciphertext only, and the flow splits to GitHub Actions and Gitea Actions, both running sops exec-env, then merges into deploy with real environment variables. The private key lives only in CI secrets, never in the repo. one encrypted file · two CI systems · same YAML your Mac sops encrypt -i git repo ciphertext only GitHub Actions sops exec-env Gitea Actions sops exec-env deploy real env vars the private key lives only in CI secrets — never in the repo
The same encrypted file serves both pipelines. Only the location of the CI secret changes.

The offboarding caveat nobody puts in the README

Someone leaves the team. You remove their fingerprint from .sops.yaml, run sops updatekeys, commit, and feel responsible.

You have not revoked anything. You’ve changed who can decrypt the file going forward. That person still has:

  • every previous commit, including the version of the file their key could open, and
  • the old data key, which they may have decrypted at any point while they had access.

Every credential in that file is still live and still known to them. So the real offboarding sequence is:

  1. Rotate the actual credentials at the source — new database password, new Stripe key, new API token.
  2. Put the new values in the file.
  3. Remove the recipient from .sops.yaml and run sops updatekeys.
  4. sops rotate -i for a fresh data key.

Step 1 is the one that matters. Steps 3 and 4 are hygiene. This is true of every git-based secrets tool, not just SOPS — the moment a secret enters history, access control becomes a rotation problem.

The rest of the free landscape

SOPS isn’t the only answer, and sometimes it’s too much answer:

  • age alone. No structure, no partial encryption, no per-value diffs — just age -e on a whole file. Perfectly fine for one blob you rarely touch.
  • git-crypt. Transparent encryption via git filters: files look plaintext in your working tree and are encrypted in the repo. Lovely when it works, awkward to unwind, and effectively unmaintained.
  • pass. The Unix password manager — GPG files in a git repo, great for personal secrets, not built for CI injection.
  • dotenvx. Encrypts .env files with a public/private keypair and decrypts at runtime. Narrower than SOPS and squarely aimed at the .env case.
  • Sealed Secrets / External Secrets. If your target is Kubernetes, these are the idiomatic answers. SOPS is still common in GitOps repos alongside them.
  • Doppler, Infisical, 1Password Secrets Automation, Vault. Real secret managers with rotation, audit logs and dynamic credentials. The right answer at team scale; overkill for two apps and a VPS.

If you’re solo, with one app and one .env on one machine: you may not need any of this. Reach for SOPS when a secret has to travel — to a second machine, to a teammate, to a pipeline.

Where the friction actually lives

Here’s the thing SOPS won’t help with, and I say this as someone who uses it.

SOPS secures the file in the repo and in CI. It does nothing about the twenty minutes a day you spend around that file:

  • Which .env is currently live in this project? The one in the folder, or the one you edited last Tuesday?
  • What drifted between staging and production? You find out when production breaks.
  • Where are the other eleven projects’ .env files, and are they all still gitignored?
  • The plaintext file that has to exist on your machine while you work, because you can’t type ENC[AES256_GCM,...] into a debugger.
  • And the classic: git add . beating a .gitignore you thought was right.

Encryption is a pipeline problem and it’s solved. The daily handling of secrets is a desk problem, and sops edit in vim is not a great desk.

Where Envly comes in

Envly is a native macOS app for exactly that desk layer, built solo by Dmytro Virych. It’s a structured editor and manager for .env files across all your projects:

  • Environments as tabs. Dev, staging, prod side by side, switched instantly instead of renaming files.
  • One window, every project. All your variables across every repo, rather than opening twelve folders.
  • Git safety. It validates .gitignore and warns before a .env gets committed — a guard rail on the specific mistake that starts this article.
  • Side-by-side compare. Diff two environments and see what drifted.
  • Masked by default, with a ⌘⇧H panic-hide for when someone walks up mid-screenshare.
  • Imports from Vercel, Netlify, Railway and Fly.io, so the cloud copy and the local copy stop disagreeing.

The security posture is the part I actually care about: it’s local-first. Your .env files and secrets stay on your Mac, secrets go in the macOS Keychain, there’s no account and no telemetry, and the optional encrypted backup is AES-256 with keys Envly doesn’t hold. Cloud imports go straight from the provider to your machine. It’s a one-time purchase — no subscription, no seat count.

And the honest limits, because a tool that gets recommended without them is a tool you’ll resent later:

  • macOS only, and recent macOS at that. No Linux, no Windows, no CI.
  • It is not a SOPS replacement and does not integrate with SOPS. It won’t encrypt a file for your repo, won’t distribute keys to a team, and won’t decrypt anything in a pipeline.
  • It’s early. Shipped in June 2026, built by one person, no revenue yet. Buy it because the workflow fits, not because a crowd validated it.

So the stack that actually makes sense is both: SOPS for the file that travels, Envly for the file on your desk. They don’t know about each other, and they don’t need to.

Ship or Die, and finding tools this small

I found Envly on Ship or Die, which is worth a paragraph of its own if you build or buy indie software.

It’s a discovery platform for products makers have actually shipped — around 292 of them across 13 categories, Dev Tools among them, sorted by community upvotes. The twist is the structure around it: makers run missions (Ship, then Market) and get public, blunt reviews on the way through. Envly’s ship page shows the shipped date, the commit count, the current revenue, and a rejection review telling him his homepage headline was too vague for cold traffic. In public. Under his name.

That transparency is the reason to browse it. You’re seeing tools before they’re famous, with the traction and the criticism attached — which is a much more useful signal than a polished landing page. It’s not a vendor shortlist, and nothing on it is battle-tested by a thousand teams. That’s the trade: earlier, more honest, riskier.

For a $19-ish one-time Mac app that saves you a recurring annoyance, that trade is easy. For your primary secrets pipeline, it isn’t — which is why the pipeline half of this article is a CNCF project and the desk half is an indie app.

What I’d actually run

  • Solo, one app: age + SOPS, one .env, sops exec-env locally and in CI. Twenty minutes to set up, then invisible.
  • Small team: SOPS with one age recipient per person in .sops.yaml. PGP instead if hardware keys are in play. A dedicated CI-only key that’s a recipient on nothing else.
  • Kubernetes: SOPS-encrypted manifests in git, plus External Secrets for anything that needs rotation.
  • On the Mac, either way: something that makes the daily .env shuffle boring. Envly if you want that bought and native.

And regardless of the tooling: the day someone leaves, rotate the credentials. Not the recipients — the credentials.

Running SOPS somewhere unusual, or found a sharper Gitea setup than mine? Tell me — I’m on X and Bluesky, kettle’s on.

Frequently asked questions

What is SOPS?
SOPS (Secrets OPerationS) is an open-source command-line tool that encrypts the values inside a config file while leaving the keys readable. It handles YAML, JSON, .env, INI and binary files, and wraps its encryption key with age, PGP, AWS/GCP/Azure KMS, or HashiCorp Vault. It started at Mozilla and is now a CNCF project maintained at github.com/getsops/sops.
Is it actually safe to commit a SOPS-encrypted file to a public repo?
The values are encrypted with AES-256-GCM and the file carries a MAC that detects tampering, so the ciphertext itself is not the weak point. What you are exposing is the shape of your config: variable names, how many secrets you have, and which recipients can decrypt. That is usually acceptable, but treat it as public metadata and never let the private age or PGP key near the repo.
Should I use age or PGP with SOPS?
Use age unless you have a reason not to. A keypair is one command, the public key is one line, and there is no keyring, no agent, and no expiry to babysit. Choose PGP when your team already has GPG keys, when you need the private key held on a YubiKey or smartcard, or when other tooling in your org is already OpenPGP-based.
Does SOPS work with self-hosted Gitea, not just GitHub?
Yes, and the workflow file is nearly identical. Gitea Actions runs GitHub-Actions-compatible YAML on act_runner, and repository secrets live under Settings → Actions → Secrets. Install the sops binary from its GitHub release with curl instead of using a marketplace action and the same job runs unchanged on both.
What happens when someone leaves the team?
Remove their recipient from .sops.yaml and run sops updatekeys, which re-wraps the data key for the remaining recipients. That controls future access only. They still have every old commit and the old data key, so every credential they could decrypt must be rotated at the source — new database password, new API key. Removing a recipient is not revocation.
Is Envly a replacement for SOPS?
No, and it does not integrate with SOPS. SOPS is the repo-and-CI layer: encrypted files in git, decrypted by a pipeline. Envly is a macOS app for the desk layer: editing .env files, switching between dev/staging/prod, spotting drift, and keeping plaintext out of a commit. They solve adjacent problems and you can reasonably run both.