Secrets management
This guide documents the complete workflow for managing SOPS keys and secrets for the ironstar, 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@ironstar"
# 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 "ironstar 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/ironstar/.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