Managing Homelab Secrets with SOPS and age Encryption
Every homelab has secrets: database passwords, API keys, SMTP credentials, VPN private keys. The common approaches to managing these secrets range from bad (hardcoded in config files committed to git) to slightly less bad (stored in a .env file you hope you remember to gitignore). Both fail eventually -- you accidentally commit a secret, you lose the .env file when rebuilding a machine, or you can't remember which version of which password is deployed where.
Photo by Steve Johnson on Unsplash
SOPS (Secrets OPerationS) solves this by encrypting only the values in your config files while leaving the keys readable. Combined with age -- a modern, simple encryption tool -- you get a secrets workflow that's safe to commit to git, easy to use, and doesn't require running a separate secrets server like HashiCorp Vault.

Why SOPS + age
The Problem with Other Approaches
.env files: Not version-controlled. Easy to lose. No audit trail. Different versions on different machines with no way to tell which is correct.
HashiCorp Vault: Powerful but massive overkill for a homelab. Running a highly-available Vault cluster to store 20 secrets is like driving a semi truck to pick up groceries.
Git-crypt: Encrypts entire files, making diffs useless. You can't see which config values changed -- just that "something changed in this encrypted blob."
Ansible Vault: Works, but ties you to Ansible. If you stop using Ansible (or never started), Ansible Vault is dead weight.
What SOPS Does Differently
SOPS encrypts values but preserves keys and structure. A SOPS-encrypted YAML file looks like this:
database:
host: homelab-db.local # Not encrypted -- not a secret
port: 5432 # Not encrypted
password: ENC[AES256_GCM,data:kD8k3j2...,iv:...,tag:...,type:str]
username: ENC[AES256_GCM,data:a8Dk2p...,iv:...,tag:...,type:str]
smtp:
server: smtp.fastmail.com # Not encrypted
api_key: ENC[AES256_GCM,data:pQ9z7m...,iv:...,tag:...,type:str]
You can see the structure. You can see which keys exist. You can review PRs and see that someone changed database.password without seeing the actual password. Git diffs are meaningful. The file is safe to commit because only the sensitive values are encrypted.
Installation
Install age
age is the encryption backend. It generates keys and handles the actual cryptographic operations.
# Debian/Ubuntu
sudo apt install age
# Fedora
sudo dnf install age
# macOS
brew install age
# From source (any platform)
go install filippo.io/age/cmd/...@latest
Install SOPS
# Download the latest binary (check GitHub releases for current version)
curl -LO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64
sudo mv sops-v3.9.4.linux.amd64 /usr/local/bin/sops
sudo chmod +x /usr/local/bin/sops
# Or via package manager
# macOS
brew install sops
# Arch Linux
sudo pacman -S sops
Generate an age Key Pair
# Generate a key pair
age-keygen -o ~/.config/sops/age/keys.txt
# This creates a file containing both the public and private key:
# public key: age1abc123...
# AGE-SECRET-KEY-1xyz789...
# Note the public key -- you'll need it for SOPS configuration
cat ~/.config/sops/age/keys.txt | grep "public key"
Back up the private key. If you lose keys.txt, you permanently lose access to every file encrypted with it. Store a copy in a password manager, on a USB drive in a safe, or printed as a QR code.
Encrypting Config Files
Create a .sops.yaml Configuration
In your project or homelab repo root, create .sops.yaml to define which files get encrypted and with which keys:
# .sops.yaml
creation_rules:
# Encrypt all files in secrets/ directory
- path_regex: secrets/.*\.yaml$
age: age1abc123def456... # Your public key
# Encrypt .env files
- path_regex: \.env\.encrypted$
age: age1abc123def456...
# Multiple recipients (e.g., you + your deploy server)
- path_regex: deploy/.*\.yaml$
age: >-
age1abc123def456...,
age1second789key...
Encrypt a File
# Create a plaintext config
cat > secrets/database.yaml << 'EOF'
database:
host: 10.0.0.50
port: 5432
name: homelab
username: admin
password: super-secret-password-123
ssl: true
EOF
# Encrypt it in place
sops -e -i secrets/database.yaml
# Or encrypt to a new file
sops -e secrets/database.yaml > secrets/database.enc.yaml
Selective Encryption
By default, SOPS encrypts all values. You can control this with --encrypted-regex or --unencrypted-regex:
# Only encrypt keys matching "password", "secret", "key", or "token"
sops -e --encrypted-regex '^(password|secret|key|token|api_key)$' config.yaml
# Or set this in .sops.yaml:
creation_rules:
- path_regex: config/.*\.yaml$
age: age1abc123...
encrypted_regex: '^(password|secret|key|token)$'
This is useful when most of your config is non-sensitive and you want to keep it readable without the noise of ENC[...] everywhere.
Edit Encrypted Files
SOPS decrypts the file, opens it in your editor, and re-encrypts when you save:
# Edit an encrypted file (uses $EDITOR)
sops secrets/database.yaml
# The file opens decrypted in your editor
# Make changes, save, and quit -- SOPS re-encrypts automatically
Decrypt for Use
# Decrypt to stdout (for piping into other commands)
sops -d secrets/database.yaml
# Decrypt to a specific file
sops -d secrets/database.yaml > /tmp/database-decrypted.yaml
# Don't forget to clean up: rm /tmp/database-decrypted.yaml
# Extract a single value
sops -d --extract '["database"]["password"]' secrets/database.yaml
# Output: super-secret-password-123
Like what you're reading? Subscribe to HomeLab Starter — free weekly guides in your inbox.
Git Integration
Gitignore Plaintext, Commit Encrypted
The beauty of SOPS is that encrypted files are safe to commit. Your .gitignore only needs to exclude plaintext secrets and the age private key:
# .gitignore
# Never commit the private key
.config/sops/
keys.txt
# Never commit decrypted output
*.decrypted.yaml
*.decrypted.json
/tmp/
Pre-commit Hook
Add a pre-commit hook to prevent accidentally committing unencrypted secrets:
#!/bin/bash
# .git/hooks/pre-commit
# Check for unencrypted files in secrets/ directory
for file in $(git diff --cached --name-only | grep -E '^secrets/.*\.(yaml|json)$'); do
if ! grep -q "sops" "$file" 2>/dev/null; then
echo "ERROR: $file appears to be unencrypted. Run 'sops -e -i $file' first."
exit 1
fi
done
Diff Encrypted Files
Configure git to show meaningful diffs of encrypted files:
# .gitattributes
secrets/*.yaml diff=sopsdiffer
# .gitconfig (local or global)
[diff "sopsdiffer"]
textconv = sops -d
Now git diff will show the decrypted content, making code reviews actually useful.
CI/CD Integration
GitHub Actions
Store the age private key as a GitHub Actions secret, then decrypt during deployment:
# .github/workflows/deploy.yaml
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install SOPS and age
run: |
curl -LO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64
sudo mv sops-v3.9.4.linux.amd64 /usr/local/bin/sops && sudo chmod +x /usr/local/bin/sops
sudo apt install -y age
- name: Decrypt secrets
env:
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
run: |
sops -d secrets/production.yaml > /tmp/secrets.yaml
# Use the decrypted secrets in your deployment
- name: Deploy
run: |
# Your deployment commands here
# Reference /tmp/secrets.yaml as needed
Gitea Actions / Self-hosted CI
Same approach -- store the age private key as a CI secret and decrypt at deploy time. The key never touches disk in plaintext (it's passed via environment variable).
SOPS vs Ansible Vault
If you're already using Ansible, here's how SOPS compares:
| Feature | SOPS + age | Ansible Vault |
|---|---|---|
| Structure preservation | Keys visible, values encrypted | Entire file encrypted |
| Git diffs | Meaningful (see which keys changed) | Useless (encrypted blob) |
| Editor integration | sops file.yaml opens decrypted |
ansible-vault edit file.yaml |
| Partial encryption | Yes (regex-based) | No (entire file or per-variable) |
| Tool dependency | Standalone (works with anything) | Requires Ansible |
| Key management | age keys (simple files) | Password-based (or vault-id files) |
| Multiple recipients | Yes (multiple age public keys) | Yes (multiple vault IDs) |
| Performance | Fast (age is very fast) | Slower (Python-based) |
Choose SOPS + age if: you use multiple tools (Docker Compose, Kubernetes manifests, custom scripts) and want a universal solution. SOPS works with YAML, JSON, ENV, and INI files regardless of what tool consumes them.
Choose Ansible Vault if: your entire infrastructure is managed by Ansible and you don't need tool-agnostic secrets.
Best Practices
One key per environment. Generate separate age keys for development, staging, and production. This limits blast radius -- compromising the dev key doesn't expose production secrets.
Rotate keys periodically. SOPS makes key rotation straightforward:
# Add a new key to .sops.yaml, then re-encrypt all files
sops updatekeys secrets/database.yaml
Use encrypted_regex for mixed files. When a config file is mostly non-sensitive, encrypt only the sensitive fields. This keeps the file readable and diffs clean.
Never decrypt to disk in production. Use sops -d to pipe decrypted values directly into your application's environment or stdin. Decrypted files on disk are a liability.
Test your recovery process. At least once, pretend you've lost your machine and try to decrypt your secrets using only your backed-up age key. If you can't, fix your backup process before you actually need it.
