Inspired by needing to make a quick change to my .ssh/config today I (finally?) decided to commit (some of) my .ssh directory into a local git repo.
I started by cding into ~/.ssh and running:
git init
Next I thought it would be an extremely bad idea to version the keys in the Git database, so this is what my .gitignore looks like currently:
# Common private key filenames
id_*
*_rsa
*_dsa
*_ecdsa
*_ed25519
*_sk
# Common key/container formats
*.pem
*.key
*.p12
*.pfx
*.ppk
*.kdbx
# Anything that looks like a key backup/export
*private*
*secret*
*backup*
*.bak
# SSH runtime / multiplex / sockets / temp
*.sock
tmp/
control-*
*.ctl
*.pid
# Authorized keys can reveal infra shape
authorized_keys
# Known hosts churn + can reveal infra shape
known_hosts
known_hosts.old
known_hosts2
# SSH certificates (optional: keep ignored unless you explicitly want them tracked)
*.pub
*-cert.pub
# OS/editor noise
.DS_Store
.vscode/
.idea/
*.swp
*.swo
# Other custom directories
agent/
old/
pub/
Sequel Ace/
Side note: I have been using 1password’s SSH agent for years, so all of my keys already live outside of my .ssh directory, which I highly recommend doing (and customizing as needed…)
Related to the above Git ignores, I also added a Git pre-commit hook that prevents me from naively committing anything that might unintentionally leak infrastructure shape into the Git database, by putting this into .ssh/git/hooks/pre-commit:
#!/usr/bin/env bash
set -euo pipefail
# Block committing anything that looks like a private key or other sensitive material.
# Runs only on staged content.
die() {
echo "✖ pre-commit: $*" >&2
exit 1
}
# Files staged for commit (Added/Copied/Modified/Renamed)
mapfile -t files < <(git diff --cached --name-only --diff-filter=ACMR)
# 1) Block by filename patterns (fast fail)
blocked_name_re='(^|/)(id_.*|.*_rsa|.*_dsa|.*_ecdsa|.*_ed25519|.*\.pem|.*\.key|.*\.p12|.*\.pfx|.*\.ppk)$'
for f in "${files[@]}"; do
if [[ "$f" =~ $blocked_name_re ]]; then
die "Refusing to commit file that looks like a key: $f"
fi
done
# 2) Block by content signatures (works even if renamed)
# Check only text-ish blobs; if binary, git show may print nothing, which is fine.
key_markers_re='BEGIN (OPENSSH|RSA|DSA|EC) PRIVATE KEY|BEGIN PRIVATE KEY|OPENSSH PRIVATE KEY|ENCRYPTED PRIVATE KEY'
for f in "${files[@]}"; do
# Read staged version
blob="$(git show ":$f" 2>/dev/null || true)"
if echo "$blob" | LC_ALL=C grep -Eqs "$key_markers_re"; then
die "Refusing to commit: $f contains private key material"
fi
done
# 3) Block accidental known_hosts commits (often noisy and revealing)
for f in "${files[@]}"; do
if [[ "$f" == "known_hosts" || "$f" == "known_hosts2" || "$f" == *"/known_hosts" ]]; then
die "Refusing to commit known_hosts: $f"
fi
done
exit 0
Don’t forget to make it executable:
chmod +x ~/.ssh/.git/hooks/pre-commit
And so now this is what my .ssh/config mostly looks like:
# ~/.ssh/config
Include ~/.ssh/config.d/*.conf
# Disable multiplexing (macOS 26.1 beta workaround)
ControlMaster no
ControlPath none
ControlPersist no
# Disconnect if no response
ServerAliveInterval 60
ServerAliveCountMax 5
# Defaults for all hosts
Host *
IdentityAgent "~/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock"
ForwardAgent yes
IPQoS none
PreferredAuthentications publickey,password,keyboard-interactive
And then, my various SSH configurations live in .ssh/config.d/ as different individual .conf files for each group/task/host, and they are automatically included at the top of the primary .ssh/config file.
That’s pretty much it. Now when I need to change my SSH config, I open the directory in VSCode, make my edits, and commit them into the local Git repo.