Skip to content

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:

ssh -vT git@github.com 2>&1 | tail -40

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:

git config --unset core.sshCommand

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:

  1. Branches actually diverged (local and origin/main each have unique commits). You must reconcile with rebase, merge, or reset --hard once.
  2. No default git pull strategy (Git 2.27+). Until you set pull.rebase, pull.ff, or pull.merge in this repo, a plain git pull will always abort with fatal: Need to specify how to reconcile divergent branches whenever histories are not fast-forward — even after you successfully rebased once. Fixing divergence does not set this; you must run git config yourself.

Do this once per /opt/homelab clone (homelab standard)

cd /opt/homelab
git config pull.rebase true

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:

cd /opt/homelab
git status
git stash push -u -m "wip before git pull"
git pull
git stash pop

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

cd /opt/homelab
git fetch origin
git log --oneline --left-right origin/main...HEAD
  • 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):

cd /opt/homelab
git fetch origin
git reset --hard origin/main

Keep local-only commits — rebase onto origin/main, then push with your write identity (pushurlinfra-services_personal when configured):

cd /opt/homelab
git pull --rebase origin main
# resolve conflicts if any, then:
git push

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).