Skip to content
Go back

SOPS + age: Secrets in Git

By SumGuy 12 min read
SOPS + age: Secrets in Git

The Secret Shuffle

Your setup looks like this: a spreadsheet in 1Password labeled “Dev Secrets,” someone’s Slack DM with a .env file, three copies of the database password scattered across two team wikis, and a nagging feeling that someone pushed plaintext credentials to git last Tuesday.

Sound familiar?

Most home labs and small teams manage secrets like they’re moving apartments: zip them up, email them around, paste them in Discord, pray that someday you’ll consolidate everything. Except you never do. You just add more sources of truth until the only truth is chaos.

The traditional backup plan is worse. You commit .env.example to git and manually copy the real values. This means:

And if you did try to solve this with GPG? You discovered that GPG feels like operating a nuclear reactor to send someone a password. Web of trust. Key servers. Subkeys. Everyone’s keys expire mysteriously. You end up just committing the secrets unencrypted because GPG was more hassle than the risk was worth.

Here’s the thing: secrets should live in git. Version control. Audit trail. Reproducibility. All of it. But they should live there encrypted.

Enter SOPS + age. Together, they’re the answer to “how do I commit secrets to git without my security team firing me?”

Why SOPS? Why age?

SOPS (Secrets Operations) is a Mozilla tool that encrypts individual values inside YAML, JSON, and .env files while leaving the structure readable. You can see that you have a database password and an API key. You just can’t read them without the right key.

age is a modern, simple file encryption tool. It’s what GPG should have been: no key servers, no web of trust, no ceremony. Just a public key, a private key, and five minutes to understand how it works.

Together they’re unstoppable. SOPS handles the operational side (which values to encrypt, multiple recipients, KMS integration). age provides the actual encryption without the nuclear reactor complexity.

Here’s why they win:

  1. Secrets live in git: One source of truth. No 1Password drift.
  2. Encrypted at rest: Even if someone clones your repo, they can’t read the secrets without your age key.
  3. Multiple recipients: Add a dev’s age key as a recipient. They can decrypt. Remove it. Done.
  4. Works everywhere: Docker, Kubernetes, Ansible, Helm, bare metal. SOPS doesn’t care.
  5. Key rotation is sane: Change your keys, re-encrypt the file, push. No manual hunting.
  6. KMS-aware: If you’re on AWS or GCP, SOPS can use their KMS services instead of age. Both work seamlessly.

The Workflow: From Zero to Encrypted

Let’s ship this. I’ll walk you through the setup, the gotchas, and how to integrate it into Ansible, Kubernetes, and your deploy pipeline.

Step 1: Generate an age Key

First, install age. On macOS:

Terminal window
brew install age

On Linux:

Terminal window
curl https://dl.filippo.io/age/latest/age-linux-amd64.tar.gz | tar xz
sudo mv age/age* /usr/local/bin/

Now generate your key:

Terminal window
age-keygen -o ~/.config/sops/age/keys.txt

This creates a private key file and prints a public key (starts with age1...). Save that public key somewhere safe. You’ll need it in the next step.

The private key in keys.txt is your treasure. Treat it like SSH keys: no backups to cloud storage, no Slack DMs, no group chats. If someone gets this, they can decrypt all your secrets encrypted for your age key. Keep it local or on a USB stick in a safe. Seriously.

Step 2: Create .sops.yaml

This config file tells SOPS which files to encrypt, how, and for whom.

.sops.yaml
creation_rules:
# Encrypt all secrets files with age
- path_regex: 'secrets\.ya?ml$'
key_groups:
- age: AGE1JLH456...
# Or: multiple recipients
- path_regex: 'prod-secrets\.ya?ml$'
key_groups:
- age: |
AGE1JLH456...
AGE1XYZ789...
# Encrypt .env files (different recipients)
- path_regex: '\.env\.encrypted$'
key_groups:
- age: AGE1JLH456...
# Fallback: catch-all
- key_groups:
- age: AGE1JLH456...

