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:
- Drift: Your deployed secrets diverge from your local ones. You debug for an hour only to discover you’re running against stale API keys.
- No audit trail: Who changed the database password? When? Why? ¯_(ツ)_/¯
- Key rotation is theater: You need to update the same secret in five different places. One gets missed. A deploy breaks at 2 AM.
- Onboarding is a nightmare: New team member joins. You send them a DM with a zip file of
.envfiles. Congratulations, the secrets are now on their laptop, their phone backup, their cloud sync, and probably their ex’s shared Dropbox.
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:
- Secrets live in git: One source of truth. No 1Password drift.
- Encrypted at rest: Even if someone clones your repo, they can’t read the secrets without your age key.
- Multiple recipients: Add a dev’s age key as a recipient. They can decrypt. Remove it. Done.
- Works everywhere: Docker, Kubernetes, Ansible, Helm, bare metal. SOPS doesn’t care.
- Key rotation is sane: Change your keys, re-encrypt the file, push. No manual hunting.
- 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:
brew install ageOn Linux:
curl https://dl.filippo.io/age/latest/age-linux-amd64.tar.gz | tar xzsudo mv age/age* /usr/local/bin/Now generate your key:
age-keygen -o ~/.config/sops/age/keys.txtThis 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.
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:
database: host: postgres.local user: sumguy_admin password: super_secret_db_pass_12345api: github_token: ghp_1234567890abcdefghijklmnopqrstuvwxyz slack_webhook: https://hooks.slack.com/services/T00000000/B00000000/XXXXregistry: username: myuser password: myregistry_secret_passNow encrypt it:
sops -e secrets.yaml > secrets.enc.yamlSOPS reads the file, encrypts values (not keys), and writes a new file. Check it out:
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.1The 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:
sops -d secrets.enc.yamlThis 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:
# Load into environmentexport $(sops -d secrets.enc.yaml | grep -E '^\w+=' | xargs)
# Or render a templatesops -d secrets.enc.yaml | envsubst > /app/.env
# Or just view itsops -d secrets.enc.yamlYour 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:
ansible-galaxy collection install community.sopsIn your playbook:
- 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 appAnsible 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):
helm repo add external-secrets https://charts.external-secrets.iohelm install external-secrets external-secrets/external-secrets -n external-secrets-system --create-namespaceThen reference your encrypted file:
apiVersion: external-secrets.io/v1beta1kind: SecretStoremetadata: name: sops-secret-storespec: provider: vault: server: "http://vault:8200"---apiVersion: external-secrets.io/v1beta1kind: ExternalSecretmetadata: name: app-secretsspec: secretStoreRef: name: sops-secret-store kind: SecretStore target: name: app-secrets creationPolicy: Owner data: - secretKey: database-password remoteRef: key: secrets.enc.yaml path: database.passwordOr, simpler: commit secrets.enc.yaml to your git repo. Flux/ArgoCD auto-syncs. Before deploying, decrypt locally:
sops -d secrets.enc.yaml | kubectl create secret generic app-secrets --from-env-file=/dev/stdinHelm + helm-secrets
If you manage charts with Helm:
helm plugin install https://github.com/jkroepke/helm-secretsEncrypt your values-secrets.yaml:
sops -e values-secrets.yaml > values-secrets.enc.yamlDeploy:
helm secrets upgrade --install my-release my-chart -f values.yaml -f values-secrets.enc.yamlhelm-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:
# Aliceage-keygen -o ~/.config/sops/age/keys.txt# Public key: age1alice123...
# Bobage-keygen -o ~/.config/sops/age/keys.txt# Public key: age1bob456...Update .sops.yaml to list both:
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:
creation_rules: - path_regex: 'secrets\.ya?ml$' key_groups: - age: | age1alice123... # age1bob456... (removed)Re-encrypt the file:
sops -r secrets.enc.yamlSOPS 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:
creation_rules: - path_regex: 'secrets\.ya?ml$' kms: - arn: arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012SOPS 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:
git add secrets.yaml secrets.enc.yamlgit commit -m "Add secrets"# Oh no. Plaintext in git history. Forever.Fix this with a pre-commit hook:
#!/bin/bash# Reject commits with plaintext secret patternsif 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 1fiBetter: use gitleaks:
brew install gitleaksgitleaks detect --source . -vAdd it to your CI:
- name: Run gitleaks uses: gitleaks/gitleaks-action@v1Footgun #2: Committing the Age Private Key
Your .age file is your master key. If it’s in git, game over:
echo "~/.config/sops/age/" >> .gitignoreStore 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:
sops -r secrets.enc.yaml # Forgot to do thisBob 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:
sops -r secrets.enc.yamlgit add secrets.enc.yamlgit commit -m "Update recipients"git pushFootgun #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:
age-keygen -o ~/.config/sops/age/keys-new.txt# Public key: age1newkey789...Update .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:
sops -r secrets.enc.yamlSOPS 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:
#!/bin/bashset -e
# Decrypt secrets into a temp filesops -d secrets.enc.yaml > /tmp/secrets.yml
# Load environmentexport $(cat /tmp/secrets.yml | grep -E '^\w+=' | xargs)
# Deploydocker pull myregistry/myapp:${IMAGE_TAG}docker run --env-file /tmp/secrets.yml myregistry/myapp
# Clean upshred -u /tmp/secrets.ymlOr with Ansible:
#!/bin/bashset -e
# Decrypt and run playbookansible-playbook \ -e "sops_secrets=$(sops -d secrets.enc.yaml)" \ deploy.ymlOr with Docker Compose:
services: app: image: myapp:latest env_file: - /tmp/secrets.env # Generated by: sops -d secrets.enc.yaml > /tmp/secrets.envYour 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:
- Secrets live in git, encrypted.
- Multiple team members can decrypt (add their age keys).
- Key rotation is a five-minute task.
- Your deploy doesn’t know about 1Password, Discord, or
.envfiles in Slack. - Audit trail is just git history.
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.