SSH keys: operators, agents, and infra-services¶
This runbook describes how SSH keys are expected to fit together after moving to per-tool keys (personal, Claude Code, Codex, Cursor) plus the original deploy key for automation.
Inbound (workstation → VM): for the split between 1Password (human) and file-key (Cursor / agents), see infra-services-inbound-ssh-humans-and-agents.md.
Two different planes¶
| Plane | Where | Purpose |
|---|---|---|
| Inbound | Your machines → someone@192.168.6.17 |
Human or agent shells, scp, port forwards |
| Outbound | infra-services → GitHub |
ansible-pull, manual git pull in /opt/homelab |
Those should use different keys so revoking or rotating a Cursor/IDE key never breaks unattended pulls.
On your laptop / workstation (inbound)¶
Use separate Host aliases in ~/.ssh/config so OpenSSH never has to
guess which key to offer (avoids "too many authentication failures" and keeps
agent keys isolated).
Example pattern (adjust paths and hostnames):
# Personal interactive shell
Host infra-services
HostName 192.168.6.17
User someone
IdentityFile ~/.ssh/id_ed25519_personal
# Cursor / automation from this IDE — dedicated key only
Host infra-services-cursor
HostName 192.168.6.17
User someone
IdentityFile ~/.ssh/id_ed25519_cursor
IdentitiesOnly yes
Then:
- Daily use:
ssh infra-services - From Cursor terminal when you want the tool identity:
ssh infra-services-cursor
IdentitiesOnly yes on tool hosts is strongly recommended so the agent does
not try every loaded key first.
On infra-services (outbound to GitHub)¶
Use different SSH identities for read (automation) vs write (you
pushing from a shell). GitHub deploy keys are often read-only; that is
correct for ansible-pull, but git push then fails with:
ERROR: The key you are authenticating with has been marked as read only.
That message means: Git connected with a key GitHub knows, but that key is not allowed to write to the repo. Your push must use a write-capable key (your personal GitHub SSH key, or a deploy key created with write access).
SSH config on infra-services (canonical host names)¶
On infra-services, ~/.ssh/config uses separate Host aliases that all
resolve to github.com, each with its own IdentityFile and
IdentitiesOnly yes (except: ensure github.com itself also sets
IdentitiesOnly yes so the agent does not offer other keys first).
Host (alias) |
Role | Typical private key |
|---|---|---|
github.com |
Pulls / ansible-pull / default git@github.com:… |
~/.ssh/github_deploy (often read-only on the repo) |
infra-services_personal |
Your manual git push to homelab |
~/.ssh/infra-services_personal |
services_cursor_homelab |
Cursor-driven Git from this host (if used) | ~/.ssh/services_cursor_homelab |
infra-services_codex_homelab |
Codex | ~/.ssh/infra-services_codex_homelab |
infra-services_cc_homelab |
Claude Code | ~/.ssh/infra-services_cc_homelab |
Example (adjust paths if yours differ):
# Pulls: ansible-pull, git fetch, git pull — deploy key (read-only is OK)
Host github.com
HostName github.com
User git
IdentityFile ~/.ssh/github_deploy
IdentitiesOnly yes
StrictHostKeyChecking accept-new
# Pushes: your GitHub user key (must have repo write access)
Host infra-services_personal
HostName github.com
User git
IdentityFile ~/.ssh/infra-services_personal
IdentitiesOnly yes
Host services_cursor_homelab
HostName github.com
User git
IdentityFile ~/.ssh/services_cursor_homelab
IdentitiesOnly yes
Host infra-services_codex_homelab
HostName github.com
User git
IdentityFile ~/.ssh/infra-services_codex_homelab
IdentitiesOnly yes
Host infra-services_cc_homelab
HostName github.com
User git
IdentityFile ~/.ssh/infra-services_cc_homelab
IdentitiesOnly yes
Remotes for other tools use git@<alias>:org/repo.git so SSH picks the right
key without touching the default github.com transport used by ansible-pull.
Split origin: fetch over github.com, push over infra-services_personal¶
Keep git@github.com:notarealemail/homelab.git for fetches (uses
Host github.com → deploy key). Send pushes through the personal alias:
cd /opt/homelab
git remote set-url origin git@github.com:notarealemail/homelab.git
git remote set-url --push origin git@infra-services_personal:notarealemail/homelab.git
git remote -v # confirm fetch vs push URLs
After that, git pull uses the deploy key; git push uses
infra-services_personal. No extra env vars for day-to-day use.
One-off push without changing remotes¶
cd /opt/homelab
GIT_SSH_COMMAND='ssh -i ~/.ssh/infra-services_personal -o IdentitiesOnly=yes' \
git push origin main
git push fails with publickey / permission denied¶
SSH never offered a key GitHub accepts for that repo (wrong file, or agent trying keys in the wrong order). Debug:
Check Offering public key lines match the key you expect for that operation.
Older runbook note (deploy key for push)¶
If you previously set core.sshCommand to force the deploy key for
everything, remove it before expecting personal pushes to work:
Authorized keys on the server¶
roles/common installs GitHub-sourced keys for users listed in
infra/ansible/inventory/group_vars/all.yml (common_github_ssh_users).
By default this is non-exclusive (common_ssh_authorized_keys_exclusive:
false in roles/common/defaults/main.yml): keys from GitHub are added
without removing manually managed inbound pubkeys (Cursor, Codex, deploy
host key, etc.). Set that variable to true only if you intentionally want
GitHub keys to be the sole source of truth for that account.
Keep a clear inventory of which public key belongs to which purpose so revocation is straightforward.
Repo hygiene¶
Private keys must never be committed. If a key file ever appears under the repo root by mistake, remove it and rotate that key.
Git: divergent main on infra-services (git pull refuses)¶
Two different things bite you:
- Branches actually diverged (local and
origin/maineach have unique commits). You must reconcile with rebase, merge, orreset --hardonce. - No default
git pullstrategy (Git 2.27+). Until you setpull.rebase,pull.ff, orpull.mergein this repo, a plaingit pullwill always abort withfatal: Need to specify how to reconcile divergent brancheswhenever histories are not fast-forward — even after you successfully rebased once. Fixing divergence does not set this; you must rungit configyourself.
Do this once per /opt/homelab clone (homelab standard)¶
Prefer rebase so main stays linear next to GitHub. (Use
pull.rebase false if you really want merge commits.)
After that, when histories diverge, git pull will rebase automatically
instead of failing with the hint.
git pull / rebase: “You have unstaged changes”¶
With pull.rebase true, git pull may run a rebase. Git refuses if the
working tree is dirty (modified tracked files or untracked files you care
about).
Typical fix — stash everything (including untracked), pull, restore:
Resolve any stash conflicts if stash pop reports them. If the changes were
junk, use git restore … / delete untracked files instead of stashing.
Inspect divergence¶
- Lines starting with
<are only on GitHub. - Lines starting with
>are only local.
Reconcile¶
Discard local-only commits (GitHub is source of truth; matches
ansible-pull):
Keep local-only commits — rebase onto origin/main, then push with your
write identity (pushurl → infra-services_personal when configured):
For /opt/homelab on infra-services, prefer no stray local commits and
reset --hard origin/main when divergent unless you committed on purpose.
Optional: same pattern elsewhere¶
If the same per-tool inbound pattern should apply on other hosts, mirror the
Host alias approach for 192.168.6.71 (Proxmox) and 192.168.6.243
(saltierpoop).