API keys, SSH private keys, OAuth tokens, and project .env files are strewn across a machine’s filesystem. Backing them up in plaintext is a security risk; not backing them up at all is a disaster when a hard drive dies or a laptop gets stolen.
This brief walks through a practical solution: a private, encrypted git repository that stores every secret in one place. The repo is encrypted with git-crypt, backed up to GitHub, and recoverable from a Bitwarden vault entry using an automated test script.
The setup uses three layers of secrets storage: the macOS Keychain (for credentials used by the automation scripts), Bitwarden (for the git-crypt decryption key), and git-crypt itself (for the repo contents). Each layer unlocks the next: the Keychain authenticates to Bitwarden, Bitwarden holds the git-crypt key, and git-crypt decrypts the repo.
Prerequisites
| Requirement | Purpose |
|---|---|
| macOS (Apple Silicon) | Keychain access via security CLI; file paths assume macOS conventions |
| Git | Version control |
| Homebrew | Installing git-crypt and Bitwarden CLI |
| GitHub account | Remote repository host |
| Bitwarden account | Storing the git-crypt recovery key |
jq |
Parsing Bitwarden CLI output (install via brew install jq) |
The steps below use exampleuser as a placeholder for GitHub and Bitwarden usernames. Replace it with your own values.
Step 1: Create the Repository
cd ~/av/prj
mkdir exampleuser-vault
cd exampleuser-vault
git initCreate the directory structure for the secrets you want to track:
mkdir -p hermes/profiles/{glm52,dspro,kimi,sonnet} \
ssh gpg/private-keys-v1.d claude \
projects/{peertube,searxng,firecrawl/api} \
personal| Directory | Content | Sync mode |
|---|---|---|
hermes/.env |
Global Hermes Agent environment variables | Source-of-truth (symlink back) |
hermes/profiles/*/.env |
Per-profile .env files | Source-of-truth (symlink back) |
hermes/auth.json |
OAuth tokens | Mirror (copied in) |
hermes/gateway_state.json |
Platform bot tokens | Mirror |
hermes/channel_directory.json |
Webhook URLs and channel IDs | Mirror |
hermes/processes.json |
Managed process configurations | Mirror |
ssh/ |
SSH private keys and config | Mirror |
gpg/ |
GPG private keys | Mirror |
claude/.credentials.json |
Claude Code API tokens | Mirror |
projects/ |
Project .env files (PeerTube, SearXNG, etc.) | Mirror |
personal/ |
Personal recovery codes, PGP keys, etc. | Add manually |
Source-of-truth means the repo holds the canonical copy and the filesystem location is a symlink to the repo – edit in the repo and the original sees the change. Mirror means the original file stays in place and sync.sh copies it into the repo; never edit inside the repo directory for mirrors.
Step 2: Install and Initialize git-crypt
brew install git-crypt
cd ~/av/prj/exampleuser-vault
git-crypt initThis generates a 148-byte symmetric key stored in .git/git-crypt/keys/default. This key is the single point of failure for the entire vault: lose it and the encrypted files are permanently unreadable.
Step 3: Configure Encryption Rules
Create .gitattributes to control which files are encrypted:
# Hermes secrets
hermes/.env filter=git-crypt diff=git-crypt
hermes/auth.json filter=git-crypt diff=git-crypt
hermes/gateway_state.json filter=git-crypt diff=git-crypt
hermes/channel_directory.json filter=git-crypt diff=git-crypt
hermes/processes.json filter=git-crypt diff=git-crypt
hermes/profiles/*/.env filter=git-crypt diff=git-crypt
# SSH
ssh/id_ed25519 filter=git-crypt diff=git-crypt
ssh/id_rsa filter=git-crypt diff=git-crypt
ssh/config filter=git-crypt diff=git-crypt
ssh/id_*.pub filter=git-crypt diff=git-crypt
# GPG
gpg/** filter=git-crypt diff=git-crypt
# Claude
claude/.credentials.json filter=git-crypt diff=git-crypt
# Projects
projects/**/.env filter=git-crypt diff=git-crypt
projects/**/.env.local filter=git-crypt diff=git-crypt
# Personal
personal/** filter=git-crypt diff=git-crypt
# Plaintext (never encrypted)
.gitattributes !filter !diff
.gitignore !filter !diff
sync.sh !filter !diff
README.md !filter !diff
test-recovery.sh !filter !diff
The !filter !diff lines ensure those files are never picked up by git-crypt, even if a glob higher in the file matches them.
Step 4: Populate the Repository
Copy every secret file into its correct location:
# Hermes
cp ~/.hermes/.env hermes/
cp ~/.hermes/auth.json hermes/
cp ~/.hermes/gateway_state.json hermes/
cp ~/.hermes/channel_directory.json hermes/
cp ~/.hermes/processes.json hermes/
for p in glm52 dspro kimi sonnet; do
cp ~/.hermes/profiles/$p/.env hermes/profiles/$p/
done
# SSH
cp ~/.ssh/id_ed25519 ssh/
cp ~/.ssh/id_ed25519.pub ssh/
cp ~/.ssh/id_rsa ssh/
cp ~/.ssh/id_rsa.pub ssh/
cp ~/.ssh/config ssh/
# GPG
cp -a ~/.gnupg/private-keys-v1.d gpg/
# Claude
cp ~/.claude/.credentials.json claude/
# Projects
cp ~/av/prj/peertube/.env projects/peertube/
cp ~/av/env/searxng/.env projects/searxng/
cp ~/av/src/firecrawl/apps/api/.env projects/firecrawl/api/Files with strict permission requirements (SSH keys at chmod 600, .env files) are copied as mirror snapshots. The repository does not need to enforce permissions – only you will ever access it.
Step 5: Verify Encryption and Commit
git add -A
git-crypt statusEvery sensitive file should show as encrypted. Plaintext files (.gitattributes, sync.sh, README.md) should show as not encrypted.
git commit -m "init: exampleuser-vault with git-crypt"Step 6: Create Symlinks for Source-of-Truth Files
For files that change frequently (Hermes .env), edit them in the repo and symlink the original location back to the repo:
VAULT=~/av/prj/exampleuser-vault
# Backup originals
cp ~/.hermes/.env ~/.hermes/.env.backup-$(date +%F)
rm ~/.hermes/.env
ln -s "$VAULT/hermes/.env" ~/.hermes/.env
for p in glm52 dspro kimi sonnet; do
cp ~/.hermes/profiles/$p/.env ~/.hermes/profiles/$p/.env.backup-$(date +%F)
rm ~/.hermes/profiles/$p/.env
ln -s "$VAULT/hermes/profiles/$p/.env" ~/.hermes/profiles/$p/.env
doneNow editing ~/av/prj/exampleuser-vault/hermes/.env immediately updates ~/.hermes/.env and vice versa. Hermes Agent sees the change without any additional steps.
Step 7: Create the Sync Script
The sync script copies mirror files into the repo, stages changes, commits, and pushes to GitHub. Source-of-truth files (the symlinked .env files) are already in the repo – the script just ensures they’re staged.
#!/bin/bash
set -euo pipefail
REPO="$HOME/av/prj/exampleuser-vault"
cd "$REPO"
echo "[sync] Mirroring secrets into exampleuser-vault..."
# ---- Hermes (mirror) ----
cp "$HOME/.hermes/auth.json" hermes/auth.json
cp "$HOME/.hermes/gateway_state.json" hermes/gateway_state.json
cp "$HOME/.hermes/channel_directory.json" hermes/channel_directory.json
cp "$HOME/.hermes/processes.json" hermes/processes.json
# ---- SSH (mirror) ----
cp "$HOME/.ssh/id_ed25519" ssh/id_ed25519
cp "$HOME/.ssh/id_ed25519.pub" ssh/id_ed25519.pub
cp "$HOME/.ssh/id_rsa" ssh/id_rsa
cp "$HOME/.ssh/id_rsa.pub" ssh/id_rsa.pub
cp "$HOME/.ssh/config" ssh/config
# ---- GPG (mirror) ----
rm -rf gpg/private-keys-v1.d
cp -a "$HOME/.gnupg/private-keys-v1.d" gpg/private-keys-v1.d
# ---- Claude (mirror) ----
cp "$HOME/.claude/.credentials.json" claude/.credentials.json
# ---- Projects (mirror) ----
cp "$HOME/av/prj/peertube/.env" projects/peertube/.env 2>/dev/null || true
cp "$HOME/av/env/searxng/.env" projects/searxng/.env 2>/dev/null || true
cp "$HOME/av/src/firecrawl/apps/api/.env" projects/firecrawl/api/.env 2>/dev/null || true
# ---- Stage, commit, push ----
git add -A
if git diff --cached --quiet; then
echo "[sync] Nothing changed."
exit 0
fi
git commit -m "sync $(date +%Y-%m-%d_%H%M)"
git push
echo "[sync] Done -- pushed to origin."Make it executable: chmod +x sync.sh.
Step 8: Create the Private GitHub Repository
brew install gh # if not already installed
gh auth login # authenticate with your GitHub account
cd ~/av/prj/exampleuser-vault
gh repo create exampleuser-vault --private --source=. --pushThis creates the private repository, sets the remote, and pushes the initial commit. At this point, the encrypted files are on GitHub and cannot be read by anyone without the git-crypt key.
Step 9: Store the git-crypt Key in Bitwarden
The git-crypt key must be recoverable without access to the repo itself. Storing it in Bitwarden creates a clean recovery path.
9.1 Install Bitwarden CLI
brew install bitwarden-cli
bw login # interactive login with email + master password9.2 Create a Secure Note
export BW_SESSION="<from_bw_login>"
B64_KEY=$(base64 < ~/exampleuser-vault-key)
bw get template item | \
jq --arg name "exampleuser-vault-gitcrypt" \
--arg notes "Git-crypt key for https://github.com/exampleuser/exampleuser-vault.git
Recovery:
echo \"<paste_key>\" | base64 -d > /tmp/git-crypt-key
cd exampleuser-vault
git-crypt unlock /tmp/git-crypt-key
Key (base64):
$B64_KEY" \
'.type = 2 | .name = $name | .notes = $notes | .secureNote = {type: 0}' | \
bw encode | bw create item --session "$BW_SESSION"The note name (exampleuser-vault-gitcrypt) is what the recovery script searches for. Keep it consistent.
9.3 Export a Local Copy
git-crypt export-key ~/exampleuser-vault-keyThis is a 148-byte binary file. It can also be stored on a USB drive or printed as hex paper backup.
Step 10: Store Bitwarden Credentials in macOS Keychain
The recovery script needs to authenticate to Bitwarden automatically. Store the master password in the macOS Keychain rather than in a file or environment variable.
Create a storage script:
cat > ~/av/bin/store-bw-master-pass.sh << 'SCRIPT'
#!/bin/bash
set -euo pipefail
echo "=== Bitwarden Master Password Storage ==="
read -s -p "Master password: " PASS
echo
read -s -p "Confirm master password: " CONFIRM
echo
if [ "$PASS" != "$CONFIRM" ]; then
echo "FAIL: Passwords do not match."
exit 1
fi
security add-generic-password -s "bitwarden" -a "BW_MASTER_PASS" -w "$PASS"
echo "Stored in macOS Keychain."
SCRIPT
chmod +x ~/av/bin/store-bw-master-pass.shThe script prompts twice and verifies a match before storing, preventing a mistyped password from locking you out during recovery. Run it once:
~/av/bin/store-bw-master-pass.shThe Bitwarden email can be stored similarly if bw status does not have it cached:
security add-generic-password -s "bitwarden" -a "BW_EMAIL" -w "[email protected]"Step 11: Create the Recovery Test Script
The recovery test validates the entire chain end-to-end: authenticate to Bitwarden, fetch the git-crypt key, clone the repo, unlock it, and verify every file decrypts correctly. It runs in a temp directory and cleans up after itself.
#!/bin/bash
set -euo pipefail
VAULT_REPO="https://github.com/exampleuser/exampleuser-vault.git"
BW_ITEM_NAME="exampleuser-vault-gitcrypt"
TMPDIR=$(mktemp -d /tmp/vault-test-XXXXX)
trap 'rm -rf "$TMPDIR"' EXIT
FAIL=0
login_bw() {
local STATUS
STATUS=$(bw status 2>/dev/null | jq -r '.status // "unauthenticated"')
if [ "$STATUS" = "unlocked" ]; then
echo "[0/4] Vault already unlocked"
return
fi
local PASS
PASS=$(security find-generic-password -s "bitwarden" -a "BW_MASTER_PASS" -w 2>/dev/null) || true
if [ -z "$PASS" ]; then
echo "FAIL: Master password not in keychain."
echo " Run ~/av/bin/store-bw-master-pass.sh"
exit 1
fi
local EMAIL
EMAIL=$(bw status 2>/dev/null | jq -r '.userEmail // empty')
if [ -z "$EMAIL" ]; then
EMAIL=$(security find-generic-password -s "bitwarden" -a "BW_EMAIL" -w 2>/dev/null) || true
fi
if [ -z "$EMAIL" ]; then
echo "FAIL: Could not determine Bitwarden email."
exit 1
fi
if [ "$STATUS" != "unauthenticated" ]; then
bw logout --quiet 2>/dev/null || true
fi
echo "[0/4] Logging in to Bitwarden..."
export BW_MASTER_PASS="$PASS"
BW_SESSION=$(bw login "$EMAIL" --passwordenv BW_MASTER_PASS --raw 2>/dev/null) || {
echo "FAIL: Login failed."
exit 1
}
unset BW_MASTER_PASS
PASS=""
export BW_SESSION
echo " OK -- logged in"
}
echo "=== exampleuser-vault recovery test ==="
echo
login_bw
echo "[1/4] Fetching key from Bitwarden..."
NOTE=$(bw list items --search "$BW_ITEM_NAME" --session "$BW_SESSION" 2>/dev/null)
ITEM_ID=$(echo "$NOTE" | jq -r '.[0].id // empty')
[ -z "$ITEM_ID" ] && { echo "FAIL: Item not found"; exit 1; }
NOTES=$(bw get item "$ITEM_ID" --session "$BW_SESSION" | jq -r '.notes // empty')
B64_KEY=$(echo "$NOTES" | sed -n '/^Key (base64):/,$ p' | tail -n +2 | tr -d '[:space:]')
[ -z "$B64_KEY" ] && { echo "FAIL: Key not found in note"; exit 1; }
echo " OK -- found item $ITEM_ID"
echo
echo "[2/4] Cloning repo..."
git clone --quiet "$VAULT_REPO" "$TMPDIR/repo" 2>&1
echo " OK -- cloned"
echo
echo "[3/4] Unlocking with git-crypt..."
echo "$B64_KEY" | base64 -d > "$TMPDIR/git-crypt-key"
cd "$TMPDIR/repo"
git-crypt unlock "$TMPDIR/git-crypt-key" 2>&1
echo " OK -- unlocked"
echo
echo "[4/4] Verifying decrypted content..."
check_file() {
local path="$1" label="$2"
if [ ! -f "$TMPDIR/repo/$path" ]; then
echo " FAIL: $label -- missing"; return 1
fi
local ft; ft=$(file "$TMPDIR/repo/$path")
if echo "$ft" | grep -qiE "text|ASCII|JSON|OpenSSH|PEM|RSA"; then
echo " OK: $label -- $(echo "$ft" | cut -d: -f2)"
else
echo " FAIL: $label -- still encrypted"; return 1
fi
}
check_file "hermes/.env" "Hermes .env"
check_file "hermes/auth.json" "Auth JSON"
check_file "ssh/id_ed25519" "SSH private key"
check_file "ssh/id_rsa" "SSH RSA key"
check_file "claude/.credentials.json" "Claude credentials"
check_file "ssh/config" "SSH config"
if grep -q "export" "$TMPDIR/repo/hermes/.env" 2>/dev/null; then
echo " OK: hermes/.env has expected content"
else
echo " FAIL: hermes/.env wrong content"; FAIL=1
fi
echo
[ "$FAIL" -eq 0 ] && echo "PASS" || { echo "FAIL"; exit 1; }The file command is the key content check: git-crypt stores encrypted files as binary data. If file reports JSON, ASCII, or OpenSSH private key, the file decrypted correctly. If it reports “data” (binary), the file is still encrypted.
Step 12: Push the Infrastructure
Commit everything – the sync script, recovery test, and this README – to the vault repo:
cd ~/av/prj/exampleuser-vault
git add sync.sh test-recovery.sh README.md
git commit -m "feat: sync, recovery test, and documentation"
git pushStep 13: Schedule Daily Sync
Add a cron entry to push changes automatically:
(crontab -l 2>/dev/null; echo "# exampleuser-vault -- daily at 6:30am")
(crontab -l 2>/dev/null; \
echo "30 6 * * * $HOME/av/prj/exampleuser-vault/sync.sh >> \
$HOME/av/log/exampleuser-vault-sync.log 2>&1") | crontab -This runs at 6:30 AM daily, after other early-morning maintenance jobs. The log file captures any errors.
Recovery Procedure
On a new machine, restore from scratch:
Step A: Install Prerequisites
brew install git git-crypt jq bitwarden-cliStep B: Clone and Unlock
git clone https://github.com/exampleuser/exampleuser-vault.git
cd exampleuser-vault
# Retrieve the key from Bitwarden
bw login
# Open the "exampleuser-vault-gitcrypt" secure note
# Copy the base64 key from the notes field
echo "<pasted_base64>" | base64 -d > /tmp/git-crypt-key
git-crypt unlock /tmp/git-crypt-key
rm /tmp/git-crypt-keyStep C: Restore Symlinks and Files
VAULT="$PWD"
# Hermes .env (source-of-truth -- symlink)
ln -sf "$VAULT/hermes/.env" ~/.hermes/.env
for p in glm52 dspro kimi sonnet; do
ln -sf "$VAULT/hermes/profiles/$p/.env" ~/.hermes/profiles/$p/.env
done
# SSH (mirror -- copy, set perms)
cp ssh/id_ed25519 ~/.ssh/ && chmod 600 ~/.ssh/id_ed25519
cp ssh/config ~/.ssh/
# Claude (mirror -- copy)
cp claude/.credentials.json ~/.claude/Step D: Restore Automation
# Bitwarden master password in keychain
~/av/bin/store-bw-master-pass.sh
# Cron job
(crontab -l 2>/dev/null; echo "30 6 * * * $VAULT/sync.sh >> \
~/av/log/exampleuser-vault-sync.log 2>&1") | crontab -Step E: Verify
cd ~/av/prj/exampleuser-vault && bash test-recovery.shSecurity Considerations
| Threat | Mitigation |
|---|---|
| GitHub breach exposes encrypted files | git-crypt uses AES-256-GCM; without the key, files are unreadable |
| Bitwarden account compromised | Master password + email + 2FA protect the vault |
| macOS Keychain unlocked on stolen machine | Same threat model as Bitwarden being open on a stolen phone – both require the device to be unlocked |
| git-crypt key lost | Two independent copies: Bitwarden secure note and macOS Keychain; optionally paper backup |
| Attacker gains physical access to unlocked Mac | Both Keychain and Bitwarden are accessible – this is a physical security problem, not a cryptography one |
| cron job fails silently | Logs to ~/av/log/exampleuser-vault-sync.log; recovery test catches failures proactively |
The weakest link in any secrets management system is the human who types the password. The double-entry confirmation in store-bw-master-pass.sh prevents a mistyped master password from bricking recovery.
Key Architecture Decisions
Why git-crypt and not a password manager? Password managers are optimized for individual entries, not for version-controlled collections of files. git-crypt gives you git history (who changed what, when), diffs between versions (for JSON config files), and a single git pull to sync.
Why symlinks for source-of-truth files? Files that the system reads from a fixed path (like ~/.hermes/.env) cannot be moved without breaking the application. Symlinks allow the canonical copy to live in the repo while the application sees the expected path.
Why mirror (copy) for SSH keys? SSH keys require chmod 600 permissions. If the repo were the canonical copy, a careless git clone would leave them world-readable. Mirroring means the canonical copy stays in ~/.ssh/ with correct permissions; the repo gets a snapshot for backup.
Why Bitwarden and not just macOS Keychain? Keychain is tied to this Mac. If the machine is destroyed physically, the git-crypt key dies with it. Bitwarden is cloud-synced and recoverable from any device, making it the cross-machine recovery layer.
Why a full recovery test script? An encrypted repo is worthless if the key doesn’t work, if the Bitwarden note got corrupted, or if a file wasn’t actually encrypted. The test script validates the entire chain every time and would catch, for example, a .gitattributes change that accidentally left a file unencrypted.
Reference
| Component | Location |
|---|---|
| Vault repository | ~/av/prj/exampleuser-vault/ |
| GitHub remote | https://github.com/exampleuser/exampleuser-vault.git |
| git-crypt key | Bitwarden secure note “exampleuser-vault-gitcrypt” |
| git-crypt key (local) | macOS Keychain “exampleuser-vault-gitcrypt” |
| BW master password | macOS Keychain “bitwarden”/“BW_MASTER_PASS” |
| Sync script | ~/av/prj/exampleuser-vault/sync.sh |
| Recovery test | ~/av/prj/exampleuser-vault/test-recovery.sh |
| BW credential store | ~/av/bin/store-bw-master-pass.sh |
| Sync log | ~/av/log/exampleuser-vault-sync.log |
Version: 1.0 (2026-07-28)
Want to stay in touch?
- Signal (announcements): https://signal.group/#CjQKIGLn7xDB0uOXMMlbKlsKEG0CmkmL9gk3U0SeIX0KlKRZEhDoqIluCXo84TrBz-2tMJD7
- Signal (discussion): https://signal.group/#CjQKIDA0v6tUciWe-3jRArkbYttju8xfuoczTOfMrGuvhmEZEhCrOnPk-IWFmFmipdI1EHxv
- Signal: archerships.43 (https://signal.me/#eu/9JUc8x9c-QA0_-QR9qQd0HUmjsnAG1BeOJM2nDo5DopjIPq5bThAJYr99lsh0cPP)
- Mailing list: https://archerships.substack.com/subscribe
- Email: [email protected]
- Website: https://archerships.com
- Substack: https://substack.com/@archerships
- Twitter: https://x.com/archerships
- Facebook: https://www.facebook.com/archerships
- Yahihonne: https://yakihonne.com/profile/nprofile1qqsgr0xn6vvr8su9ptzj4n50j8vzmczzayed0wcl5rdnvh0tc6xhqncy6jrjw
- Nostr-npub:
npub1sx7d85ccx0pc2zk99t8glywc9hsy96fj67a3lgxmxew7h35dwp8shak49e - Odysee: https://odysee.com/@archerships:6
- TikTok: https://www.tiktok.com/@archertships
Support my work
- Donations (crypto): https://trocador.app/anonpay/?ticker_to=xmr&network_to=Mainnet&address=85e4n5bgLTWiAWZbkjbbF5MLrwyiU8kjxHWHL9t6vDE5MyNUCPzBuZUNDcvbCisC5iW5PPBP9ETRQUWQQjMuvAhHRFaYCeM&donation=True&simple_mode=True&name=Archerships&[email protected]&ticker_from=xmr&network_from=Mainnet&bgcolor=000000ff
- Donations (fiat): https://ko-fi.com/archerships
- Consulting: privacy / crypto / censorship consulting – email or Signal