Storing credentials in the repo: SOPS + age

On one of my projects there was a question I kept hitting during deployment: where should I keep the secret values? At first I stored them as GitHub secrets. It worked fine, but over time reading them back became a hassle.

My first approach was to put the whole .env file into a single GitHub secret. It was easy, but this time I couldn't see which secret was actually set: everything was one block. If instead I made each value a separate secret, then as the number of secrets grew I had to set dozens of values one by one, a management headache of its own.

I could also have kept a secret file directly on the server. But then I'd have to connect to the server for every change. I clarified what I wanted: to change a secret and deploy it automatically, without touching the CI process at all.

After some research I came across a method the Mozilla team put out: SOPS. Combined with age, I could keep the secrets encrypted inside the repo itself. Wherever the code is, the secret is right there too, but not as plaintext. In this post I explain how I did it, where it works well, and where you need to be careful.

All the examples are in a working demo repo: https://github.com/erdyasan/sops-age-example

The idea

There are two pieces:

  • age: a simple, modern encryption tool. We generate a public/private key pair. What is encrypted with the public key can only be opened with the private key.
  • SOPS (Secrets OPerationS): it encrypts only the values of a YAML/JSON/env file, leaving the keys (the field names) in the clear. So the file still stays in a readable shape, but the values are encrypted. When we take a diff we can see "which field changed", but not the value itself.

age works with asymmetric keys: we generate a public/private key pair. We encrypt the data with the public key, and that data can only be opened with the matching private key, no other key. That's where the asymmetry is: we can hand the public key to everyone, anyone can prepare encrypted data for us with it, but the only party who can open it is the owner of the private key. So being able to encrypt is a capability open to everyone, while decrypting is in a single hand. (GPG basically works with the same asymmetric logic: encrypt with public, open with private.)

age's difference from GPG is in weight. GPG (GNU Privacy Guard) is powerful but heavy: a trust model called the web-of-trust, keyring management, keyservers, key expiry dates, subkeys, agent processes running in the background, dozens of subcommands. There's quite a bit of concept to learn before you even encrypt a file. age is focused on a single job: no config file, short keys, one-line commands. For everyday secret encryption you rarely need all of GPG's machinery; age does the same job far more plainly.

Putting these together, we get this: the secret file can be committed to git. Because the values inside it are encrypted with age. Someone who clones it without the private key sees only a useless piece of ciphertext. Who changed what and when stays in the git history. On a new machine everything comes with a single git clone (plus the age key).

Let me say this upfront: this approach is a perfect fit for small and mid-sized projects. But it has a limit. Because the public keys are out in the open in .sops.yaml, anyone with write access to the repo, even if they can't read the values of the secrets, can write new values over them (I explain this below in the "Being able to encrypt is not being able to read" section). In a small and trusted team this is an acceptable risk. As the team and the blast radius grow, you need extra protections on the git side (branch protection, mandatory review, rules specific to the secret path) or, past a certain point, a centralized secret manager.

Setup

On macOS both are in brew:

brew install sops age

1. Generate an age key

age-keygen -o key.txt

Its output is a file like this:

# created: 2026-08-12T12:02:42+03:00
# public key: age1cpyuq5ss7w85phtrkhsxntek7k4ypx3gr8qa8c4vd5y0k772wqssrd4w4q
AGE-SECRET-KEY-1DC8QTSCYAXRADPLAQA853TMCYDYZXSJ9JW8MFWRYLZFM6J4E67CS3K6ZW5

The top age1... line is the public key (recipient). The bottom AGE-SECRET-KEY-... is the private key. We encrypt with the public key, we open with the private key. The private key stays secret, and we can share the public key freely. In practice the private key lives on each machine under ~/.config/sops/age/keys.txt and is never committed.

2. File layout: secrets/ and .env.enc

In my own setup I keep the secrets in a secrets/ directory, separated per environment. There are three files per environment:

secrets/
  app.local.env.enc         # encrypted, committed
  app.local.env.example     # keys only, committed (so which secrets exist is clear)
  app.local.env             # plaintext working file, GITIGNORE (local, decrypted copy)

The logic is this: .enc is the encrypted, committed file; .example is the template that carries only the key names, which a newcomer looks at to see "which values do I need to fill in"; and the plain .env is the decrypted copy created while editing, which stays in .gitignore and is never committed.

