Secrets in Git with sops and age

Secrets in Git with sops and age

Committing secrets to Git is the mistake everyone makes once. The usual response is to keep secrets entirely outside version control, which means they live in someone’s password manager, get pasted into chat, and drift out of sync with the code that needs them.

sops plus age gives you a third option: commit them encrypted, review changes in diffs, and decrypt only where needed.

age, briefly

age encrypts a file to one or more public keys. That is the entire tool.

sudo apt install age

age-keygen -o ~/.config/sops/age/keys.txt
# Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p

No keyring, no trust model, no expiry, no subkeys, no --batch flags to get wrong. Our GPG guide covers the tool age is replacing for this specific job, and GPG remains right for signing and for email.

Protect that private key file. Anyone with it decrypts everything.

sops, and why it encrypts values

You could age -e secrets.yaml and commit the blob. sops does something better.

# secrets.yaml, as committed
database:
    host: db.internal
    password: ENC[AES256_GCM,data:x8Kq...,iv:...,tag:...,type:str]
api:
    endpoint: https://api.example.com
    token: ENC[AES256_GCM,data:9Zm2...,iv:...,tag:...,type:str]

Keys and structure stay readable. Only values are encrypted.

That distinction is the whole point:

Diffs are meaningful. A commit changing api.token shows exactly that, rather than an opaque blob replacing another opaque blob.

Review works. A reviewer sees a new key was added without seeing its value.

Merges are tractable. Two people editing different secrets in the same file do not produce an unresolvable conflict.

Setting up

# install sops from its releases, or your package manager
sops --version
# .sops.yaml at the repository root
creation_rules:
  - path_regex: secrets/prod/.*\.yaml$
    age: >-
      age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p,
      age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg

  - path_regex: secrets/dev/.*\.yaml$
    age: >-
      age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p

Different paths encrypt to different recipients, so production secrets go to fewer people than development ones. sops picks the rule automatically.

sops secrets/prod/database.yaml    # opens $EDITOR, encrypts on save
sops -d secrets/prod/database.yaml # decrypt to stdout
sops -e -i plaintext.yaml          # encrypt in place

sops <file> on an existing encrypted file decrypts to a temporary file, opens your editor, and re-encrypts on save. The plaintext never touches your working directory.

Rotating access

# edit .sops.yaml to add or remove keys, then
sops updatekeys secrets/prod/database.yaml
find secrets -name '*.yaml' -exec sops updatekeys -y {} \;

That re-encrypts to the current recipient list.

Removing a key stops future access and does not undo past access. Anything that person already decrypted, they still have. When someone leaves, remove their key and rotate the underlying credentials. The same point applies to our pass guide for team shares.

Using them

# environment variables for a command
sops exec-env secrets/prod/app.yaml 'myapp --serve'

# a temporary decrypted file, removed after
sops exec-file secrets/prod/config.yaml 'myapp --config {}'

# one value
sops -d --extract '["database"]["password"]' secrets/prod/database.yaml

sops exec-env is the one to reach for. The plaintext exists in the process environment and never on disk.

With Ansible:

- name: Load secrets
  community.sops.load_vars:
    file: secrets/prod/app.yaml
    name: app_secrets

That is a reasonable alternative to Ansible Vault, particularly if the same secrets are consumed by things that are not Ansible.

Automated decryption

The deployment problem: how does a server decrypt without you putting a key on it?

Cloud KMS. sops supports AWS KMS, GCP KMS, and Azure Key Vault. The machine’s IAM role grants decryption, so no key material sits on disk and access is revoked in the cloud console.

creation_rules:
  - path_regex: secrets/prod/.*
    kms: 'arn:aws:kms:eu-west-1:111122223333:key/abcd-1234'

An age key delivered by configuration management, for self-hosted setups. Less elegant, and it works, and the key file should be mode 0400 owned by the service user.

systemd credentials are worth knowing about for the final step:

[Service]
LoadCredentialEncrypted=dbpass:/etc/myapp/dbpass.cred

The secret is decrypted into a tmpfs readable only by that service, which pairs well with the confinement in our service hardening guide.

Belt and braces

Encrypted-by-default is good; a pre-commit hook that refuses plaintext is better.

# .git/hooks/pre-commit
#!/bin/sh
for f in $(git diff --cached --name-only --diff-filter=ACM | grep '^secrets/'); do
    if ! grep -q 'ENC\[AES256_GCM' "$f"; then
        echo "REFUSING: $f under secrets/ is not encrypted"
        exit 1
    fi
done

Tools like gitleaks and git-secrets scan more broadly for credential patterns and are worth running in CI.

If a secret does reach a public repository, rotate it immediately. Removing it from history does not help: it was scraped within minutes, and public repositories are monitored continuously for exactly this. Our Git basics guide makes the same point about .gitignore not retroactively protecting anything.

The honest scope

sops and age suit secrets that belong with code: database passwords, API tokens, TLS keys, service credentials. They are version-controlled, reviewable, and deployed alongside what uses them.

They are not a secrets service. There is no dynamic credential issuance, no automatic rotation, no lease expiry, no audit log of who read what. Vault does those things and costs considerably more to operate.

For most teams, encrypted files in Git is the right amount of machinery, and it is a large improvement on a shared password manager entry that nobody remembers updating.

Frequently Asked Questions

What does sops do that encrypting the whole file does not?

sops encrypts only the values in a structured file, leaving keys and structure readable. That means a Git diff shows which settings changed even though the values stay encrypted, which makes code review possible and merge conflicts tractable.

Why use age instead of GPG?

age does one job with no configuration, no keyring, no web of trust, and no expiry handling. GPG is powerful and its complexity is a frequent source of mistakes. For encrypting a file to a handful of recipients, age is simpler and harder to misuse.

Is it safe to commit encrypted secrets to a public repository?

The ciphertext is safe with current algorithms, and the metadata is not always. File names, the structure of your configuration, and which keys exist are all visible. A private repository remains the better default, and encryption is the protection rather than obscurity.

How do I remove someone’s access after they leave?

Remove their public key from the sops configuration and re-encrypt the files with updatekeys. That stops future access, and anything they already decrypted they still have, so rotate the underlying secrets themselves rather than just the encryption.

What happens if I lose the private key?

The secrets are unrecoverable, which is why you encrypt to more than one recipient. A common arrangement is each team member’s key plus an offline break-glass key stored securely, so losing one person’s laptop does not lose the secrets.

Can sops decrypt automatically during deployment?

Yes. sops supports cloud KMS backends including AWS KMS, GCP KMS, and Azure Key Vault, so a machine with the right IAM role decrypts without any key material on disk. For self-hosted setups an age key delivered through your configuration management serves the same purpose.