Secrets in git: sops and age with a master key in Yandex Cloud KMS or a passphrase

A project's secrets (the database password, token signing keys, OAuth client secrets, SMTP) are something you want to keep next to the code: in the same repository, with the same history, in the same branches. But they have no place in git in plain text. This article describes the scheme I use for the blog: the secret files live in the repository encrypted by sops with an age key, and the age key itself is stored right there, encrypted with a master key. I'll go through two options for the master key, in Yandex Cloud KMS and without any cloud, on a passphrase, and at the end I'll cover the alternatives: Google Cloud, AWS, Azure, Vault, hardware keys.

The tools

sops (Secrets OPerationS) doesn't encrypt the file as a whole, but the values in it. The keys of YAML, JSON and .env stay in the clear, and git diff shows which secret changed, though not to what. Here's a fragment of the encrypted values of the blog's chart as it sits in git:

database:
    password: ENC[AES256_GCM,data:lPFUNnhq…UAQ=,iv:baYT…,tag:V9NF…,type:str]
rabbitmq:
    url: ENC[AES256_GCM,data:85G9nSGK…,iv:0o88…,tag:dSEE…,type:str]
sops:
    age:
        - recipient: age1<public-key>…
          enc: |
            -----BEGIN AGE ENCRYPTED FILE-----
            …
            -----END AGE ENCRYPTED FILE-----
    lastmodified: "2026-10-04T14:36:59Z"
    mac: ENC[AES256_GCM,data:…]
    version: 3.11.0

Here's how it works: sops generates a random data key, encrypts every value with it (AES-256-GCM), and encrypts the data key itself for every recipient listed in the sops: section; in the example there's one recipient, an age key. At the bottom is a MAC of the whole file: sops will notice if a value is tampered with or removed.

age is a modern replacement for PGP for encrypting files: one binary, one command, one-line keys. A key pair looks like this:

# created: 2026-10-01T14:09:00+03:00
# public key: age1<public-key>…
AGE-SECRET-KEY-1…

The public half (age1…) is the recipient you encrypt for; it isn't secret and goes right into the repository's settings. The secret half (AGE-SECRET-KEY-1…) decrypts, and that's the one you have to keep somewhere.

Both tools install from their GitHub releases (getsops/sops, FiloSottile/age) or via scoop/winget/brew. To check:

sops --version
age --version
age-keygen --version

The overall scheme

Wherever the master key lives, the repository ends up looking the same:

repo/
├── age.agekey.enc        ← the age secret key, encrypted with the master key
├── decrypt.cmd           ← master key → %USERPROFILE%\sops\blog-age.agekey
├── encrypt.cmd           ← the reverse (needed once, when the key is created)
├── src/
│   ├── .env.enc          ← dev environment secrets for docker compose
│   ├── .env              ← decrypted, in .gitignore
│   ├── decrypt.cmd
│   └── encrypt.cmd
└── charts/
    ├── .sops.yaml        ← recipient: the age public key
    ├── secrets.enc.yaml  ← production secrets for Helm
    ├── secrets.dec.yaml  ← decrypted, in .gitignore
    ├── decrypt.cmd
    └── encrypt.cmd

Two levels: the master key decrypts the age key (once per machine), and the age key decrypts the secret files (every day). Why not encrypt the files with the master key directly? Three reasons.

  1. sops supports age out of the box, but not Yandex Cloud KMS. For AWS, GCP and Azure sops has built-in support, and there the two-level scheme isn't required (more on that below), but it's still convenient.
  2. Day-to-day work doesn't depend on the cloud: decrypt the key once, and from then on sops works offline, with no tokens and no network calls for every file.
  3. The age key is what you hand to CI: a single line in the GitHub Actions secrets. There's no need to set up cloud KMS access for CI.

The naming convention is simple: *.enc.* goes into git, *.dec.* and .env go into .gitignore. You can't get git add wrong.

Option 1: the master key in Yandex Cloud KMS

This fits when several people or machines work on the project: access to the key is granted and revoked with cloud roles rather than by passing a password around.

The key in KMS. You need the Yandex Cloud CLI (yc) with a configured profile. Create a symmetric key:

yc kms symmetric-key create --name blog-master --default-algorithm aes-256

The response contains the key's id, which is what the scripts use. The name blog-master is convenient for humans, but the CLI looks a key up by name in the folder of the active profile, while by ID it finds it anywhere, so IDs are better in scripts.

Access to the key is the kms.keys.encrypterDecrypter role on the key for everyone who needs to decrypt. That's your access control for all of the project's secrets: revoke the role, and the person can no longer get the age key. (A key they already have stays with them, of course; more on that in the section on rotation.)

The encryption script for the age key, encrypt.cmd in the repository root. It creates the age key if there isn't one and encrypts it in KMS:

@echo off
rem Encrypts %USERPROFILE%\sops\blog-age.agekey with the blog-master key in Yandex Cloud KMS
rem into age.agekey.enc. Generates the age key first if it does not exist.
setlocal
set "KMS_KEY_ID=<key id>"
set "KEY_DIR=%USERPROFILE%\sops"
set "KEY_FILE=%KEY_DIR%\blog-age.agekey"