Replace AGE1JLH456... with your actual age public key. If your team has multiple people, list all public keys as recipients. Each person can decrypt.

Commit .sops.yaml to git. It’s config, not secrets.

Step 3: Encrypt Your First File

Create a plaintext secrets file:

secrets.yaml
database:
host: postgres.local
user: sumguy_admin
password: super_secret_db_pass_12345
api:
github_token: ghp_1234567890abcdefghijklmnopqrstuvwxyz
slack_webhook: https://hooks.slack.com/services/T00000000/B00000000/XXXX
registry:
username: myuser
password: myregistry_secret_pass

Now encrypt it:

Terminal window
sops -e secrets.yaml > secrets.enc.yaml

SOPS reads the file, encrypts values (not keys), and writes a new file. Check it out:

secrets.enc.yaml (encrypted)
database:
host: postgres.local
user: sumguy_admin
password: ENC[AES256_GCM,data:Jk8T2x5mN9pL4qR7sT3uV...==,iv:8aB9cC2dE3fG4hI5jK6lM7n=,tag:wX2yZ3aB4cD5eF6gH7iJ8k=,type:str]
api:
github_token: ENC[AES256_GCM,data:kL9mN8oP7qR6sT5uV4wX3y...==,iv:2nO3pQ4rS5tU6vW7xY8zA9b=,tag:cD4eF5gH6iJ7kL8mN9oP0q=,type:str]
slack_webhook: ENC[AES256_GCM,data:9cD8eF7gH6iJ5kL4mN3oP2...==,iv:qR8sT9uV0wX1yZ2aB3cD4e=,tag:fG6hI7jK8lL9mN0oP1qR2s=,type:str]
registry:
username: myuser
password: ENC[AES256_GCM,data:2sT1uV0wX9yZ8aB7cD6eF5...==,iv:gH7iJ8kL9mN0oP1qR2sT3u=,tag:hI8jK9lL0mN1oP2qR3sT4u=,type:str]
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age:
- recipient: age1jlh456...
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
...
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-06-17T12:00:00Z"
mac: ENC[AES256_GCM,data:...==,iv:...,tag:...,type:str]
pgp: []
encrypted_regex: ^(password|token|secret|key|api_key)$
version: 3.8.1

The structure is still readable. Only the secret values are encrypted. Commit secrets.enc.yaml to git. Delete the plaintext secrets.yaml. You’re done.

Step 4: Decrypt at Deploy Time

To decrypt:

Terminal window
sops -d secrets.enc.yaml

This reads the encrypted file, uses your age key from ~/.config/sops/age/keys.txt to unlock it, and prints plaintext to stdout. Pipe it where you need it:

Terminal window
# Load into environment
export $(sops -d secrets.enc.yaml | grep -E '^\w+=' | xargs)
# Or render a template
sops -d secrets.enc.yaml | envsubst > /app/.env
# Or just view it
sops -d secrets.enc.yaml

Your deploy script runs one of these commands. No manual file copies. No DMs. Just decryption.

Integration: The Real World

Secrets are only useful if your infrastructure can actually use them. Here’s how to wire SOPS into the tools you’re probably running.

Ansible + SOPS

The community.sops collection decrypts SOPS files in your Ansible runs:

Terminal window
ansible-galaxy collection install community.sops

In your playbook:

deploy.yml
- name: Load encrypted secrets
hosts: servers
vars:
secrets: "{{ lookup('community.sops.sops', 'secrets.enc.yaml') }}"
tasks:
- name: Create .env from decrypted secrets
copy:
content: |
DATABASE_HOST={{ secrets.database.host }}
DATABASE_USER={{ secrets.database.user }}
DATABASE_PASSWORD={{ secrets.database.password }}
GITHUB_TOKEN={{ secrets.api.github_token }}
dest: /app/.env
mode: '0600'
notify: Restart app