Another benefit of the .enc extension is on the CI side: the pipeline can find all the encrypted files at once with a pattern like secrets/*.env.enc and decrypt them in bulk. The * here is a wildcard character; it means "bring me all the file names that match this pattern". Matching file names by a pattern like this is called globbing (everything in secrets/ that ends with .env.enc). The extension is also a signal that says "this file is encrypted, it will be decrypted at deploy".

One point: the .enc extension doesn't tell SOPS the format. The files are in dotenv (KEY=value) format, but since SOPS can't infer that from the extension, you have to pass the format by hand on every command: --input-type dotenv --output-type dotenv. The little manager below always adds this automatically.

3. Who it's encrypted for: .sops.yaml

.sops.yaml tells SOPS which file to encrypt for whom. We define different recipients per environment. First we write the keys once as anchors and reference them below:

# .sops.yaml
keys:
  - &local          age1cpyuq5ss...   # my machine
  - &server_staging age12clzmdhl...   # staging server
  - &server_prod    age1g5uwzup8...   # prod server

creation_rules:
  # local: only my machine can open
  - path_regex: secrets[/\\]app\.local\.env(\.enc)?$
    key_groups:
      - age:
          - *local

  # staging: staging server + me
  - path_regex: secrets[/\\]app\.staging\.env(\.enc)?$
    key_groups:
      - age:
          - *server_staging
          - *local

  # prod: prod server + me
  - path_regex: secrets[/\\]app\.prod\.env(\.enc)?$
    key_groups:
      - age:
          - *server_prod
          - *local

The recipient model here matters: each environment is encrypted with that environment's server public key + my local public key. The result:

  • The server opens it at deploy time with its own private key.
  • I open and edit it locally with my own private key.
  • Only the prod server (and me) can open the prod file; the staging server or another developer can't decrypt the prod secret, because their key isn't in that rule.

All the age1... lines we write in .sops.yaml are public keys. No private key goes in here.

For a while I set up sops+age a bit by rote; I didn't fully know how this structure works. Back then I hadn't read the whole documentation, and honestly I had no experience either. Later I realized that key_groups here is actually a term that needs care: the moment we say "encrypt for local too" in the prod rule, the prod secret becomes decryptable on local as well. When SOPS encrypts a value it first generates a random data key, encrypts the actual data with it, then wraps that data key separately for each recipient's public key in the group. So the data key in the prod file can be opened both with the prod server's private key and with my local private key; either one is enough. When I saw that I could decrypt the prod secrets locally with sops -d, honestly I was quite surprised; my first thought was "is there a vulnerability here?". There isn't: this is exactly the result of my adding &local to every environment's key group, the design itself. The same holds for staging/qa: as long as &local is a recipient in the rule, I can open that environment locally.

But you need to see the trade-off it brings: someone who gets hold of the local private key can decrypt the secrets of every environment (prod included) where that key is a recipient. So it matters to protect the keys.txt file and to choose deliberately who is a recipient for which environment. In a small and trusted team this is an acceptable convenience. If we want to narrow access, we take local out of the prod group and encrypt prod with only the server key; then no one can open the prod secret locally.

(The secrets[/\\] in path_regex covers both the / and \ separator: a Windows portability detail, which I touch on in the encoding section below.)

A terminology note too: key_groups here is just a container that wraps recipients within a single group. For environment separation, a single group and the recipient list we write into it are enough.

Full file: https://github.com/erdyasan/sops-age-example/blob/811a70873d040b2be83f68f5cfaaf972cef7deeb/.sops.yaml

4. Write and encrypt the secret

First we prepare the plain working file (dotenv):

# secrets/app.local.env  (gitignored, plaintext)
HELLO=world
FAKE_API_KEY=demo_local_sk_0000
DB_PASSWORD=local-demo-password

Then we encrypt it (passing the dotenv format by hand):

sops -e --input-type dotenv --output-type dotenv secrets/app.local.env > secrets/app.local.env.enc

The .enc file now looks like this:

HELLO=ENC[AES256_GCM,data:wPU91TF7...,iv:bbS2qHGd...,tag:ALssHW9H...,type:str]
FAKE_API_KEY=ENC[AES256_GCM,data:...,type:str]
DB_PASSWORD=ENC[AES256_GCM,data:...,type:str]
sops_age__list_0__map_recipient=age1cpyuq5ss...
sops_mac=ENC[AES256_GCM,...]
sops_version=3.13.2

The field names (HELLO, FAKE_API_KEY, DB_PASSWORD) are in the clear, their values encrypted. That's exactly why the diffs stay meaningful. The sops_... lines at the bottom are SOPS's metadata: which recipient it was encrypted for and the integrity check (mac). If we used YAML this would be a sops: block; since dotenv holds plain key=value, the metadata is also spread across plain keys as sops_....

We can commit this .enc file without worry. The plain app.local.env, on the other hand, git doesn't see.

5. Decrypting at runtime

The encrypted file is nice, but how will the application read it? The cleanest way is to never write the plaintext to disk at all: decrypt the file in memory and inject the values into the process as environment variables.

For this I put a small manager in the repo (secrets.mjs), a simplified version of the more thorough one in my real project. It doesn't roll its own crypto, it just calls the sops CLI. The run command decrypts the environment and runs the given command with those variables:

node secrets.mjs run local -- node index.js

The application side just reads process.env, it doesn't even know about SOPS:

// index.js
const KEYS = ["HELLO", "FAKE_API_KEY", "DB_PASSWORD"];

console.log("Secrets decrypted by SOPS, read from process.env:\n");
for (const key of KEYS) {
  console.log(`  ${key} = ${process.env[key] ?? "(missing)"}`);
}

Output:

$ npm start

Secrets decrypted by SOPS, read from process.env:

  HELLO = world (local)
  FAKE_API_KEY = demo_local_sk_0000_not_a_real_key
  DB_PASSWORD = local-demo-password

Why my own little script instead of sops exec-env? Because exec-env doesn't accept the --input-type flag; since it can't infer the format from the extension, it doesn't work with extensionless/.enc dotenv files. On the Node side, decrypting with sops -d --input-type dotenv ... and parsing the output both solves this problem and keeps the shell from mangling values that contain spaces (like world (local)). The decrypted content is never written to disk, it only stays in memory as long as the process is alive.

The full manager: https://github.com/erdyasan/sops-age-example/blob/811a70873d040b2be83f68f5cfaaf972cef7deeb/secrets.mjs

Team: not sharing a key, but adding a recipient

At the start we generated a single key pair, because we were a single person. So what happens when it becomes a team, do we hand everyone the same private key? No. The trick is exactly here.

Each person on the team runs age-keygen on their own machine and generates a pair of their own: their own private key (secret, never leaves their machine) and their own public key (shareable). So there isn't a single public key out there; every member has a different pair. All the age1... lines in .sops.yaml are public keys.

Here's how SOPS works when it encrypts a file: it generates a random data key, encrypts the actual values with it, then wraps this data key separately with each recipient's public key in that rule. So anyone (or any server) in the list can open the data key with their own private key and decrypt the file. Since everyone has a separate private key, no one needs to know anyone else's key.

Why this way? Because if we handed a single private key to everyone: when one of them leaked it, they'd all burn at once, you couldn't tell who opened what, and to change the key we'd have to redistribute it to everyone. When they're separate, only the public keys are shared, and the private keys never leave the machine.

When someone new joins, we add their public key to the relevant rule, then, to wrap the file for them too:

sops updatekeys secrets/app.local.env.enc

When someone leaves: we remove their public key from .sops.yaml, run sops updatekeys, and also rotate the actual secret values. (The person who left can still open the encrypted file in an old commit, because that version stays in the history. That's why changing the value is a must.)

CI (GitHub Actions)

In the CI environment we provide that environment's private key as a repo secret:

- run: node secrets.mjs run prod -- node index.js
  env:
    SOPS_AGE_KEY: ${{ secrets.PROD_AGE_KEY }}

SOPS_AGE_KEY takes the key's content directly; SOPS_AGE_KEY_FILE instead wants a file path. That's exactly what I wanted: without touching CI at all, I update a secret by committing the encrypted file, and since the prod key lives only in the prod CI's secret, environment isolation isn't broken.

Being able to encrypt is not being able to read: repo access

At some point I realized this: the public keys are out in the open in .sops.yaml. So can someone with write access to the repo change the secrets? Yes, they can. Encrypting needs only the public key, no private key. So that person can't read the existing values (reading requires the private key), but they can write new values over them, re-encrypt, and commit. Since at deploy the server decrypts and uses the committed value, someone with write access can set DB_PASSWORD to a different value or point an API address to the wrong place.

So SOPS gives us confidentiality, not integrity. It protects against reading the values, not against changing them. In a small project this is usually enough; as scale grows you need to close this gap on the git side. If you decide to use sops in a team, my recommendation is these few measures:

  • Branch protection + mandatory review. Close off direct push to main, take every change via PR + approval. That way no one can write over a secret and send it to deploy on their own.

  • Reviewer specific to the secret path (CODEOWNERS). Make certain people's approval mandatory on every PR that touches secrets/ and .sops.yaml.

  • Automatic warning on the PR. Drop an automatic warning comment on a PR when it opens touching the secret files. That way the change doesn't slip past the reviewer's eye easily. If we want, we can tie this to a required status check to block merges without approval, and go further with commit signing.

Note: the mac field in SOPS catches manual (non-sops) tampering with the file, but someone who properly re-encrypts with sops produces a valid mac. So mac prevents a blind hex edit, not a re-encryption by an authorized person.

Something to watch out for: metadata leaks

SOPS encrypts only the values, the field names stay in the clear. It's this way so that diffs stay readable, but there's a cost: someone looking at the file sees which secrets are being talked about. DB_PASSWORD, STRIPE_SECRET_KEY, ADMIN_TOKEN... the values are secret but the names give away the shape of the infrastructure. If the field name itself is sensitive, you either encrypt the whole file or neutralize the name.

The line ending (CRLF/LF) and encoding trap

I struggled quite a bit with this on the deploy side, this was the most infuriating part. I created the .enc file on one machine (mac, LF line endings), then opened the same repo on Windows. The values were correct but decryption just wouldn't happen. The reason: SOPS computes a mac (integrity check) over the content for each file. When the bytes change even a little, the mac doesn't match and the file is considered corrupted. On Windows there are a few separate things that change these bytes.

1. Git autocrlf. On Windows, git can convert LFs to CRLF during checkout. The .enc's bytes change, the mac blows up. The fix is to turn off the conversion for these files with .gitattributes:

# .gitattributes
secrets/*.enc -text

-text tells git "don't treat these files as text, don't touch the line endings".

2. PowerShell > redirection writes UTF-16. This one took time to find. On Windows, when you do sops -e ... > file.enc, PowerShell's > operator writes the output as UTF-16 LE, not UTF-8, and adds a BOM on top. The file is born corrupted from the start. A BOM inserted by editors gives the same result.

That's why I wrote a small PowerShell script for Windows (encrypt.ps1). Roughly: it first normalizes the plaintext to LF-only + UTF-8 no BOM, then takes the sops output into a variable rather than via > and writes it with the .NET File API (again LF + UTF-8 no BOM, a single trailing newline), and finally looks at the file's first bytes and throws if there's a UTF-16/UTF-8 BOM or CRLF. So it makes producing a corrupted .enc impossible from the start. All of it is in the repo: https://github.com/erdyasan/sops-age-example/blob/811a70873d040b2be83f68f5cfaaf972cef7deeb/encrypt.ps1

3. Path separator. A small but annoying other Windows difference is the path_regex in .sops.yaml. sops passes the file path to the regex as the OS gives it; on Windows the separator is \, so a pattern written only with / doesn't match and the file's "which rule it belongs to" can't be found. The reason I wrote path_regex as secrets[/\\]... in the .sops.yaml example above is this: the bracket covers both separators.

In short: the bytes of the .enc files must be preserved exactly. Line endings, encoding, BOM, anything that gets in between breaks the mac. You shouldn't open and save the .enc by hand in an editor; we always edit through the tools (sops, secrets.mjs, encrypt.ps1). The encoding of the files matters.

When SOPS, when the alternatives

  • git-crypt: encrypts the whole file, works transparently (opens on checkout). But since it's file-based rather than value-based, the diffs are unreadable. An advantage if we don't want field-name leakage.
  • Vault / cloud secret manager (Azure Key Vault, AWS Secrets Manager): centralized, audit-logged, heavy solutions that generate dynamic secrets. The right place if you have a large team and strict compliance requirements. But standing them up and operating them is costly.
  • SOPS + age: the sweet spot for small/mid projects. No extra service, the secret is versioned in the same place as the code, setup is a handful of commands. It also fits GitOps flows well.

One might ask, "We already have a private repo, isn't encrypting on top of it unnecessary?" I don't think so: defense in depth. If the repo accidentally goes public, if a fork leaks, or if someone's GitHub token is stolen, a plain .env is compromised instantly; the encrypted file is still useless without the private key.

Conclusion

Keeping secrets encrypted in the repo largely removes the "store the secret somewhere separate" hassle. With SOPS + age the setup is a few commands, and daily use is almost invisible. On small projects it provides a reasonable level of security and a comfortable workflow without needing Vault.

The full working demo, with all its files: https://github.com/erdyasan/sops-age-example

Share