Skip to content

Secrets management

This guide documents the complete workflow for managing SOPS keys and secrets for the ironstar, supporting both initial bootstrap and key rotation.

  1. 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/
  2. 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/
  1. Bootstrap secrets (must exist before SOPS works):

    • SOPS_AGE_KEY - GitHub secret containing CI private age key
    • Uploaded directly via gh secret set
  2. SOPS-managed secrets (in vars/shared.yaml):

    • CACHIX_AUTH_TOKEN - Nix binary cache auth
    • GITGUARDIAN_API_KEY - Secret scanning
    • CLOUDFLARE_API_TOKEN - Cloudflare Workers deployment
    • CLOUDFLARE_ACCOUNT_ID - Cloudflare account
    • CI_AGE_KEY - Backup of CI private key (for re-uploading)
  3. GitHub variables (non-secret):

    • CACHIX_CACHE_NAME - Name of cachix cache

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
Terminal window
# 1. Generate dev key
just 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 key
just sops-bootstrap ci
# Output shows private key - save to Bitwarden
# The recipe automatically adds it to vars/shared.yaml
# 3. Edit secrets with actual values
just edit-secrets
# Replace all REPLACE_ME values with actual secrets
# 4. Check requirements
just sops-check-requirements
# Verify all required secrets are present
# 5. Upload SOPS_AGE_KEY to GitHub
just sops-upload-github-key
# Choose option 2 to extract from vars/shared.yaml
# 6. Upload other secrets to GitHub
just sops-setup-github
# Uploads CACHIX_AUTH_TOKEN, GITGUARDIAN_API_KEY, etc.
# 7. Verify
gh secret list
gh variable list
just show-secrets
# 8. Test CI
just gh-ci-run --debug=true
Terminal window
# Option A: Quick rotation (guided)
just sops-rotate dev
# Option B: Manual steps
# 1. Bootstrap new dev key
just sops-bootstrap dev
# Adds as dev-next, saves private key
# 2. Install new key locally
just sops-add-key
# Paste the new private key
# 3. Verify decryption works with new key
just show-secrets
# 4. Finalize rotation (remove old key)
just sops-finalize-rotation dev
# 5. Update Bitwarden - mark old key as revoked
Terminal window
# 1. Bootstrap new CI key
just sops-bootstrap ci
# Adds as ci-next, saves private key to Bitwarden
# 2. Add new key to vars/shared.yaml
just edit-secrets
# Update CI_AGE_KEY field with new private key
# 3. Upload new key to GitHub
just sops-upload-github-key
# Choose option 1, paste from Bitwarden
# 4. Test CI with new key
just gh-ci-run --debug=true
# 5. Verify workflow succeeds with new key
just gh-workflow-status
# 6. Finalize rotation (remove old key)
just sops-finalize-rotation ci
# 7. Update vars/shared.yaml to remove old CI_AGE_KEY
just edit-secrets
# (The old value is fine to keep or remove)
Terminal window
# 1. Edit encrypted file
just edit-secrets
# 2. Add new secret
# NEW_SECRET_NAME: new_secret_value
# 3. If needed in CI, upload to GitHub
sops exec-env vars/shared.yaml \
'gh secret set NEW_SECRET_NAME --body="$NEW_SECRET_NAME"'
# Or add to ghsecrets recipe
Terminal window
# Option 1: Share existing dev key (small team)
# Send developer the dev private key via secure channel
just sops-add-key
# Paste the shared dev key
# Option 2: Generate individual dev key (recommended)
# 1. Add developer's public key to .sops.yaml
cat >> .sops.yaml << EOF
- &dev-alice age1abc...xyz
EOF
# Update creation_rules
sed -i '/- \*dev/a\ - \*dev-alice' .sops.yaml
# 2. Re-encrypt all files with new key
just updatekeys
# 3. Commit and push .sops.yaml
# 4. Developer adds their private key
just sops-add-key
Terminal window
# If dev key lost but CI key backed up:
# 1. Get CI private key from Bitwarden
# 2. Install as temporary dev key
mkdir -p ~/.config/sops/age
cat >> ~/.config/sops/age/keys.txt << EOF
# Temporary CI key
# public key: age1m9m...22j3p8
AGE-SECRET-KEY-...
EOF
# 3. Now can decrypt secrets
just show-secrets
# 4. Rotate dev key
just sops-bootstrap dev
# 5. Remove temporary CI key from ~/.config/sops/age/keys.txt
  • just sops-bootstrap <role> [method] - Generate new key (role: dev|ci, method: age|ssh)
  • just sops-rotate <role> - Quick rotation workflow with guided steps
  • just sops-finalize-rotation <role> - Remove old key after verifying new one
  • just edit-secrets - Edit vars/shared.yaml (decrypts, opens editor, re-encrypts)
  • just show-secrets - Display decrypted secrets
  • just set-secret <name> <value> - Set specific secret value
  • just rotate-secret <name> - Rotate specific secret value
  • just validate-secrets - Verify all encrypted files can be decrypted
  • just sops-check-requirements - Analyze workflows and show required secrets
  • just sops-upload-github-key [repo] - Upload SOPS_AGE_KEY to GitHub
  • just sops-setup-github [repo] - Upload all secrets and variables (except SOPS_AGE_KEY)
  • just ghsecrets [repo] - Upload specific secrets from vars/shared.yaml
  • just ghvars [repo] - Upload variables from environment
  • just sops-init - Generate new age key for current user
  • just sops-add-key - Add existing age key to local config
  • just updatekeys - Update all encrypted files with current keys from .sops.yaml
  • just test-build - Test CI build job locally with act
  • just test-deploy - Test CI deploy job locally with act
  • just gh-ci-run - Trigger CI workflow on GitHub
