Tutorials
Tutorial: Encrypt a Secret
How to store an API key (or any secret) encrypted in the repository using Age and SOPS — decrypted automatically on dot apply.
The Threat Model
Secrets in plaintext config files are:
- Committed to Git history (permanent record)
- Visible to every process on the machine
- Included in backup archives
Secrets encrypted with Age:
- Cipher text committed to Git is useless without the private key
- Private key (
~/.config/age/keys.txt) never leaves the user's machine - Per-machine policy: only authorized hosts hold keys for their share of secrets
Step 1: Generate an Age Key (First Time Only)
The public key is embedded in the file:
# created: 2026-04-16T09:00:00Z
# public key: age1qy90l...xyz
Copy the public key — you'll reference it when encrypting.
Step 2: Choose Encryption Method
Two approaches are supported:
| Method | Best For | Filename |
|---|---|---|
| Chezmoi encrypt | Individual files (API tokens, config snippets) | dot_config/token.age |
| SOPS | YAML/JSON with multiple secrets, selective field encryption | dot_config/creds.sops.yaml |
Step 3A: Chezmoi-Encrypted File
Create an unencrypted source file:
Import it as encrypted:
This:
- Reads
~/tmp/stripe-key.txt - Encrypts with your Age public key
- Stores encrypted content at
~/.dotfiles/dot_tmp/stripe-key.txt.age - Template reads encrypted content at apply time and decrypts to target
Delete the plaintext source:
On dot apply, chezmoi decrypts and writes to ~/tmp/stripe-key.txt. If someone lacks the private key, they see only the encrypted .age file in Git.
Step 3B: SOPS-Encrypted YAML
SOPS encrypts values in YAML/JSON while preserving the structure. Useful when you want:
- Multiple secrets in one file
- Encryption of only sensitive fields (leaving metadata readable)
- Multi-recipient (e.g. the work laptop AND the backup laptop can decrypt)
Configure SOPS once:
# .sops.yaml (at repo root)
creation_rules:
- path_regex: \.sops\.yaml$
age: age1qy90l...xyz
Create a secrets file:
# dot_config/credentials.sops.yaml
database:
host: db.prod.example.com
user: app
password: supersecret
api_keys:
stripe: sk_live_abc
sendgrid: SG.xyz
Encrypt it:
The file now contains ciphertext blobs where plaintext values were:
database:
host: ENC[AES256_GCM,data:...]
user: ENC[AES256_GCM,data:...]
password: ENC[AES256_GCM,data:...]
api_keys:
stripe: ENC[AES256_GCM,data:...]
sendgrid: ENC[AES256_GCM,data:...]
sops:
age:
- recipient: age1qy90l...xyz
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
...
To edit the encrypted file:
# Opens in $EDITOR with decrypted content; re-encrypts on save
Step 4: Reference the Secret in a Template
Chezmoi has a built-in decrypt helper:
# dot_bashrc.tmpl
export STRIPE_KEY="{{ include "dot_tmp/stripe-key.txt.age" | decrypt | trim }}"
For SOPS:
# Use the sopsDecrypt template function
export DB_PASSWORD="{{ $creds.database.password }}"
Step 5: Commit and Verify
Verify the commit doesn't leak secrets:
| | ||
On the next machine:
# Chezmoi decrypts using that machine's Age key
# If decryption fails, the template errors with a clear message
Step 6: Multi-Recipient (Fleet)
To allow multiple hosts to decrypt the same secret, add more recipients:
# .sops.yaml
creation_rules:
- path_regex: \.sops\.yaml$
age: >-
age1qy90l...xyz,
age1z2x33...abc,
age1mm0kk...def
Re-encrypt the affected files:
Now any host with one of those three private keys can decrypt. Hosts without any matching key see the encrypted file but cannot read it.
Step 7: Rotate a Compromised Key
# 1. Generate a new Age key on the affected host
# 2. Update .sops.yaml with the new public key
# 3. Re-encrypt all SOPS files with the new recipient list
# 4. Commit and push
# 5. On other fleet hosts
Old key still works for existing ciphertext, but new secrets use the new key. To force deprecation, delete the old key from .sops.yaml and sops updatekeys all files.
Verifying No Plaintext Leaks
CI runs three secret scanners (gitleaks, detect-secrets, trufflehog) on every commit. You can run them locally:
If any scanner finds a potential leak, the commit is blocked.
Operational Rules
- Never commit plaintext secrets — use chezmoi encrypt or SOPS
- Never commit
~/.config/age/keys.txt— it's gitignored; double-check before pushing - Age keys are per-host — don't copy them via Git; use secure channels (1Password, Bitwarden, physical USB)
- Rotate on team membership changes — when someone leaves, update recipients and rotate all shared secrets
- Audit with
dot verify --security— runs gitleaks + signature + policy hash checks
Troubleshooting
"Could not decrypt: no age key found"
~/.config/age/keys.txt is missing or unreadable:
# -rw------- 1 user user 189 Apr 16 09:00 keys.txt
"sops: file encrypted with outdated recipients"
A secret file has an old recipient list. Fix:
Secrets Not Applying After Apply
Check that dot_tmp/stripe-key.txt.age is committed. Run:
# Will show decrypt attempts and errors
Next
- Concept: Trust Model — the security architecture
- Reference: Secret operations
- Security: Secret management