Secrets management
This guide documents the complete workflow for managing SOPS keys and secrets for the nix-config, supporting both initial bootstrap and key rotation.
Security architecture
Section titled “Security architecture”Key roles
Section titled “Key roles”-
Dev key (
age1dn8...ghptu3): Developer workstation key- Stored in
~/.config/sops/age/keys.txt - Can be shared among small team (or individual per developer)
- Can decrypt all secrets in vars/
- Stored in
-
CI key (
age1m9m...22j3p8): GitHub Actions key- Stored in GitHub Secrets as
SOPS_AGE_KEY - Backup stored in Bitwarden
- Can decrypt all secrets in vars/
- Stored in GitHub Secrets as
Secret categories
Section titled “Secret categories”-
Bootstrap secrets (must exist before SOPS works):
SOPS_AGE_KEY- GitHub secret containing CI private age key- Uploaded directly via
gh secret set
-
SOPS-managed secrets (in
vars/shared.yaml):CACHIX_AUTH_TOKEN- Nix binary cache authGITGUARDIAN_API_KEY- Secret scanningCLOUDFLARE_API_TOKEN- Cloudflare Workers deploymentCLOUDFLARE_ACCOUNT_ID- Cloudflare accountCI_AGE_KEY- Backup of CI private key (for re-uploading)
-
GitHub variables (non-secret):
CACHIX_CACHE_NAME- Name of cachix cache
Design decisions
Section titled “Design decisions”Why store CI_AGE_KEY in vars/shared.yaml?
- Allows rotating SOPS_AGE_KEY GitHub secret from dev workstation
- Still requires dev key to decrypt
- Bitwarden serves as offline backup
Why separate sops-upload-github-key from ghsecrets?
- Avoids chicken-and-egg: can’t use SOPS to get key needed to use SOPS
- During rotation, new key may not be in vars/shared.yaml yet
- Supports pasting from Bitwarden during initial bootstrap
Why support both SSH and age key generation?
- If CI needs SSH access (deploy, git push as bot), can derive age key from SSH key
- Single source of truth in Bitwarden
- Age-only is simpler if SSH not needed
Workflows
Section titled “Workflows”Initial bootstrap (new project)
Section titled “Initial bootstrap (new project)”# 1. Generate dev keyjust sops-bootstrap dev
# Output shows private key - copy to password manager# Then install locally:just sops-add-key# Paste the private key when prompted
# 2. Generate CI keyjust sops-bootstrap ci
# Output shows private key - save to Bitwarden# The recipe automatically adds it to vars/shared.yaml
# 3. Edit secrets with actual valuesjust edit-secrets# Replace all REPLACE_ME values with actual secrets
# 4. Check requirementsjust sops-check-requirements# Verify all required secrets are present
# 5. Upload SOPS_AGE_KEY to GitHubjust sops-upload-github-key# Choose option 2 to extract from vars/shared.yaml
# 6. Upload other secrets to GitHubjust sops-setup-github# Uploads CACHIX_AUTH_TOKEN, GITGUARDIAN_API_KEY, etc.
# 7. Verifygh secret listgh variable listjust show-secrets
# 8. Test CIjust gh-ci-run --debug=trueKey rotation (dev key)
Section titled “Key rotation (dev key)”# Option A: Quick rotation (guided)just sops-rotate dev
# Option B: Manual steps# 1. Bootstrap new dev keyjust sops-bootstrap dev# Adds as dev-next, saves private key
# 2. Install new key locallyjust sops-add-key# Paste the new private key
# 3. Verify decryption works with new keyjust show-secrets
# 4. Finalize rotation (remove old key)just sops-finalize-rotation dev
# 5. Update Bitwarden - mark old key as revokedKey rotation (CI key)
Section titled “Key rotation (CI key)”# 1. Bootstrap new CI keyjust sops-bootstrap ci# Adds as ci-next, saves private key to Bitwarden
# 2. Add new key to vars/shared.yamljust edit-secrets# Update CI_AGE_KEY field with new private key
# 3. Upload new key to GitHubjust sops-upload-github-key# Choose option 1, paste from Bitwarden
# 4. Test CI with new keyjust gh-ci-run --debug=true
# 5. Verify workflow succeeds with new keyjust gh-workflow-status
# 6. Finalize rotation (remove old key)just sops-finalize-rotation ci
# 7. Update vars/shared.yaml to remove old CI_AGE_KEYjust edit-secrets# (The old value is fine to keep or remove)Adding new secrets
Section titled “Adding new secrets”# 1. Edit encrypted filejust edit-secrets
# 2. Add new secret# NEW_SECRET_NAME: new_secret_value
# 3. If needed in CI, upload to GitHubsops exec-env vars/shared.yaml \ 'gh secret set NEW_SECRET_NAME --body="$NEW_SECRET_NAME"'
# Or add to ghsecrets recipeOnboarding new developer
Section titled “Onboarding new developer”# Option 1: Share existing dev key (small team)# Send developer the dev private key via secure channeljust sops-add-key# Paste the shared dev key
# Option 2: Generate individual dev key (recommended)# 1. Add developer's public key to .sops.yamlcat >> .sops.yaml << EOF - &dev-alice age1abc...xyzEOF
# Update creation_rulessed -i '/- \*dev/a\ - \*dev-alice' .sops.yaml
# 2. Re-encrypt all files with new keyjust updatekeys
# 3. Commit and push .sops.yaml
# 4. Developer adds their private keyjust sops-add-keyEmergency key recovery
Section titled “Emergency key recovery”# If dev key lost but CI key backed up:# 1. Get CI private key from Bitwarden
# 2. Install as temporary dev keymkdir -p ~/.config/sops/agecat >> ~/.config/sops/age/keys.txt << EOF# Temporary CI key# public key: age1m9m...22j3p8AGE-SECRET-KEY-...EOF
# 3. Now can decrypt secretsjust show-secrets
# 4. Rotate dev keyjust sops-bootstrap dev
# 5. Remove temporary CI key from ~/.config/sops/age/keys.txtRecipe reference
Section titled “Recipe reference”Bootstrap and rotation
Section titled “Bootstrap and rotation”just sops-bootstrap <role> [method]- Generate new key (role: dev|ci, method: age|ssh)just sops-rotate <role>- Quick rotation workflow with guided stepsjust sops-finalize-rotation <role>- Remove old key after verifying new one
Secret management
Section titled “Secret management”just edit-secrets- Edit vars/shared.yaml (decrypts, opens editor, re-encrypts)just show-secrets- Display decrypted secretsjust set-secret <name> <value>- Set specific secret valuejust rotate-secret <name>- Rotate specific secret valuejust validate-secrets- Verify all encrypted files can be decrypted
GitHub integration
Section titled “GitHub integration”just sops-check-requirements- Analyze workflows and show required secretsjust sops-upload-github-key [repo]- Upload SOPS_AGE_KEY to GitHubjust sops-setup-github [repo]- Upload all secrets and variables (except SOPS_AGE_KEY)just ghsecrets [repo]- Upload specific secrets from vars/shared.yamljust ghvars [repo]- Upload variables from environment
Key management
Section titled “Key management”just sops-init- Generate new age key for current userjust sops-add-key- Add existing age key to local configjust updatekeys- Update all encrypted files with current keys from .sops.yaml
Testing
Section titled “Testing”just test-build- Test CI build job locally with actjust test-deploy- Test CI deploy job locally with actjust gh-ci-run- Trigger CI workflow on GitHub
File structure
Section titled “File structure”.├── .sops.yaml # SOPS config with public keys (committed)├── vars/│ ├── shared.yaml # Encrypted secrets (committed)│ └── README.md # Documentation├── .github/workflows/│ └── ci.yaml # CI workflow that uses SOPS_AGE_KEY└── justfile # Recipes for secret managementSecurity checklist
Section titled “Security checklist”- Dev private keys stored in
~/.config/sops/age/keys.txtwith600permissions - CI private key backed up in Bitwarden
-
SOPS_AGE_KEYGitHub secret set - No unencrypted secrets committed to git
-
.sops.yamlonly contains public keys - All secrets in
vars/shared.yamlhave non-REPLACE_ME values - GitHub Actions logs don’t expose
SOPS_AGE_KEYor decrypted secrets - Key rotation procedure documented and tested
- Recovery procedure documented (CI key in Bitwarden)
Troubleshooting
Section titled “Troubleshooting”Cannot decrypt vars/shared.yaml
Section titled “Cannot decrypt vars/shared.yaml”# Check if you have a valid keygrep "public key:" ~/.config/sops/age/keys.txt
# Check if your public key is in .sops.yamlcat .sops.yaml
# Verify file is encryptedhead vars/shared.yaml # Should show SOPS metadata
# Try decrypting with explicit keySOPS_AGE_KEY_FILE=~/.config/sops/age/keys.txt sops -d vars/shared.yamlCI fails with “could not decrypt data key”
Section titled “CI fails with “could not decrypt data key””# Verify SOPS_AGE_KEY is set in GitHubgh secret list | grep SOPS_AGE_KEY
# Verify CI public key in .sops.yaml matches private key# Get public key from private key:age-keygen -y <<< "AGE-SECRET-KEY-..."
# Re-upload keyjust sops-upload-github-keyRotation left system in inconsistent state
Section titled “Rotation left system in inconsistent state”# Restore from backupcp .sops.yaml.backup .sops.yaml
# Or manually fix .sops.yaml# - Remove -next suffix from new key# - Remove old key line# - Update all filesjust updatekeysAdvanced usage
Section titled “Advanced usage”SSH-derived keys for CI bot
Section titled “SSH-derived keys for CI bot”If CI needs SSH access (e.g., to push commits as bot user):
# Generate SSH keyssh-keygen -t ed25519 -f /tmp/ci-bot -N "" -C "ci-bot@nix-config"
# Derive age keyssh-to-age < /tmp/ci-bot.pub# Output: age1abc...xyz
# Add to .sops.yaml as ci key
# Save SSH private key to Bitwarden as "nix-config CI SSH key"
# For SOPS, derive age private keyssh-to-age -private-key -i /tmp/ci-bot# Output: AGE-SECRET-KEY-...
# Upload to GitHubecho "AGE-SECRET-KEY-..." | gh secret set SOPS_AGE_KEY
# For SSH access, also upload SSH keygh secret set CI_SSH_KEY < /tmp/ci-bot
# Clean uprm /tmp/ci-bot /tmp/ci-bot.pubEnvironment-specific secrets (dev/staging/prod)
Section titled “Environment-specific secrets (dev/staging/prod)”keys: - &dev age1dn8... - &ci age1m9m... - &prod-admin age1xyz...
creation_rules: - path_regex: vars/dev\.yaml$ key_groups: - age: [*dev, *ci]
- path_regex: vars/prod\.yaml$ key_groups: - age: [*prod-admin, *ci]# Edit environment-specific secretsjust edit-secrets vars/dev.yamljust edit-secrets vars/prod.yamlMulti-repository shared secrets
Section titled “Multi-repository shared secrets”For secrets shared across multiple repos (e.g., CACHIX_AUTH_TOKEN):
# Create shared secrets repomkdir ~/.sops-sharedcd ~/.sops-shared
# Copy .sops.yaml and create shared.yamlcp ~/projects/nix-config/.sops.yaml .sops shared.yaml
# Upload to multiple reposfor repo in org/repo1 org/repo2; do sops exec-env shared.yaml "gh secret set CACHIX_AUTH_TOKEN --repo=$repo --body=\$CACHIX_AUTH_TOKEN"doneQuick reference
Section titled “Quick reference”Common operations
Section titled “Common operations”First-time setup
Section titled “First-time setup”# 1. Generate and install dev keyjust sops-bootstrap devjust sops-add-key # Paste private key
# 2. Generate CI keyjust sops-bootstrap ci# Save private key to Bitwarden
# 3. Edit secretsjust edit-secrets# Replace all REPLACE_ME values
# 4. Upload to GitHubjust sops-upload-github-key # Option 2: from vars/shared.yamljust sops-setup-github # Other secrets and variables
# 5. Verifyjust sops-check-requirementsgh secret listjust gh-ci-runDaily usage
Section titled “Daily usage”# View secretsjust show-secrets
# Edit secretsjust edit-secrets
# Set specific secretjust set-secret CLOUDFLARE_API_TOKEN "new-value"
# Run command with secretsjust run-with-secrets 'echo $CLOUDFLARE_API_TOKEN'
# Validate all secrets decryptjust validate-secretsKey rotation
Section titled “Key rotation”# Quick rotation (guided)just sops-rotate dev # or 'ci'
# Manual rotationjust sops-bootstrap devjust sops-add-keyjust show-secrets # Verify worksjust sops-finalize-rotation devGitHub sync
Section titled “GitHub sync”# Check what secrets are neededjust sops-check-requirements
# Upload SOPS_AGE_KEYjust sops-upload-github-key
# Upload all other secretsjust sops-setup-github
# Or upload individuallyjust ghsecrets # Secrets from vars/shared.yamljust ghvars # Variables from environmentTroubleshooting
Section titled “Troubleshooting”# Can't decrypt?grep "public key:" ~/.config/sops/age/keys.txtcat .sops.yaml # Is your key listed?
# Update keys after changing .sops.yamljust updatekeys
# CI failing?gh secret list | grep SOPS_AGE_KEYjust gh-logs # Check error messageRecipe quick reference
Section titled “Recipe quick reference”| Recipe | Purpose |
|---|---|
sops-bootstrap <role> | Generate new dev/ci key |
sops-rotate <role> | Quick rotation workflow |
sops-finalize-rotation <role> | Remove old key after rotation |
sops-add-key | Install key locally |
sops-init | Generate new age key |
edit-secrets | Edit vars/shared.yaml |
show-secrets | View decrypted secrets |
set-secret <name> <value> | Set specific secret |
rotate-secret <name> | Rotate specific secret value |
validate-secrets | Verify all files decrypt |
updatekeys | Update encrypted files after key changes |
sops-check-requirements | Show required secrets from workflows |
sops-upload-github-key | Upload SOPS_AGE_KEY to GitHub |
sops-setup-github | Upload all secrets/vars to GitHub |
ghsecrets [repo] | Upload secrets from SOPS |
ghvars [repo] | Upload variables |
File locations
Section titled “File locations”| File | Purpose |
|---|---|
.sops.yaml | SOPS config (public keys only) |
vars/shared.yaml | Encrypted secrets (committed) |
~/.config/sops/age/keys.txt | Your private keys (NOT committed) |
GitHub Secrets: SOPS_AGE_KEY | CI private key |
Key public keys (from .sops.yaml)
Section titled “Key public keys (from .sops.yaml)”- Dev:
age1dn8w7y4t4h23fmeenr3dghfz5qh53jcjq9qfv26km3mnv8l44g0sghptu3 - CI:
age1m9m8h5vqr7dqlmvnzcwshmm4uk8umcllazum6eaulkdp3qc88ugs22j3p8
Required secrets (from ci.yaml)
Section titled “Required secrets (from ci.yaml)”| Secret | Location | Purpose |
|---|---|---|
SOPS_AGE_KEY | GitHub Secret | CI age private key |
CACHIX_AUTH_TOKEN | vars/shared.yaml → GitHub Secret | Nix cache auth |
CACHIX_CACHE_NAME | vars/shared.yaml → GitHub Variable | Nix cache name |
GITGUARDIAN_API_KEY | vars/shared.yaml → GitHub Secret | Secret scanning |
CLOUDFLARE_API_TOKEN | vars/shared.yaml | Cloudflare deploy |
CLOUDFLARE_ACCOUNT_ID | vars/shared.yaml | Cloudflare account |
CI_AGE_KEY | vars/shared.yaml | Backup of SOPS_AGE_KEY |
Emergency contacts
Section titled “Emergency contacts”- Bitwarden: CI key backup
.sops.yaml.backup: Rollback pointvars/shared.yaml.backup: Rollback point- SOPS-WORKFLOW-GUIDE.md: Full documentation