.
├── .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 management
  • Dev private keys stored in ~/.config/sops/age/keys.txt with 600 permissions
  • CI private key backed up in Bitwarden
  • SOPS_AGE_KEY GitHub secret set
  • No unencrypted secrets committed to git
  • .sops.yaml only contains public keys
  • All secrets in vars/shared.yaml have non-REPLACE_ME values
  • GitHub Actions logs don’t expose SOPS_AGE_KEY or decrypted secrets
  • Key rotation procedure documented and tested
  • Recovery procedure documented (CI key in Bitwarden)
Terminal window
# Check if you have a valid key
grep "public key:" ~/.config/sops/age/keys.txt
# Check if your public key is in .sops.yaml
cat .sops.yaml
# Verify file is encrypted
head vars/shared.yaml # Should show SOPS metadata
# Try decrypting with explicit key
SOPS_AGE_KEY_FILE=~/.config/sops/age/keys.txt sops -d vars/shared.yaml

CI fails with “could not decrypt data key”

Section titled “CI fails with “could not decrypt data key””
Terminal window
# Verify SOPS_AGE_KEY is set in GitHub
gh 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 key
just sops-upload-github-key

Rotation left system in inconsistent state

Section titled “Rotation left system in inconsistent state”
Terminal window
# Restore from backup
cp .sops.yaml.backup .sops.yaml
# Or manually fix .sops.yaml
# - Remove -next suffix from new key
# - Remove old key line
# - Update all files
just updatekeys

If CI needs SSH access (e.g., to push commits as bot user):

Terminal window
# Generate SSH key
ssh-keygen -t ed25519 -f /tmp/ci-bot -N "" -C "ci-bot@ironstar"
# Derive age key
ssh-to-age < /tmp/ci-bot.pub
# Output: age1abc...xyz
# Add to .sops.yaml as ci key
# Save SSH private key to Bitwarden as "ironstar CI SSH key"
# For SOPS, derive age private key
ssh-to-age -private-key -i /tmp/ci-bot
# Output: AGE-SECRET-KEY-...
# Upload to GitHub
echo "AGE-SECRET-KEY-..." | gh secret set SOPS_AGE_KEY
# For SSH access, also upload SSH key
gh secret set CI_SSH_KEY < /tmp/ci-bot
# Clean up
rm /tmp/ci-bot /tmp/ci-bot.pub

Environment-specific secrets (dev/staging/prod)

Section titled “Environment-specific secrets (dev/staging/prod)”
.sops.yaml
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]
Terminal window
# Edit environment-specific secrets
just edit-secrets vars/dev.yaml
just edit-secrets vars/prod.yaml

For secrets shared across multiple repos (e.g., CACHIX_AUTH_TOKEN):

