
Sops Setup
- 3 installs
- 21 repo stars
- Updated August 5, 2026
- joaquimscosta/arkhe-claude-plugins
Interactive wizard that installs SOPS and age, generates keys, creates .sops.yaml, and encrypts .env files as YAML for secure sharing across machines.
About
Sets up SOPS plus age encryption for sharing .env files across machines, detecting state, installing tools, generating keys, and encrypting as YAML. A developer uses it to bootstrap secure secret sharing in a project.
- Generates age keys and creates .sops.yaml
- Encrypts as YAML to avoid the SOPS dotenv bug
Sops Setup by the numbers
- 3 all-time installs (skills.sh)
- Ranked #1,752 of 2,203 Security skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/joaquimscosta/arkhe-claude-plugins --skill sops-setupAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 21 |
| Last updated | August 5, 2026 |
| Repository | joaquimscosta/arkhe-claude-plugins ↗ |
What it does
Interactive wizard that installs SOPS and age, generates keys, creates .sops.yaml, and encrypts .env files as YAML for secure sharing across machines.
Files
SOPS + age Setup Wizard
Interactive setup for SOPS + age encryption. Detects current state and guides through configuration.
Important: This skill uses YAML format for encrypted files (not dotenv) because SOPS has a known bug (#1435) where the dotenv store corrupts backslash and \n sequences on decrypt. A helper script handles dotenv↔YAML conversion transparently.
Pre-flight
Run the detection script to understand current state:
python3 ${CLAUDE_SKILL_DIR}/scripts/detect_sops.py <project-root>Two-Phase Workflow
Phase 1: Detect
1. Run the detector on the project root:
python3 ${CLAUDE_SKILL_DIR}/scripts/detect_sops.py <project-root>2. Summarize findings — show a status table:
| Component | Status | Detail |
|---|---|---|
| sops binary | installed/missing | version, path |
| age binary | installed/missing | version, path |
| age key | exists/missing | public key (truncated) |
| .sops.yaml | exists/missing | # of authorized keys |
| .env files | N found | list of filenames |
| encrypted files | N found | list of *.enc.yaml files |
| .gitignore | ok/needs update | env ignored, enc.yaml not ignored |
Phase 2: Configure
Walk through each missing component. Skip steps where detection shows everything is already configured.
1. Install tools (if tools.sops.installed or tools.age.installed is false):
- Show install commands based on
osfield: - macOS:
brew install sops age - Linux: Show download URLs for latest binaries from GitHub releases
- After user confirms installation, re-run detector to verify
2. Generate age key (if age_key.exists is false):
- Create directory:
mkdir -p ~/.config/sops/age - Generate key:
age-keygen -o ~/.config/sops/age/keys.txt - Set permissions:
chmod 600 ~/.config/sops/age/keys.txt - Display the public key and tell user to save it somewhere safe (password manager, secure note)
3. Generate backup key (recommended):
- Use
AskUserQuestion— offer to create an offline backup key for disaster recovery - If yes:
age-keygen 2>&1 | tee /dev/stderr | grep "public key:" | awk '{print $NF}'Display BOTH the public key AND the full output (which includes the private key). Tell user: Copy the entire output (including the private key line starting with AGE-SECRET-KEY-) to a password manager or secure offline storage. This is your recovery key.
- The backup public key will be added to
.sops.yamlalongside the machine key
4. Create `.sops.yaml` (if project.sops_yaml.exists is false):
- Write
.sops.yamlwith machine key + backup key (if generated):
creation_rules:
- path_regex: (^|/)\.env\.[^/]+\.enc\.yaml$
age: >-
<machine-public-key>,
<backup-public-key>- If
.sops.yamlalready exists but is missing this machine's key, offer to add it
5. Set up `.gitattributes` for diff-friendly encrypted files:
- Add to
.gitattributes:
*.enc.yaml diff=sopsdiffer- Configure git:
git config diff.sopsdiffer.textconv "sops decrypt"- This makes
git diffshow decrypted content for encrypted files
6. Update `.gitignore` (if needed):
- Ensure
.env*patterns are ignored (secrets must not be committed in plaintext) - Ensure
*.enc.yamlis NOT ignored (encrypted files should be committed) - Show proposed changes and confirm with user before writing
7. Encrypt files (if project.env_files is non-empty):
- Use
AskUserQuestion(multiSelect: true) — show detected.env*files - For each selected file, convert dotenv→YAML then encrypt:
python3 ${CLAUDE_SKILL_DIR}/scripts/dotenv_yaml.py to-yaml <file> > <file>.enc.yaml.tmp
sops --encrypt <file>.enc.yaml.tmp > <file>.enc.yaml
rm <file>.enc.yaml.tmpExample: .env.local → .env.local.enc.yaml
- Verify each encrypted file was created successfully
8. Confirmation summary — show table of all actions taken:
| Step | Action | Result |
|------|--------|--------|
| Tools | sops 3.9.4, age 1.2.0 | installed |
| Key | Machine key generated | age1abc...def |
| Key | Backup key generated | age1xyz...uvw (save offline!) |
| Permissions | chmod 600 keys.txt | done |
| Config | .sops.yaml created | 2 keys authorized |
| Git | .gitattributes updated | sopsdiffer configured |
| Git | .gitignore updated | .env* ignored |
| Encrypt | .env.local → .env.local.enc.yaml | done |
## Next Steps
- Commit .sops.yaml, .gitattributes, and *.enc.yaml files to git
- On another machine: clone, install sops+age, place age key, run /devtools:sops-decrypt
- To add another machine: /devtools:sops-add-key
- To encrypt after editing .env: /devtools:sops-encrypt
- To decrypt after pulling: /devtools:sops-decryptKey Rules
- Never overwrite existing files without asking. Always offer merge/replace/skip.
- Detect first — skip steps that are already configured.
- Use `AskUserQuestion` for every decision. Do not assume user preferences.
- YAML format only — never use
--input-type dotenv. Use the dotenv_yaml.py helper for conversion. - chmod 600 on age key files immediately after creation.
- Display public keys after generation — user needs them for multi-machine setup.
- Verify after each step — re-run relevant checks to confirm success.
References
- Workflow: See WORKFLOW.md for detailed per-step flows
- Examples: See EXAMPLES.md for example setup sessions
- Troubleshooting: See TROUBLESHOOTING.md for common issues
- Detection Script: See scripts/detect_sops.py for detection logic
- Converter: See scripts/dotenv_yaml.py for dotenv↔YAML conversion
- Best Practices: See references/sops-best-practices.md for research
SOPS Setup Examples
Example 1: Fresh Setup (Nothing Installed)
User: /devtools:sops-setup
Detection output:
{
"tools": {
"sops": { "installed": false },
"age": { "installed": false }
},
"age_key": { "exists": false, "expected_path": "/home/user/.config/sops/age/keys.txt" },
"project": {
"sops_yaml": { "exists": false },
"env_files": [".env.local"],
"encrypted_files": [],
"gitignore": { "exists": true, "ignores_env": false, "ignores_encrypted": false }
},
"os": "macos"
}Status table:
| Component | Status | Detail |
|-----------|--------|--------|
| sops | missing | — |
| age | missing | — |
| age key | missing | expected: ~/.config/sops/age/keys.txt |
| .sops.yaml | missing | — |
| .env files | 1 found | .env.local |
| encrypted | 0 found | — |
| .gitignore | needs update | .env* not ignored |Flow: 1. Install tools → brew install sops age 2. Generate machine key → displays public key age1abc..., sets chmod 600 3. Generate backup key → displays full key output, user saves to 1Password 4. Create .sops.yaml → machine key + backup key as recipients 5. Set up .gitattributes → adds sopsdiffer for readable diffs 6. Update .gitignore → adds .env* rules 7. Encrypt → converts .env.local to YAML → encrypts → .env.local.enc.yaml 8. Summary with next steps
---
Example 2: Tools Installed, Adding to New Project
User: /devtools:sops-setup
Detection output:
{
"tools": {
"sops": { "installed": true, "version": "3.9.4" },
"age": { "installed": true, "version": "1.2.0" }
},
"age_key": {
"exists": true,
"public_key": "age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p"
},
"project": {
"sops_yaml": { "exists": false },
"env_files": [".env.local", ".env.production"],
"encrypted_files": [],
"gitignore": { "exists": true, "ignores_env": true, "ignores_encrypted": false }
},
"os": "linux"
}Flow: 1. Tools installed → skip (shows versions) 2. Age key exists → skip (shows truncated public key) 3. Backup key → user says "Skip" (already has one from previous project) 4. Create .sops.yaml → machine key only 5. Set up .gitattributes → adds sopsdiffer 6. .gitignore already ignores .env* → skip 7. Encrypt → user selects both → .env.local.enc.yaml and .env.production.enc.yaml 8. Summary — 4 actions (config, gitattributes, encrypt 2 files)
---
Example 3: Second Machine Joining Existing Project
The project already has .sops.yaml and encrypted files. A new machine clones and runs setup.
Detection output:
{
"tools": {
"sops": { "installed": true, "version": "3.9.4" },
"age": { "installed": true, "version": "1.2.0" }
},
"age_key": {
"exists": true,
"public_key": "age1newmachine..."
},
"project": {
"sops_yaml": {
"exists": true,
"authorized_keys": ["age1originalmachine..."],
"key_count": 1
},
"env_files": [],
"encrypted_files": ["apps/web/.env.local.enc.yaml"],
"gitignore": { "exists": true, "ignores_env": true, "ignores_encrypted": false }
},
"os": "macos"
}Flow: 1. Tools installed → skip 2. Age key exists → skip 3. .sops.yaml exists but missing this machine's key → offer to add 4. User confirms → key added to .sops.yaml 5. Attempt to decrypt → fails because files were encrypted before this key was added 6. Tell user: "On the original machine, run /devtools:sops-add-key with your public key (age1newmachine...), then pull the re-encrypted files and run /devtools:sops-decrypt"
SOPS + age Best Practices for Securing .env Files
Researched: 2026-03-20 Sources: getsops.io official docs (v3.12.1), getsops/sops GitHub issues and discussions, GitGuardian, Flux CD docs, Techno Tim, Hey Linux, OneUptime engineering guides
---
Overview
SOPS (Secrets OPerationS) encrypts individual values within structured files (YAML, JSON, ENV, INI) while leaving keys in plaintext. This produces git-diff-friendly output — reviewers can see which variable changed without seeing its value. age is the recommended encryption backend: it is simple, modern, requires no infrastructure, and generates small keys with a single command.
SOPS works by generating a random per-file Data Encryption Key (DEK), encrypting each value with AES256-GCM using that DEK, then encrypting the DEK itself with your master key (age, GPG, AWS KMS, etc.). The encrypted DEK and metadata are stored inline in the sops block of the file. The master key (your age private key) never touches the actual secret values — only the DEK.
---
1. .sops.yaml Configuration Best Practices
Basic Structure
Place .sops.yaml at the repository root. SOPS walks up the directory tree from the file being encrypted until it finds a .sops.yaml.
creation_rules:
- path_regex: \.env(\.encrypted)?$
age: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8pRule Matching Order
Rules are evaluated top to bottom; the first match wins. Always place more specific rules before general ones:
creation_rules:
# Most specific first: production secrets
- path_regex: environments/production/.*\.enc\.yaml$
age: >-
age1prod...,
age1backup...
# Staging with different key
- path_regex: environments/staging/.*\.enc\.yaml$
age: age1staging...
# Catch-all for all other YAML
- path_regex: .*\.enc\.yaml$
age: age1dev...Recommended path_regex Patterns for .env Files
The following patterns are commonly used for .env file matching:
# Match .env exactly (dotenv store auto-detected by filename)
- path_regex: (^|/)\.env$
# Match .env.encrypted (common naming convention)
- path_regex: (^|/)\.env\.encrypted$
# Match .env.enc (shorter convention)
- path_regex: (^|/)\.env\.enc$
# Match any .env variant: .env, .env.local, .env.production, etc.
- path_regex: (^|/)\.env(\.[a-zA-Z0-9]+)?$
# Match secrets files stored as YAML (preferred approach - see Section 3)
- path_regex: secrets(\.encrypted)?\.yaml$
# Named convention used in some teams
- path_regex: /*secrets(\.encrypted)?\.yaml$A widely cited real-world pattern from dfrojas.com:
creation_rules:
- path_regex: /*secrets(\.encrypted)?.yaml$
age: <age-public-key>Key Groups vs. Single age Recipients
Single recipient (solo developer or single environment):
creation_rules:
- path_regex: .*\.enc\.yaml$
age: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8pMultiple recipients (any one key can decrypt — OR logic):
creation_rules:
- path_regex: .*\.enc\.yaml$
age: >-
age1developer...,
age1cicd...,
age1backup...Key groups with Shamir's Secret Sharing (N-of-M required — AND logic):
creation_rules:
- path_regex: secrets/production/.*\.yaml$
shamir_threshold: 2
key_groups:
- age:
- age1teamlead...
- age:
- age1seniordev...
- age:
- age1backup...With Shamir threshold 2 of 3, any two of the three key holders must participate to decrypt. This is appropriate for high-security production secrets. For most developer workflows, the multi-recipient (OR) approach is sufficient and far simpler to operate.
Recommendation: Use multiple recipients (OR) with one key per developer/machine plus a dedicated CI/CD key and a backup key. Reserve Shamir groups for compliance-critical production secrets.
Additional .sops.yaml Options
Control which fields get encrypted (useful for YAML but irrelevant for dotenv format):
creation_rules:
- path_regex: .*secrets.*\.yaml$
age: age1...
encrypted_regex: ^(data|stringData|password|secret|token|key|credential)$
# OR use encrypted_suffix to encrypt only keys ending with _secret:
# encrypted_suffix: _secretFor store-level formatting:
stores:
yaml:
indent: 2
json:
indent: 2
creation_rules:
- ...---
2. age Key Management
Default Storage Locations
SOPS looks for the age private key file at:
| Platform | Default path |
|---|---|
| Linux | $XDG_CONFIG_HOME/sops/age/keys.txt → $HOME/.config/sops/age/keys.txt |
| macOS | $XDG_CONFIG_HOME/sops/age/keys.txt → $HOME/Library/Application Support/sops/age/keys.txt |
| Windows | %AppData%\sops\age\keys.txt |
The file format is one age X25519 identity per line; lines beginning with # are comments and ignored.
Generating a Key
age-keygen -o ~/.config/sops/age/keys.txt
# Output: Public key: age1ql3z7hjy54pw3...Or generate to a named file for multi-key setups:
age-keygen -o ~/keys/project-dev.agekeyFile Permissions
Set permissions to owner-read-only immediately after generation. This is critical — any other process running as your user can read the key if permissions are left open:
chmod 600 ~/.config/sops/age/keys.txt
# Verify
ls -la ~/.config/sops/age/keys.txt
# Should show: -rw------- 1 user group ...Overriding Key Location
# Point to a specific file
export SOPS_AGE_KEY_FILE=/path/to/key.agekey
# Or pass the key value directly (useful in CI/CD)
export SOPS_AGE_KEY="AGE-SECRET-KEY-1..."
# Or use a command to retrieve the key (e.g., from a vault)
export SOPS_AGE_KEY_CMD="op read op://vault/sops-age-key/credential"Multi-Machine Workflow
Two valid approaches:
Option A: Per-machine keys (recommended for teams) Each machine or developer generates their own key. All public keys are listed in .sops.yaml. Files are encrypted for all recipients simultaneously.
# Developer Alice on machine 1
age-keygen -o ~/.config/sops/age/keys.txt
# Note public key: age1alice...
# Developer Bob on machine 2
age-keygen -o ~/.config/sops/age/keys.txt
# Note public key: age1bob...In .sops.yaml:
creation_rules:
- path_regex: secrets.*\.yaml$
age: >-
age1alice...,
age1bob...,
age1cicd...Anyone with any of these private keys can decrypt. To add a new machine, add the new public key to .sops.yaml, then run sops updatekeys on all encrypted files.
Option B: Shared key (solo developer, multiple machines) Generate one key, store it securely (password manager, 1Password, Bitwarden), and retrieve it on each machine. Less auditable but simpler for single-developer projects.
# Store in 1Password, then retrieve on new machine:
op read "op://Personal/SOPS Age Key/key" > ~/.config/sops/age/keys.txt
chmod 600 ~/.config/sops/age/keys.txtKey Backup Strategy
Private keys are the single point of failure. If lost, all encrypted files are permanently unreadable.
Recommended backup approaches (in order of preference): 1. Password manager (1Password, Bitwarden, KeePassXC) — store the full keys.txt content as a secure note 2. Offline hardware — print the key or write it to an encrypted USB drive stored physically separate from your machines 3. Dedicated backup age recipient — generate a separate backup key, include its public key in all .sops.yaml rules, store the backup private key offline. If your primary key is lost, you can still decrypt with the backup.
# Include a backup recipient in all rules
creation_rules:
- path_regex: .*secrets.*\.yaml$
age: >-
age1primary...,
age1backup-stored-offline...Never commit private keys to git. Add to .gitignore:
*.agekey
keys.txt
# but NOT .sops.yaml — that should be committedCI/CD Key Management
CI/CD environments should have their own dedicated age key — never reuse developer keys:
# Generate a CI/CD-specific key
age-keygen -o ci-key.agekey
cat ci-key.agekey # Note public key and store private key in CI secret managerStore the private key as a CI secret (GitHub Actions Secret, GitLab CI Variable, etc.), then reference at runtime:
# In CI pipeline
echo "$SOPS_AGE_PRIVATE_KEY" > /tmp/age-key.txt
export SOPS_AGE_KEY_FILE=/tmp/age-key.txt
sops decrypt secrets.enc.yaml
rm /tmp/age-key.txt # Clean up immediately after use---
3. Encryption Workflow
Dotenv Native Format vs. YAML — Which to Use
This is a critical decision with significant practical implications.
SOPS dotenv store limitations (known issues as of SOPS 3.x):
1. Not roundtrip-safe (GitHub issue #1435, reported by SOPS maintainer felixfontein): The dotenv store escapes newlines as literal \n but does not escape backslashes themselves. This means \n in a value and a literal newline cannot be distinguished on round-trip. 2. Quote stripping: SOPS dotenv handling strips quotes during encryption (e.g., ID="123#567" becomes ID=123#567 after decrypt), breaking dotenv parsers that rely on quoted values with special characters. 3. Comment handling is fragile: Inline comments and comments in non-dotenv-named files (e.g., .env.example) require explicit --input-type dotenv --output-type dotenv flags; without them the encrypted output defaults to JSON and comments are lost. 4. No spec compatibility: The SOPS dotenv parser is primitive — it assumes all keys are strings without =, does not support the full dotenv spec (multiline values, exports, etc.). 5. Extension detection: A file named .env is auto-detected as dotenv format. Files named secrets.env or .env.encrypted may not be detected correctly and require explicit --input-type dotenv.
Recommendation: prefer YAML over native dotenv format.
Store secrets as YAML and generate the .env file at runtime (during deployment or local dev setup). This avoids all dotenv store limitations and gains the full power of YAML processing (comments, structured data, encrypted_regex).
# secrets.enc.yaml (stored in git, encrypted)
DATABASE_URL: postgres://user:pass@host/db
API_KEY: sk-abc123
REDIS_URL: redis://localhost:6379Generate .env at runtime:
sops decrypt secrets.enc.yaml | python3 -c "
import sys, yaml
data = yaml.safe_load(sys.stdin)
for k, v in data.items():
if k != 'sops':
print(f'{k}={v}')
" > .envOr use SOPS exec-env for direct injection without writing to disk:
sops exec-env secrets.enc.yaml 'your-command-here'When dotenv Format IS Appropriate
If your tooling strictly requires committing a .env-style file, use dotenv format but be aware of the limitations:
# Encrypt a .env file using dotenv store
sops encrypt --input-type dotenv --output-type dotenv .env > .env.enc
# Decrypt
sops decrypt --input-type dotenv --output-type dotenv .env.enc > .envAlways specify both --input-type and --output-type explicitly when working with dotenv files that do not have the .env extension, or when piping:
sops encrypt --input-type dotenv --output-type dotenv --in-place .env.encryptedFile Naming Conventions
Common conventions observed in the wild, with trade-offs:
| Convention | Example | Notes |
|---|---|---|
.env.encrypted | .env.encrypted | Clear semantic meaning; SOPS may not auto-detect dotenv format |
.env.enc | .env.enc | Shorter; same auto-detection caveat |
secrets.enc.yaml | secrets.enc.yaml | Recommended — YAML format, auto-detected, diff-friendly |
secrets.sops.yaml | secrets.sops.yaml | Common in Kubernetes/Flux workflows |
secrets.yaml | secrets.yaml | Simple; gitignore the plaintext, commit the encrypted version separately |
Recommended convention: Use .enc.yaml suffix (e.g., secrets.enc.yaml) to make it unambiguous that a file is SOPS-encrypted and to leverage YAML format. The .enc. infix is a strong visual signal and is easy to gitignore the non-encrypted counterpart.
---
4. Git Integration
What to Commit vs. What to Gitignore
Commit to git:
.sops.yaml— encryption rules, public keys, path patterns*.enc.yaml,secrets.enc.yaml,.env.encrypted— encrypted secret files.gitattributes(if using SOPS diff driver)
Gitignore (never commit):
*.agekey,keys.txt, any file containing a private key.env,*.env.local— plaintext secret filessecrets.yaml(if using the pattern of separate plaintext + encrypted files)
Example .gitignore additions:
# Age private keys - NEVER commit
*.agekey
keys.txt
# Plaintext secrets - encrypt before committing
.env
.env.local
.env.*.local
secrets.yaml
# But DO commit encrypted versions:
# !secrets.enc.yaml (no negation needed if only encrypted files exist)Git Diff Integration
SOPS can show meaningful diffs by decrypting both versions before diffing. Add to .gitattributes:
*.enc.yaml diff=sopsdiffer
secrets.enc.yaml diff=sopsdiffer
.env.encrypted diff=sopsdifferConfigure the git diff driver once per machine:
git config diff.sopsdiffer.textconv "sops decrypt"Now git diff and git log -p show decrypted diffs. This works with GUI git clients too since they call git diff internally. Note: requires the decryption key to be present locally.
Pre-Commit Hooks
Option A: pre-commit framework with gitleaks (catch any accidentally staged secret):
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.0
hooks:
- id: gitleaks
- repo: local
hooks:
- id: check-sops-encrypted
name: Verify .env files are not staged in plaintext
entry: bash -c 'git diff --cached --name-only | grep -E "^\.env$|/\.env$" && echo "ERROR: Plaintext .env file staged for commit" && exit 1 || exit 0'
language: system
pass_filenames: falseOption B: squat/pre-commit-sops (validates that SOPS-managed files are actually encrypted):
repos:
- repo: https://github.com/squat/pre-commit-sops
rev: 0.1.0
hooks:
- id: sops
files: '(secrets|\.env\.encrypted)'Option C: yuvipanda/pre-commit-hook-ensure-sops (any file with secret in its path must be SOPS-encrypted):
repos:
- repo: https://github.com/yuvipanda/pre-commit-hook-ensure-sops
rev: v1.0
hooks:
- id: sops-encryptionOption D: custom shell hook (bare-minimum, no dependencies):
Create .git/hooks/pre-commit (make executable with chmod +x):
#!/bin/bash
# Block commits of plaintext .env files
if git diff --cached --name-only | grep -qE '(^|/)\.env$'; then
echo "ERROR: Plaintext .env file staged. Encrypt with SOPS first."
exit 1
fi
# Optionally check that .env.enc / secrets.enc.yaml contain the sops metadata marker
for f in $(git diff --cached --name-only | grep -E '\.enc\.(yaml|json)$'); do
if ! grep -q '"sops":' "$f" && ! grep -q 'sops:' "$f"; then
echo "ERROR: $f does not appear to be SOPS-encrypted"
exit 1
fi
doneImportant git history note: If a plaintext secret is ever committed, the secret is in git history even after deletion. Use git filter-repo (or BFG Repo-Cleaner) to purge it, then rotate the exposed credential immediately. Pre-commit hooks prevent this but are bypassable with git commit --no-verify — defense in depth (gitleaks CI scan, branch protection) is required.
---
5. Security Considerations
What Metadata is Exposed (in Plaintext)
The sops block appended to every encrypted file contains the following in cleartext:
sops:
age:
- recipient: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
...
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-03-20T10:00:00Z"
mac: ENC[AES256_GCM,...]
version: 3.12.1Exposed metadata:
- Recipient public keys — reveals which age public keys can decrypt the file (fingerprints are public by design, but this identifies who has access)
- Modification timestamp — reveals when the file was last changed
- SOPS version — can help target version-specific vulnerabilities
- Encrypted DEK — the per-file data key, encrypted with your master key (safe as long as the master key is not compromised)
For KMS backends: KMS ARNs, GCP resource IDs, and Azure vault URLs are also exposed. These reveal cloud account structure.
Key names are always in plaintext. If your variable names are themselves sensitive (e.g., STRIPE_LIVE_SECRET_KEY), the name reveals the type of credential even if the value is encrypted.
Common Mistakes
1. Committing the age private key. The most catastrophic mistake. Add *.agekey and keys.txt to .gitignore and enforce this with pre-commit hooks.
2. No backup of the age private key. If the key is lost, all encrypted files are permanently unreadable. Always have at least one backup copy in a password manager or offline secure storage.
3. Reusing the same key across all environments. A compromised dev key then compromises prod secrets. Use separate keys per environment.
4. Forgetting to run `sops updatekeys` after adding a new developer. New team members listed in .sops.yaml cannot decrypt existing files until all existing encrypted files are updated. This is a silent failure — the file looks valid but won't decrypt.
5. Using `--in-place` on a plaintext file without keeping a backup. SOPS --in-place overwrites the file. If encryption fails mid-operation, the file may be corrupted. Always test with a copy first or ensure the plaintext is committed or otherwise recoverable.
6. Storing age keys inside the project directory with broad permissions. Keys stored as ./key.agekey in a project root can be accidentally committed or read by other processes. Store keys in ~/.config/sops/age/keys.txt with chmod 600.
7. Using unencrypted_comment_regex carelessly. A bug-class exists (GitHub issue #1672) where unencrypted_comment_regex can unexpectedly leave entire sections unencrypted rather than just the adjacent line. Test the output visually after encryption.
8. Dotenv roundtrip data loss. Using dotenv format for values containing backslashes or \n sequences leads to data corruption on decrypt due to the known roundtrip bug. Use YAML format instead.
9. Not verifying MAC. SOPS authenticates the entire file structure with a MAC. Never manually edit encrypted SOPS files with a text editor — use sops edit instead, which handles decryption, editing, and re-encryption atomically.
10. Key service without authentication. The SOPS keyservice forwarding mode has no authentication or encryption. Only use it over a trusted channel (e.g., an SSH tunnel), never over an untrusted network.
Cryptographic Properties
- Each encrypted value uses AES256-GCM with a unique IV — semantically secure
- The MAC is computed over the entire key tree (concatenated key names provide AAD), preventing structural tampering
- Value length is preserved in ciphertext (known limitation, GitHub issue #815) — observable to anyone with access to the encrypted file
- The KMS/age master key only encrypts the DEK (~32 bytes), not the secrets themselves — this is the standard "envelope encryption" pattern
---
6. .sops.yaml path_regex Reference
All patterns use Go regexp syntax (RE2 engine — no lookaheads).
Patterns for .env Files
# Exact filename .env at any directory depth
- path_regex: (^|/)\.env$
# .env with any suffix (covers .env.local, .env.production, .env.test, etc.)
- path_regex: (^|/)\.env(\.[^/]+)?$
# Explicit encrypted variants
- path_regex: (^|/)\.env\.encrypted$
- path_regex: (^|/)\.env\.enc$
# Any file ending in .env.yaml or .env.enc.yaml
- path_regex: \.env(\.enc)?\.yaml$
# secrets.yaml / secrets.enc.yaml anywhere in the tree
- path_regex: (^|/)secrets(\.enc)?\.yaml$
# Kubernetes-style: matches *.sops.yaml
- path_regex: .*\.sops\.yaml$
# Files under a secrets/ directory
- path_regex: (^|/)secrets/.*\.(yaml|json)$Environment-Based Patterns
creation_rules:
# Production: two keys required (Shamir) or both recipients
- path_regex: (^|/)production/.*\.enc\.yaml$
age: >-
age1prod-primary...,
age1prod-backup...
# Staging: one key
- path_regex: (^|/)staging/.*\.enc\.yaml$
age: age1staging...
# Development: dev key only
- path_regex: (^|/)development/.*\.enc\.yaml$
age: age1dev...
# Catch-all: encrypt everything else with dev key
- path_regex: .*\.enc\.yaml$
age: age1dev...Pattern Matching Notes
- Patterns match against the full file path as seen from the
.sops.yamllocation - The path is normalized with forward slashes on all platforms
- Omitting
path_regexentirely creates a catch-all that matches all files - First matching rule wins — ordering is significant
- Be careful with overly broad patterns like
.*\.yaml$— this will match.sops.yamlitself if you run SOPS from the repo root
---
7. sops updatekeys vs. Decrypt-then-Re-encrypt
sops updatekeys (Recommended)
updatekeys reads the current .sops.yaml configuration and synchronizes the recipient list in each encrypted file without changing the per-file data key or re-encrypting any values.
Specifically:
- Re-wraps the existing DEK for new recipients (adds new
encblocks) - Removes
encblocks for recipients no longer in.sops.yaml - The encrypted values themselves are unchanged
- The MAC is unchanged (since the data key and values are unchanged)
- Produces a minimal diff — only the
sops.ageblock changes
# Update a single file
sops updatekeys secrets.enc.yaml
# Non-interactive (for scripts)
sops updatekeys -y secrets.enc.yaml
# Update all encrypted files in a repo
find . -name "*.enc.yaml" -exec grep -l "sops:" {} \; | \
xargs -I{} sops updatekeys -y {}Workflow for adding a new team member: 1. New member generates age-keygen -o ~/.config/sops/age/keys.txt and shares public key 2. Update .sops.yaml to add their public key 3. Run sops updatekeys -y on all affected encrypted files 4. Commit the updated encrypted files — they now include an additional DEK copy for the new recipient
sops rotate (Full Re-encryption)
sops rotate generates a new DEK and re-encrypts every value. Every encrypted field in the file changes, producing a large diff.
# Rotate data key in place (all values get new ciphertext)
sops rotate -i secrets.enc.yaml
# Rotate and simultaneously add/remove recipients
sops rotate -i --add-age age1newkey... --rm-age age1oldkey... secrets.enc.yamlWhen to use rotate instead of updatekeys:
- A key is compromised — the old key holder may have cached the old DEK. Rotating generates a completely new DEK that the old holder cannot derive.
- You want to re-randomize all IVs and ciphertext for audit/compliance reasons.
- The official docs note: when removing a key, "it is recommended to rotate the data key using
-r, otherwise, owners of the removed key may have had access to the data key in the past."
Decision Guide
| Scenario | Use |
|---|---|
| Adding a new team member | updatekeys |
| Onboarding a new machine | updatekeys |
| Removing a departing team member (non-security incident) | updatekeys then later rotate |
| Key was compromised | rotate immediately |
| Periodic security hygiene rotation | rotate |
| CI/CD key rotation (routine) | updatekeys |
Key Rotation Workflow (Full Rotation)
# Phase 1: Generate new key, add to .sops.yaml alongside old key
age-keygen -o new-key.agekey
# Edit .sops.yaml to include both old and new public keys
# Phase 2: Update all files to include new key (both keys can decrypt)
find . -name "*.enc.yaml" -exec grep -l "sops:" {} \; | \
xargs -I{} sops updatekeys -y {}
# Verify decryption works with new key
SOPS_AGE_KEY_FILE=new-key.agekey sops decrypt secrets.enc.yaml
# Phase 3: Remove old key from .sops.yaml and re-encrypt with new key only
# Edit .sops.yaml to remove old key
find . -name "*.enc.yaml" -exec grep -l "sops:" {} \; | \
xargs -I{} sops updatekeys -y {}
# Optional: rotate data key too (if old key may have been compromised)
find . -name "*.enc.yaml" -exec grep -l "sops:" {} \; | \
xargs -I{} sops rotate -i {}
# Commit all changes
git add .sops.yaml **/*.enc.yaml
git commit -m "chore(secrets): rotate SOPS age keys"---
8. Summary of Recommendations
| Decision | Recommendation |
|---|---|
| Encryption backend | age (simpler than GPG, no infrastructure vs. cloud KMS) |
| File format for secrets | YAML (.enc.yaml) — avoid dotenv format due to roundtrip bugs |
| File naming | secrets.enc.yaml or *.sops.yaml — unambiguous, diff-friendly |
| Key storage | ~/.config/sops/age/keys.txt with chmod 600 |
| Key backup | Password manager + offline copy; include a backup recipient in all rules |
| Multi-developer access | Multiple recipients (OR logic) in .sops.yaml — one key per developer |
| Environments | Separate age keys per environment; never share prod key with dev |
| CI/CD | Dedicated CI key stored as CI secret; inject via SOPS_AGE_KEY env var |
| Adding recipient | sops updatekeys -y |
| Compromised key | sops rotate -i after removing old key from .sops.yaml |
| Git diff | .gitattributes with diff=sopsdiffer driver |
| Preventing accidents | pre-commit hooks (gitleaks + custom check) |
.sops.yaml | Commit it — it contains only public keys and rules, no secrets |
---
References
- SOPS Official Documentation — getsops.io
- getsops/sops GitHub — source and issues (v3.12.1 latest as of 2026-02-22)
- GitGuardian: A Comprehensive Guide to SOPS — 2024-09-05
- SOPS dotenv roundtrip issue #1435 — felixfontein, 2024-02-10
- SOPS dotenv docs request #1818 — 2025-03-29
- Rotating SOPS Keys - Techno Tim — 2023-03-05
- Using SOPS + age to Encrypt Files - Hey! Linux — 2026-02
- How to Rotate SOPS Age Keys Without Re-Encrypting All Files — 2026-03-13
- How to Configure SOPS Creation Rules in .sops.yaml — 2026-03-13
- SOPS Pre-Commit Hooks for Flux — 2026-03-13
- squat/pre-commit-sops — pre-commit hook for SOPS validation
- yuvipanda/pre-commit-hook-ensure-sops — pre-commit hook for SOPS enforcement
- My recipe: SOPS + age - dfrojas.com
- sops updatekeys discussion #919 — felixfontein batch updatekeys command
- Encrypted data reveals ciphertext length #815 — known metadata leakage
#!/usr/bin/env python3
"""
Detect SOPS + age environment configuration.
Outputs JSON report of installed tools, age keys, project config,
and .env files. Used by sops-setup skill to determine current state.
Uses only standard library (no external dependencies).
Usage:
python3 detect_sops.py [project_root]
"""
import json
import os
import platform
import re
import shutil
import subprocess
import sys
from pathlib import Path
def run_cmd(cmd):
"""Run a command and return stdout, or None on failure."""
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=10
)
if result.returncode == 0:
return result.stdout.strip()
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
pass
return None
def detect_tool(name):
"""Check if a tool is installed and get its version."""
path = shutil.which(name)
if not path:
return {"installed": False}
info = {"installed": True, "path": path}
version_output = run_cmd([name, "--version"])
if version_output:
# Extract version number from output
match = re.search(r"(\d+\.\d+\.\d+)", version_output)
if match:
info["version"] = match.group(1)
else:
info["version_raw"] = version_output.splitlines()[0]
return info
def detect_age_key():
"""Check for age key file and extract public key."""
# age uses XDG_CONFIG_HOME or platform default
config_home = os.environ.get("XDG_CONFIG_HOME")
if not config_home:
if platform.system() == "Darwin":
config_home = str(Path.home() / "Library" / "Application Support")
else:
config_home = str(Path.home() / ".config")
key_path = Path(config_home) / "sops" / "age" / "keys.txt"
# Also check SOPS_AGE_KEY_FILE env var
env_key_path = os.environ.get("SOPS_AGE_KEY_FILE")
if env_key_path:
key_path = Path(env_key_path)
if not key_path.exists():
return {"exists": False, "expected_path": str(key_path)}
result = {
"exists": True,
"path": str(key_path),
}
try:
content = key_path.read_text()
# Extract public key from comment line: # public key: age1...
for line in content.splitlines():
match = re.match(r"#\s*public key:\s*(age1\S+)", line)
if match:
result["public_key"] = match.group(1)
break
except OSError:
result["read_error"] = True
return result
def detect_sops_yaml(project_root):
"""Check for .sops.yaml and parse its age recipients."""
sops_path = Path(project_root) / ".sops.yaml"
if not sops_path.exists():
return {"exists": False}
result = {"exists": True, "path": str(sops_path)}
try:
content = sops_path.read_text()
# Extract age public keys from the file
keys = re.findall(r"(age1[a-z0-9]+)", content)
if keys:
result["authorized_keys"] = list(set(keys))
result["key_count"] = len(set(keys))
except OSError:
result["read_error"] = True
return result
def detect_env_files(project_root):
"""Find .env* files and encrypted YAML files in the project tree."""
root = Path(project_root)
env_files = []
encrypted_files = []
tmp_files = []
skip_dirs = {
".git", "node_modules", "build", "dist", ".next",
"__pycache__", ".gradle", "target", ".terraform",
}
for f in sorted(root.rglob("*")):
if any(part in skip_dirs for part in f.parts):
continue
if not f.is_file():
continue
name = f.name
if name.endswith(".enc.yaml.tmp") or name.endswith(".tmp.yaml"):
tmp_files.append(str(f.relative_to(root)))
elif name.endswith(".enc.yaml"):
encrypted_files.append(str(f.relative_to(root)))
elif name.startswith(".env") and name != ".env.example" and f.parent == root:
env_files.append(name)
return env_files, encrypted_files, tmp_files
def detect_gitignore(project_root):
"""Check .gitignore for .env and .encrypted rules."""
gitignore_path = Path(project_root) / ".gitignore"
if not gitignore_path.exists():
return {"exists": False, "ignores_env": False, "ignores_encrypted": False}
try:
content = gitignore_path.read_text()
lines = [
line.strip()
for line in content.splitlines()
if line.strip() and not line.strip().startswith("#")
]
ignores_env = any(
pattern in lines
for pattern in (".env", ".env*", ".env.*", ".env.local")
)
ignores_encrypted = any(
"enc.yaml" in line or "encrypted" in line for line in lines
)
return {
"exists": True,
"ignores_env": ignores_env,
"ignores_encrypted": ignores_encrypted,
}
except OSError:
return {"exists": True, "ignores_env": False, "ignores_encrypted": False}
def detect_os():
"""Detect operating system for install instructions."""
system = platform.system().lower()
if system == "darwin":
return "macos"
elif system == "linux":
return "linux"
return system
def detect(project_root):
"""Run all detection checks and return structured results."""
env_files, encrypted_files, tmp_files = detect_env_files(project_root)
result = {
"tools": {
"sops": detect_tool("sops"),
"age": detect_tool("age"),
},
"age_key": detect_age_key(),
"project": {
"sops_yaml": detect_sops_yaml(project_root),
"env_files": env_files,
"encrypted_files": encrypted_files,
"gitignore": detect_gitignore(project_root),
},
"os": detect_os(),
}
if tmp_files:
result["project"]["tmp_files"] = tmp_files
return result
def main():
project_root = sys.argv[1] if len(sys.argv) > 1 else os.getcwd()
if not Path(project_root).is_dir():
json.dump(
{"error": "not_a_directory", "path": project_root},
sys.stdout,
indent=2,
)
sys.exit(1)
result = detect(project_root)
json.dump(result, sys.stdout, indent=2)
print()
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Convert between dotenv and YAML formats for SOPS encryption.
SOPS dotenv store has a known bug (#1435) that corrupts backslash and
newline sequences. This script converts .env files to YAML for safe
encryption, and converts decrypted YAML back to dotenv format.
Uses only standard library (no external dependencies).
Usage:
# dotenv → yaml (for encryption)
python3 dotenv_yaml.py to-yaml .env.local > .env.local.yaml
# yaml → dotenv (after decryption)
python3 dotenv_yaml.py to-dotenv .env.local.yaml > .env.local
# Round-trip test
python3 dotenv_yaml.py test .env.local
"""
import re
import sys
from pathlib import Path
def dotenv_to_yaml(content: str) -> str:
"""Convert dotenv format to YAML key-value pairs.
Handles:
- KEY=value (unquoted)
- KEY="value" (double-quoted, preserves escapes)
- KEY='value' (single-quoted, literal)
- Comments (# ...) — preserved as YAML comments
- Empty lines — preserved
- export KEY=value — strips export prefix
"""
lines = []
for line in content.splitlines():
stripped = line.strip()
# Empty lines and comments pass through
if not stripped or stripped.startswith("#"):
lines.append(line)
continue
# Strip optional 'export ' prefix
if stripped.startswith("export "):
stripped = stripped[7:].strip()
# Parse KEY=VALUE
match = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)", stripped)
if not match:
# Not a valid env line, keep as comment
lines.append(f"# {line}")
continue
key = match.group(1)
raw_value = match.group(2)
# Remove surrounding quotes if present
if len(raw_value) >= 2:
if raw_value[0] == '"' and raw_value[-1] == '"':
raw_value = raw_value[1:-1]
elif raw_value[0] == "'" and raw_value[-1] == "'":
raw_value = raw_value[1:-1]
# YAML quoting: always double-quote to preserve special chars
# Escape backslashes and double quotes for YAML
yaml_value = raw_value.replace("\\", "\\\\").replace('"', '\\"')
lines.append(f'{key}: "{yaml_value}"')
return "\n".join(lines) + "\n"
def yaml_to_dotenv(content: str) -> str:
"""Convert simple YAML key-value pairs back to dotenv format.
Handles the subset of YAML produced by dotenv_to_yaml:
- KEY: "value" (quoted string)
- KEY: value (unquoted)
- Comments and empty lines preserved
- Ignores sops metadata block
"""
lines = []
in_sops_block = False
for line in content.splitlines():
stripped = line.strip()
# Skip sops metadata block
if stripped == "sops:":
in_sops_block = True
continue
if in_sops_block:
if stripped and not stripped.startswith("#") and not line.startswith(" ") and not line.startswith("\t"):
in_sops_block = False
else:
continue
# Empty lines and comments pass through
if not stripped or stripped.startswith("#"):
lines.append(line)
continue
# Parse YAML KEY: VALUE
match = re.match(r'^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)', stripped)
if not match:
continue
key = match.group(1)
raw_value = match.group(2).strip()
# Remove YAML quotes
if len(raw_value) >= 2:
if raw_value[0] == '"' and raw_value[-1] == '"':
raw_value = raw_value[1:-1]
# Unescape YAML double-quote escapes
raw_value = raw_value.replace('\\"', '"').replace("\\\\", "\\")
elif raw_value[0] == "'" and raw_value[-1] == "'":
raw_value = raw_value[1:-1]
# Quote in dotenv if value contains special chars
needs_quoting = any(c in raw_value for c in ' "\'\\$`!#&|;')
if needs_quoting:
escaped = raw_value.replace("\\", "\\\\").replace('"', '\\"')
lines.append(f'{key}="{escaped}"')
else:
lines.append(f"{key}={raw_value}")
return "\n".join(lines) + "\n"
def round_trip_test(filepath: str) -> bool:
"""Test that a dotenv file survives round-trip conversion."""
original = Path(filepath).read_text()
yaml_content = dotenv_to_yaml(original)
restored = yaml_to_dotenv(yaml_content)
# Compare key=value pairs (ignoring whitespace differences)
def parse_env(text):
pairs = {}
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if line.startswith("export "):
line = line[7:].strip()
match = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)", line)
if match:
pairs[match.group(1)] = match.group(2)
return pairs
original_pairs = parse_env(original)
restored_pairs = parse_env(restored)
if original_pairs == restored_pairs:
print(f"PASS: {len(original_pairs)} key-value pairs preserved", file=sys.stderr)
return True
else:
missing = set(original_pairs) - set(restored_pairs)
added = set(restored_pairs) - set(original_pairs)
changed = {
k for k in original_pairs.keys() & restored_pairs.keys()
if original_pairs[k] != restored_pairs[k]
}
if missing:
print(f"FAIL: Missing keys: {missing}", file=sys.stderr)
if added:
print(f"FAIL: Extra keys: {added}", file=sys.stderr)
if changed:
print(f"FAIL: Changed values: {changed}", file=sys.stderr)
return False
def main():
if len(sys.argv) < 3:
print(
"Usage:\n"
" dotenv_yaml.py to-yaml <file> # Convert .env to YAML (stdout)\n"
" dotenv_yaml.py to-dotenv <file> # Convert YAML to .env (stdout)\n"
" dotenv_yaml.py test <file> # Round-trip test a .env file",
file=sys.stderr,
)
sys.exit(1)
command = sys.argv[1]
filepath = sys.argv[2]
content = Path(filepath).read_text()
if command == "to-yaml":
print(dotenv_to_yaml(content), end="")
elif command == "to-dotenv":
print(yaml_to_dotenv(content), end="")
elif command == "test":
success = round_trip_test(filepath)
sys.exit(0 if success else 1)
else:
print(f"Unknown command: {command}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
SOPS Setup Troubleshooting
Installation Issues
brew install fails (macOS)
Symptom: brew install sops age fails or command not found.
Fix:
# Update Homebrew
brew update
# If brew itself isn't installed
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"age not in apt repositories (Linux)
Symptom: apt-get install age fails on older Ubuntu/Debian.
Fix: Install from binary:
AGE_VERSION=$(curl -s https://api.github.com/repos/FiloSottile/age/releases/latest | grep tag_name | cut -d '"' -f 4)
curl -Lo /tmp/age.tar.gz "https://github.com/FiloSottile/age/releases/download/${AGE_VERSION}/age-${AGE_VERSION}-linux-amd64.tar.gz"
tar -xzf /tmp/age.tar.gz -C /tmp
sudo install -m 755 /tmp/age/age /usr/local/bin/age
sudo install -m 755 /tmp/age/age-keygen /usr/local/bin/age-keygen
rm -rf /tmp/age /tmp/age.tar.gz---
Key Issues
"no identity matched any of the recipients"
Symptom: sops --decrypt fails with this error.
Cause: The machine's age key is not listed in .sops.yaml when the file was encrypted.
Fix: 1. On a machine that CAN decrypt, run /devtools:sops-add-key 2. Paste the new machine's public key 3. This re-encrypts files for all authorized keys (using sops updatekeys) 4. Commit and push the updated .sops.yaml and *.enc.yaml files 5. Pull on the new machine and try decrypting again
Age key file has wrong permissions
Symptom: Warnings about key file permissions.
Fix:
chmod 600 ~/.config/sops/age/keys.txtLost age private key
Symptom: Cannot decrypt files, private key file is gone.
Fix options: 1. If you have a backup key: Place the backup private key at ~/.config/sops/age/keys.txt and decrypt 2. If another machine has access: On that machine, run /devtools:sops-add-key with your new machine's public key 3. If no backup and no other machine: Secrets are lost. Generate a new key, recreate .env files manually, re-encrypt from scratch
This is why the setup wizard recommends generating a backup key.
---
Encryption Issues
"could not load config" when running sops
Symptom: sops --encrypt fails saying it can't find configuration.
Cause: .sops.yaml is missing or not in the current directory / parent.
Fix: Ensure .sops.yaml is in the project root and you're running sops from within the project directory tree.
Encrypted file is empty
Symptom: .enc.yaml file was created but has 0 bytes.
Cause: Shell redirect (>) created the file before sops ran, and sops failed.
Fix: 1. Check for sops errors: sops --encrypt file.yaml (without redirect, shows output/errors) 2. Common causes: missing .sops.yaml, invalid age key, file permissions 3. After fixing, re-encrypt
dotenv_yaml.py conversion errors
Symptom: Helper script fails to convert .env file.
Common causes:
- File uses non-standard dotenv syntax
- Binary content in env file
- Encoding issues (non-UTF8)
Fix: Check the .env file is valid UTF-8 with standard KEY=value format. Run the round-trip test:
python3 ${CLAUDE_SKILL_DIR}/scripts/dotenv_yaml.py test .env.localWhy not use dotenv format directly?
SOPS has a known bug (#1435) where the dotenv store corrupts backslash sequences (\\, \n) on decrypt. The YAML format is roundtrip-safe. The dotenv_yaml.py helper handles conversion transparently.
---
Git Issues
Accidentally committed plaintext .env file
Fix: 1. Remove from git (keep local): git rm --cached .env.local 2. Ensure .gitignore has .env* rules 3. Commit: git commit -m "remove plaintext env from tracking" 4. Important: The secret is still in git history. Rotate any exposed credentials. 5. Optionally clean history: git filter-branch or BFG Repo-Cleaner
.enc.yaml files showing as ignored by git
Cause: .gitignore has a pattern matching *.yaml or *.enc.yaml.
Fix: Remove the pattern from .gitignore. Encrypted YAML files are safe to commit — that's the whole point.
Git diff shows binary/garbled content for .enc.yaml
Cause: .gitattributes sopsdiffer not configured.
Fix:
# Add to .gitattributes
echo '*.enc.yaml diff=sopsdiffer' >> .gitattributes
# Configure git
git config diff.sopsdiffer.textconv "sops decrypt"Now git diff will show decrypted content for encrypted files.
---
sops updatekeys Issues
Command not found or unsupported
Symptom: sops updatekeys not recognized.
Cause: Older version of sops (pre-3.8).
Fix: Update sops or fall back to manual re-encryption:
sops --decrypt file.enc.yaml > file.tmp.yaml
sops --encrypt file.tmp.yaml > file.enc.yaml
rm file.tmp.yamlKey removed but old data still accessible
After removing a key from .sops.yaml and running updatekeys, the removed party could still decrypt if they cached the old data encryption key (DEK).
Fix: Run full key rotation to generate a new DEK:
sops rotate -i file.enc.yamlThis re-encrypts every value with a fresh DEK, invalidating any cached keys.
SOPS Setup Workflow
Detailed per-step flows. Each step follows the pattern: detect existing state, ask user preferences via AskUserQuestion, show confirmation, then execute.
Format note: All encryption uses YAML format (not dotenv) to avoid SOPS bug #1435. The dotenv_yaml.py helper script handles conversion transparently.
---
Step 1: Install Tools
Detect: Check tools.sops.installed and tools.age.installed
If both installed: Skip — show versions and move to next step.
If missing: Show install commands based on os field.
macOS
brew install sops ageLinux (Debian/Ubuntu)
# age
sudo apt-get update && sudo apt-get install -y age
# sops — download latest binary
SOPS_VERSION=$(curl -s https://api.github.com/repos/getsops/sops/releases/latest | grep tag_name | cut -d '"' -f 4)
curl -Lo /tmp/sops "https://github.com/getsops/sops/releases/download/${SOPS_VERSION}/sops-${SOPS_VERSION}.linux.amd64"
sudo install -m 755 /tmp/sops /usr/local/bin/sops
rm /tmp/sopsLinux (Other)
# age — from source or binary
# https://github.com/FiloSottile/age/releases
# sops — from binary
# https://github.com/getsops/sops/releasesAskUserQuestion:
- Install now (run commands above)
- I'll install manually (skip, re-run setup after)
Post-install: Re-run detector to verify tools are available.
---
Step 2: Generate Machine Key
Detect: Check age_key.exists
If exists: Show the public key (truncated) and path. Skip to next step.
If missing:
1. Create the directory:
mkdir -p ~/.config/sops/age2. Generate the key pair:
age-keygen -o ~/.config/sops/age/keys.txt3. Set secure permissions:
chmod 600 ~/.config/sops/age/keys.txt4. Extract and display the public key:
grep "public key:" ~/.config/sops/age/keys.txt5. Tell user:
Save this public key somewhere safe (password manager, secure note).
You'll need it when authorizing this machine on other projects.
The private key stays at ~/.config/sops/age/keys.txt — never share it.---
Step 3: Generate Backup Key
AskUserQuestion:
- Generate backup key (Recommended) — creates a recovery key for disaster scenarios
- Skip — can add one later via
/devtools:sops-add-key
If generating:
1. Generate a key pair (output to stdout, NOT saved to disk):
age-keygenThis outputs:
# created: 2024-01-15T10:30:00Z
# public key: age1backupkeyhere...
AGE-SECRET-KEY-1PRIVATEBACKUPKEYHERE...2. Display the full output and tell user:
CRITICAL: Copy this ENTIRE output (including the AGE-SECRET-KEY- line) to a secure location:
- Password manager (1Password, Bitwarden, etc.)
- Encrypted USB drive
- Printed and stored in a safe
>
This is your disaster recovery key. If you lose access to all machines,
this key can decrypt everything. It will NOT be saved to this machine.
3. Extract and store the public key for use in .sops.yaml (Step 4).
---
Step 4: Create .sops.yaml
Detect: Check project.sops_yaml.exists
If exists with current machine key: Show config summary, skip to next step.
If exists without current key: Offer to add this machine's key:
AskUserQuestion:
- Add my key to existing .sops.yaml
- Replace .sops.yaml entirely
- Skip (keep current config)
If missing:
Write .sops.yaml to project root. Include machine key + backup key (if generated):
creation_rules:
- path_regex: (^|/)\.env\.[^/]+\.enc\.yaml$
age: >-
<machine-public-key>,
<backup-public-key>If no backup key was generated:
creation_rules:
- path_regex: (^|/)\.env\.[^/]+\.enc\.yaml$
age: >-
<machine-public-key>---
Step 5: Set Up .gitattributes
Detect: Check if .gitattributes exists and contains sopsdiffer
If already configured: Skip.
If missing:
1. Append to .gitattributes (create if doesn't exist):
*.enc.yaml diff=sopsdiffer2. Configure git locally:
git config diff.sopsdiffer.textconv "sops decrypt"3. This enables git diff to show decrypted content for encrypted files, making code review easier.
---
Step 6: Update .gitignore
Detect: Check project.gitignore
Actions needed (show as confirmation table):
| Check | Current | Needed | Action |
|---|---|---|---|
.env* ignored | yes/no | yes | Add if missing |
*.enc.yaml ignored | yes/no | no | Remove if present |
If .gitignore doesn't exist, create it.
If changes needed, AskUserQuestion:
- Apply changes (show diff preview)
- Skip
Append to `.gitignore` (if .env* rules missing):
# Secrets — never commit plaintext env files
.env
.env.*
!.env.exampleRemove from `.gitignore` (if *.enc.yaml is ignored): Remove lines containing enc.yaml pattern.
---
Step 7: Encrypt Files
Detect: Check project.env_files
If empty: Skip — no files to encrypt.
If non-empty:
AskUserQuestion (multiSelect: true) — list each .env* file:
.env.local.env.production- etc.
For each selected file, convert and encrypt:
# Convert dotenv → YAML
python3 ${CLAUDE_SKILL_DIR}/scripts/dotenv_yaml.py to-yaml <file> > <file>.enc.yaml.tmp
# Encrypt the YAML
sops --encrypt <file>.enc.yaml.tmp > <file>.enc.yaml
# Clean up temp file
rm <file>.enc.yaml.tmpVerify:
test -s <file>.enc.yaml && echo "OK" || echo "FAILED"---
Step 8: Confirmation Summary
Show a complete table of all actions taken during the session:
## Setup Complete
| Step | Action | Result |
|------|--------|--------|
| Tools | sops 3.9.4, age 1.2.0 | installed |
| Machine key | Generated | age1abc...def |
| Backup key | Generated | age1xyz...uvw (saved offline) |
| Permissions | chmod 600 keys.txt | done |
| Config | .sops.yaml created | 2 keys authorized |
| Git | .gitattributes updated | sopsdiffer configured |
| Git | .gitignore updated | .env* ignored |
| Encrypt | .env.local → .env.local.enc.yaml | done |
## Next Steps
- Commit .sops.yaml, .gitattributes, and *.enc.yaml files to git
- On another machine: clone, install sops+age, place age key, run /devtools:sops-decrypt
- To add another machine: /devtools:sops-add-key with the new machine's public key
- To encrypt after .env changes: /devtools:sops-encrypt
- To decrypt after pulling: /devtools:sops-decrypt