if not exist "%KEY_DIR%" mkdir "%KEY_DIR%"
if not exist "%KEY_FILE%" (
    age-keygen -o "%KEY_FILE%" || exit /b 1
)
yc kms symmetric-crypto encrypt --id %KMS_KEY_ID% --plaintext-file "%KEY_FILE%" --ciphertext-file "%~dp0age.agekey.enc"
exit /b %ERRORLEVEL%

The resulting age.agekey.enc (a few hundred bytes) gets committed. The public key from the age-keygen output will come in handy in the next step.

The decryption script, decrypt.cmd next to it:

@echo off
setlocal
set "KMS_KEY_ID=<key id>"
set "KEY_DIR=%USERPROFILE%\sops"
set "KEY_FILE=%KEY_DIR%\blog-age.agekey"

if not exist "%KEY_DIR%" mkdir "%KEY_DIR%"
yc kms symmetric-crypto decrypt --id %KMS_KEY_ID% --ciphertext-file "%~dp0age.agekey.enc" --plaintext-file "%KEY_FILE%"
exit /b %ERRORLEVEL%

On a new machine: install yc, sign in, run decrypt.cmd, and the age key is in %USERPROFILE%\sops. The cloud isn't needed after that.

Option 2: no cloud, a passphrase

For a personal project or a single developer, the cloud is overkill. age has a passphrase mode (-p): the file is encrypted with a key derived from the passphrase via scrypt. We apply it to the same age key:

age-keygen -o ~/sops/blog-age.agekey
age -p -o age.agekey.enc ~/sops/blog-age.agekey

age will ask for the passphrase twice (or offer to generate one). Decrypting on another machine:

age -d -o ~/sops/blog-age.agekey age.agekey.enc

The same two scripts as in the first option, with yc kms … replaced by age -p / age -d. Everything else (.sops.yaml, the *.enc.* files, CI) stays the same, and you can switch from a passphrase to KMS (or back) at any time: it's a single re-encrypted age.agekey.enc file.

Keep the passphrase in a password manager. It's the only thing standing between the contents of the repository and all of the project's secrets, so generate it rather than make it up: age offers a passphrase of random words if you leave the field empty.

You can even do without the intermediate file: sops can work with an encrypted age key directly if you point SOPS_AGE_KEY_FILE at it, in which case it asks for the passphrase on every call. I find it more convenient to decrypt the key once per machine.

Encrypting the secrets

From here on both options are the same. The secrets folder has a .sops.yaml with a rule saying which files to encrypt for whom.

creation_rules:
  - path_regex: .*\.enc\.(yaml|json)$
    age: age1<public-key>…

Edit secrets.dec.yaml in a regular editor and encrypt it:

export SOPS_AGE_KEY_FILE=~/sops/blog-age.agekey
sops --encrypt --filename-override secrets.enc.yaml --output secrets.enc.yaml secrets.dec.yaml
sops --decrypt --output secrets.dec.yaml secrets.enc.yaml

Two non-obvious switches:

  • --filename-override: sops picks the rule from .sops.yaml by the name of the encrypted file, but we feed it *.dec.yaml, which the path_regex doesn't match.
  • --output instead of a > redirect: if decryption fails, a redirect leaves an empty file over the previous plain text, while --output touches nothing.

The docker compose .env needs the dotenv type, and it has a quirk: the sops parser doesn't accept CR, so the file must have LF line endings even on Windows.

sops --input-type dotenv --output-type dotenv --encrypt --age age1<public-key>… --output .env.enc .env
sops --input-type dotenv --output-type dotenv --decrypt --output .env .env.enc

I've wrapped this in encrypt.cmd/decrypt.cmd in every secrets folder; the chart's script walks through all the *.dec.yaml and *.dec.json files. One detail worth repeating: sops takes a new data key and new IVs on every encryption, so a re-encrypted file always differs from the previous one, even if the secrets haven't changed. In git that's noise: a commit "changed the database password" looks the same as a commit "changed nothing, re-encrypted". My encryption script first decrypts the current *.enc.* into a temporary file, compares it with *.dec.*, and re-encrypts only what has changed:

sops --decrypt --output "%TEMP%\compare.tmp" secrets.enc.yaml
fc /b "%TEMP%\compare.tmp" secrets.dec.yaml >nul && set "SKIP=1"

The alternative for editing the encrypted file directly is sops edit secrets.enc.yaml: it opens the decrypted text in $EDITOR and re-encrypts only the values that changed. That's the standard way that keeps the diff honest; I need the *.dec.* files for helm -f and docker compose, where the file has to be in the clear.

CI

The blog is deployed by GitHub Actions: it builds the images and runs helm upgrade with the secrets from charts/secrets.enc.yaml. For that the runner needs only the age key: the contents of blog-age.agekey as a single line in the SOPS_AGE_KEY repository secret. Neither yc nor a passphrase is needed in CI:

- name: Install sops
  run: |
    curl -sSfL -o sops "https://github.com/getsops/sops/releases/download/${SOPS_VERSION}/sops-${SOPS_VERSION}.linux.amd64"
    sudo install -m 0755 sops /usr/local/bin/sops
- name: Prepare secrets
  env:
    SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
  run: |
    set +x
    umask 077
    sops --decrypt --output "$RUNNER_TEMP/secrets.dec.yaml" charts/secrets.enc.yaml
- name: Deploy
  run: helm upgrade --install prod chart -f "$RUNNER_TEMP/secrets.dec.yaml" --rollback-on-failure --wait

sops reads SOPS_AGE_KEY straight from the environment variable, so no file needs to be created. set +x and umask 077 keep the decrypted data out of the log and readable only by its owner; a Cleanup step with if: always() deletes the file after the deployment.

Multiple recipients and rotation

As long as there's one recipient, the "age key + master key" scheme works as a shared team key. But sops lets you list several recipients, and then every developer can have their own age key, and the master key isn't needed at all:

creation_rules:
  - path_regex: .*\.enc\.(yaml|json)$
    age: >-
      age1aaaa…,
      age1bbbb…,
      age1cccc…

The data key is encrypted for each of them, and any of them can decrypt. When someone joins, add their public key and run sops updatekeys secrets.enc.yaml: sops re-encrypts the data key without touching the values. When someone leaves, do the same, but after that the secrets must be changed: the person who left has already seen them, and no cryptography can undo that. The same goes for a revoked KMS role and for a changed passphrase: rotating the key protects future secrets, not past ones.

For really large teams sops has key_groups with a Shamir threshold: you can only decrypt by gathering keys from several groups. That's overkill for a blog, but it's good to know.

What else can serve as a master key

I chose Yandex Cloud because the rest of my infrastructure is there. sops has built-in support for other key stores, and with them the two-level scheme is optional: you can put the KMS key straight into .sops.yaml, and sops will go to the cloud itself on every operation.

Google Cloud KMS. Create a key and reference it in the rule:

gcloud kms keyrings create blog --location global
gcloud kms keys create master --keyring blog --location global --purpose encryption
gcloud auth application-default login
creation_rules:
  - path_regex: .*\.enc\.(yaml|json)$
    gcp_kms: projects/<project>/locations/global/keyRings/blog/cryptoKeys/master

Access is the roles/cloudkms.cryptoKeyEncrypterDecrypter role. You can also follow my scheme: gcloud kms encrypt --plaintext-file blog-age.agekey --ciphertext-file age.agekey.enc … and then age, as with Yandex; then the cloud is only needed when setting up a machine for the first time, and CI gets by with the age key.

AWS KMS. The oldest sops integration: kms: arn:aws:kms:eu-central-1:123456789012:key/… in the rule, access via IAM and the usual AWS credentials chain. You can list several keys in different regions, and the file will decrypt as long as at least one is available.

Azure Key Vault. azure_kv: https://<vault>.vault.azure.net/keys/<key>/<version>, signing in via az login or a managed identity.

HashiCorp Vault. hc_vault_transit_uri: https://vault.example.com/v1/transit/keys/sops: the transit engine, with the token from VAULT_TOKEN. An option for those who already have Vault; it's not worth setting it up just for sops.

PGP. sops supports it, and plenty of articles are written specifically about PGP, but today it's the worst choice: bulky keys, an agent, expiry dates, signatures, none of which you need for encrypting files. age came about precisely in response to that.

Hardware keys. age has plugins: age-plugin-yubikey keeps the secret key on a YubiKey, age-plugin-tpm in the computer's TPM; their recipients start with age1yubikey1… and age1tpm1…. The key never leaves the device, but CI can't work with such a key, so it gets a separate regular age key as another recipient.

A password manager. The simplest option for one person: the age secret key is kept in 1Password/Bitwarden/KeePass and copied to the machine by hand. Essentially it's option 2 without the age.agekey.enc file in the repository. The downside is that a new machine needs access to the password vault, which CI or a colleague doesn't have.

How to choose

  • One person, a couple of machines: a passphrase (option 2) or the key in a password manager. Zero infrastructure.
  • Several people or services, with a cloud already in place: the KMS of the cloud where the project lives, directly in sops (AWS/GCP/Azure) or through an encrypted age key (Yandex Cloud and anything sops doesn't know about).
  • A team where everyone has their own key: multiple age recipients and sops updatekeys; no master key needed.

In any option the repository holds .sops.yaml, the *.enc.* files and, if there's a master key, age.agekey.enc with a couple of scripts. A new developer runs one command and gets all the secrets they're entitled to, and CI gets by with a single line in its secrets. Most importantly, the secrets are versioned together with the code: rolling back to the v4.0.12 tag also brings back the values that tag was deployed with.

Only registered users can leave a comment.

Sign in or register to leave a comment.