Terminal window
# Create shared secrets repo
mkdir ~/.sops-shared
cd ~/.sops-shared
# Copy .sops.yaml and create shared.yaml
cp ~/projects/ironstar/.sops.yaml .
sops shared.yaml
# Upload to multiple repos
for repo in org/repo1 org/repo2; do
sops exec-env shared.yaml "gh secret set CACHIX_AUTH_TOKEN --repo=$repo --body=\$CACHIX_AUTH_TOKEN"
done
Terminal window
# 1. Generate and install dev key
just sops-bootstrap dev
just sops-add-key # Paste private key
# 2. Generate CI key
just sops-bootstrap ci
# Save private key to Bitwarden
# 3. Edit secrets
just edit-secrets
# Replace all REPLACE_ME values
# 4. Upload to GitHub
just sops-upload-github-key # Option 2: from vars/shared.yaml
just sops-setup-github # Other secrets and variables
# 5. Verify
just sops-check-requirements
gh secret list
just gh-ci-run
Terminal window
# View secrets
just show-secrets
# Edit secrets
just edit-secrets
# Set specific secret
just set-secret CLOUDFLARE_API_TOKEN "new-value"
# Run command with secrets
just run-with-secrets 'echo $CLOUDFLARE_API_TOKEN'
# Validate all secrets decrypt
just validate-secrets
Terminal window
# Quick rotation (guided)
just sops-rotate dev # or 'ci'
# Manual rotation
just sops-bootstrap dev
just sops-add-key
just show-secrets # Verify works
just sops-finalize-rotation dev
Terminal window
# Check what secrets are needed
just sops-check-requirements
# Upload SOPS_AGE_KEY
just sops-upload-github-key
# Upload all other secrets
just sops-setup-github
# Or upload individually
just ghsecrets # Secrets from vars/shared.yaml
just ghvars # Variables from environment
Terminal window
# Can't decrypt?
grep "public key:" ~/.config/sops/age/keys.txt
cat .sops.yaml # Is your key listed?
# Update keys after changing .sops.yaml
just updatekeys
# CI failing?
gh secret list | grep SOPS_AGE_KEY
just gh-logs # Check error message
RecipePurpose
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-keyInstall key locally
sops-initGenerate new age key
edit-secretsEdit vars/shared.yaml
show-secretsView decrypted secrets
set-secret <name> <value>Set specific secret
rotate-secret <name>Rotate specific secret value
validate-secretsVerify all files decrypt
updatekeysUpdate encrypted files after key changes
sops-check-requirementsShow required secrets from workflows
sops-upload-github-keyUpload SOPS_AGE_KEY to GitHub
sops-setup-githubUpload all secrets/vars to GitHub
ghsecrets [repo]Upload secrets from SOPS
ghvars [repo]Upload variables
FilePurpose
.sops.yamlSOPS config (public keys only)
vars/shared.yamlEncrypted secrets (committed)
~/.config/sops/age/keys.txtYour private keys (NOT committed)
GitHub Secrets: SOPS_AGE_KEYCI private key
  • Dev: age1dn8w7y4t4h23fmeenr3dghfz5qh53jcjq9qfv26km3mnv8l44g0sghptu3
  • CI: age1m9m8h5vqr7dqlmvnzcwshmm4uk8umcllazum6eaulkdp3qc88ugs22j3p8
SecretLocationPurpose
SOPS_AGE_KEYGitHub SecretCI age private key
CACHIX_AUTH_TOKENvars/shared.yaml → GitHub SecretNix cache auth
CACHIX_CACHE_NAMEvars/shared.yaml → GitHub VariableNix cache name
GITGUARDIAN_API_KEYvars/shared.yaml → GitHub SecretSecret scanning
CLOUDFLARE_API_TOKENvars/shared.yamlCloudflare deploy
CLOUDFLARE_ACCOUNT_IDvars/shared.yamlCloudflare account
CI_AGE_KEYvars/shared.yamlBackup of SOPS_AGE_KEY
  • Bitwarden: CI key backup
  • .sops.yaml.backup: Rollback point
  • vars/shared.yaml.backup: Rollback point
  • SOPS-WORKFLOW-GUIDE.md: Full documentation