Ansible runs locally (or on a bastion), decrypts the secrets, and injects them into plays. Servers never touch the encrypted file.

Kubernetes + SOPS via Flux or ArgoCD

If you’re running Kubernetes, use sealed-secrets or external-secrets (which integrates with SOPS):

Terminal window
helm repo add external-secrets https://charts.external-secrets.io
helm install external-secrets external-secrets/external-secrets -n external-secrets-system --create-namespace

Then reference your encrypted file:

secretstore.yaml
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
name: sops-secret-store
spec:
provider:
vault:
server: "http://vault:8200"
---
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: app-secrets
spec:
secretStoreRef:
name: sops-secret-store
kind: SecretStore
target:
name: app-secrets
creationPolicy: Owner
data:
- secretKey: database-password
remoteRef:
key: secrets.enc.yaml
path: database.password

Or, simpler: commit secrets.enc.yaml to your git repo. Flux/ArgoCD auto-syncs. Before deploying, decrypt locally:

Terminal window
sops -d secrets.enc.yaml | kubectl create secret generic app-secrets --from-env-file=/dev/stdin

Helm + helm-secrets

If you manage charts with Helm:

Terminal window
helm plugin install https://github.com/jkroepke/helm-secrets

Encrypt your values-secrets.yaml:

Terminal window
sops -e values-secrets.yaml > values-secrets.enc.yaml

Deploy:

Terminal window
helm secrets upgrade --install my-release my-chart -f values.yaml -f values-secrets.enc.yaml

helm-secrets intercepts the encrypted file, decrypts it, and passes plaintext to Helm. Encrypted values never hit the cluster unencrypted.

Multiple Key Holders: The Team Scenario

Real teams have more than one person. You don’t want the sole.ops-key-holder to be a single point of failure. SOPS makes this trivial.

Each team member generates their own age key:

Terminal window
# Alice
age-keygen -o ~/.config/sops/age/keys.txt
# Public key: age1alice123...
# Bob
age-keygen -o ~/.config/sops/age/keys.txt
# Public key: age1bob456...

Update .sops.yaml to list both:

.sops.yaml
creation_rules:
- path_regex: 'secrets\.ya?ml$'
key_groups:
- age: |
age1alice123...
age1bob456...

Now both Alice and Bob can decrypt. When someone leaves the team, remove their key:

.sops.yaml
creation_rules:
- path_regex: 'secrets\.ya?ml$'
key_groups:
- age: |
age1alice123...
# age1bob456... (removed)

Re-encrypt the file:

Terminal window
sops -r secrets.enc.yaml

SOPS detects the new recipients in .sops.yaml, decrypts with the old keys, and re-encrypts for the new ones. Bob’s copy of the private key is now useless. Done.

KMS Backends: When age Isn’t Enough

If you’re on AWS or GCP, you can use their KMS services instead of age files. SOPS will use the cloud provider’s key management:

.sops.yaml (AWS KMS)
creation_rules:
- path_regex: 'secrets\.ya?ml$'
kms:
- arn: arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012

SOPS still encrypts the file locally but uses AWS KMS to encrypt the data key. Decryption requires AWS IAM permissions. No .env files with age keys on EC2 instances.

The Footguns: What Will Bite You

You’re not done yet. Here are the ways people shoot themselves:

Footgun #1: Committing the Plaintext File

You encrypt with sops -e secrets.yaml > secrets.enc.yaml. Then you forget and commit secrets.yaml:

Terminal window
git add secrets.yaml secrets.enc.yaml
git commit -m "Add secrets"
# Oh no. Plaintext in git history. Forever.

Fix this with a pre-commit hook:

.git/hooks/pre-commit
#!/bin/bash
# Reject commits with plaintext secret patterns
if git diff --cached | grep -E '(password|api_key|token|secret).*:.*[a-zA-Z0-9]{10,}' | grep -v ENC; then
echo "Error: Plaintext secrets detected"
exit 1
fi

Better: use gitleaks:

Terminal window
brew install gitleaks
gitleaks detect --source . -v

Add it to your CI:

.github/workflows/security.yml
- name: Run gitleaks
uses: gitleaks/gitleaks-action@v1

Footgun #2: Committing the Age Private Key

Your .age file is your master key. If it’s in git, game over:

Terminal window
echo "~/.config/sops/age/" >> .gitignore

Store the age key on a USB stick, in a password manager (encrypted separately), or—if you’re on a team server—in a secrets manager like Vault. Not in git. Never in git.

Footgun #3: Using the Wrong Recipient

You add Bob’s key to .sops.yaml but forget to re-encrypt the existing file:

Terminal window
sops -r secrets.enc.yaml # Forgot to do this

Bob pulls the repo, tries to decrypt, and gets “permission denied” because the file was still encrypted for Alice only. Confusion ensues.

Always re-encrypt after changing recipients:

Terminal window
sops -r secrets.enc.yaml
git add secrets.enc.yaml
git commit -m "Update recipients"
git push

Footgun #4: Losing Your Age Key

Your laptop dies. Your USB stick melts. Your age key is gone. The encrypted file is now a useless blob of AES-256.

Solution: Back up your age key. To a password manager (1Password, Bitwarden). To a second USB stick in a safe. Print it on paper and laminate it (seriously). Just don’t rely on a single copy.

Key Rotation: The Sane Way

At some point, you’ll need to rotate your keys. Maybe someone left the team. Maybe you’re paranoid and want to rotate annually. SOPS makes it painless.

Generate a new age key:

Terminal window
age-keygen -o ~/.config/sops/age/keys-new.txt
# Public key: age1newkey789...

Update .sops.yaml:

.sops.yaml
creation_rules:
- path_regex: 'secrets\.ya?ml$'
key_groups:
- age: |
age1newkey789...
# Old keys still listed for decryption
age1alice123...
age1bob456...

Re-encrypt the file:

Terminal window
sops -r secrets.enc.yaml

SOPS decrypts with the old keys (still accessible) and re-encrypts for the new key. The file is now keyed to age1newkey789.... The old keys can be discarded.

If you want to rotate all your secrets (a full key rotation), just update the primary key in .sops.yaml and re-encrypt. Your secrets stay encrypted. The crypto changes. Your deploy doesn’t.

The Deploy Reality

Once you have SOPS set up, your deploy looks like this:

deploy.sh
#!/bin/bash
set -e
# Decrypt secrets into a temp file
sops -d secrets.enc.yaml > /tmp/secrets.yml
# Load environment
export $(cat /tmp/secrets.yml | grep -E '^\w+=' | xargs)
# Deploy
docker pull myregistry/myapp:${IMAGE_TAG}
docker run --env-file /tmp/secrets.yml myregistry/myapp
# Clean up
shred -u /tmp/secrets.yml

Or with Ansible:

deploy.sh
#!/bin/bash
set -e
# Decrypt and run playbook
ansible-playbook \
-e "sops_secrets=$(sops -d secrets.enc.yaml)" \
deploy.yml

Or with Docker Compose:

docker-compose.yml
services:
app:
image: myapp:latest
env_file:
- /tmp/secrets.env # Generated by: sops -d secrets.enc.yaml > /tmp/secrets.env

Your infrastructure doesn’t change. It just reads from decrypted secrets instead of a 1Password note.

Wrapping Up

SOPS + age is the unsexy, boring solution to secrets management. No dashboards. No SaaS. No monthly bill. Just encryption that works.

Here’s what you’ve got now:

Is it perfect? No. You still need to protect the age private key. You still need backups. You still need to remember to run sops -r when you add recipients. But compared to the spreadsheet-in-Slack hellscape, it’s nirvana.

Now go encrypt those secrets. Your 2 AM self will thank you.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Next Post
Boundary vs Teleport

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts