Setup SSH Key on Remote Machine¶
Copy a local SSH key to a remote machine (e.g. Raspberry Pi) for both SSH login and GitHub access.
Prerequisites¶
- SSH access to the remote machine (password auth initially)
- The public key added to your GitHub account at https://github.com/settings/keys
0. Create an SSH Key¶
Choosing an algorithm¶
| Algorithm | Command | Notes |
|---|---|---|
| Ed25519 | ssh-keygen -t ed25519 |
Recommended. Fast, secure, short keys. Works everywhere (OpenSSH >= 6.5). |
| Ed25519-SK | ssh-keygen -t ed25519-sk |
Hardware-bound (FIDO2 / YubiKey). Requires physical touch to sign. |
| ECDSA-SK | ssh-keygen -t ecdsa-sk |
FIDO2 fallback for older YubiKeys that don't support Ed25519-SK. |
| RSA (4096) | ssh-keygen -t rsa -b 4096 |
Legacy compatibility only. Use if connecting to very old systems. |
| ECDSA | ssh-keygen -t ecdsa -b 521 |
Less common; no advantage over Ed25519. |
TL;DR: Use ed25519 unless you need hardware key (ed25519-sk) or legacy compat (rsa).
Generating the key¶
# Basic (interactive prompts for path and passphrase)
ssh-keygen -t ed25519 -C "your_email@example.com"
# Explicit file path and comment
ssh-keygen -t ed25519 -f ~/.ssh/<key> -C "description or email"
# With a YubiKey (requires the key plugged in)
ssh-keygen -t ed25519-sk -f ~/.ssh/id_ed25519_sk -C "yubikey"
Passphrase or not?¶
| Scenario | Passphrase? | Why |
|---|---|---|
| Personal machine, full-disk encryption | Optional | Disk encryption already protects the file |
| Shared / multi-user machine | Yes | Prevents other users/processes from using your key |
| CI/CD, automation, headless server | No | No human to type it; use file permissions + secrets manager |
| High-security / compliance | Yes | Defense in depth; combine with SSH agent so you type it once |
Tip: You can change or remove a passphrase later without regenerating the key:
Managing keys after creation¶
Plain file (simplest)¶
Keys live as files in ~/.ssh/. You specify them explicitly in ~/.ssh/config:
Pros: simple, portable. Cons: key file on disk, must copy to each machine.
SSH agent¶
Load the key into an agent so you only type the passphrase once per session:
# Start agent (this repo does this automatically, see docs/tools/ssh-agent.md)
eval "$(ssh-agent -s)"
# Add key (prompts for passphrase if set)
ssh-add ~/.ssh/<key>
# macOS: store passphrase in Keychain (survives reboots)
ssh-add --apple-use-keychain ~/.ssh/<key>
Bitwarden SSH Agent¶
Store the key in Bitwarden vault; the desktop app acts as the SSH agent. No key file on disk after initial import.
See Bitwarden SSH Agent tutorial for full setup.
1Password / other password managers¶
Similar concept to Bitwarden -- the app provides an SSH agent socket. Consult your password manager's docs.
YubiKey (FIDO2)¶
The private key never leaves the hardware. The -sk key types (ed25519-sk, ecdsa-sk) generate a key handle stored in ~/.ssh/ but the actual signing happens on the YubiKey. You need the YubiKey plugged in for every SSH operation.
Comparison¶
| Method | Key on disk? | Portable across machines? | Passphrase needed each session? |
|---|---|---|---|
| Plain file | Yes | Copy manually | Every time (or no passphrase) |
| SSH agent | Yes (loaded into memory) | Copy key, agent auto-loads | Once per session |
| Bitwarden / 1Password | No (after import) | Synced via vault | Unlock vault once |
| YubiKey | Handle only | Carry the physical key | Touch per operation |
IdentitiesOnly yes¶
By default, the SSH client offers all keys from the agent to the server, one by one, before trying IdentityFile. This causes problems when:
- Too many keys in agent -- Most servers allow only 5-6 auth attempts. If your agent holds 8+ keys, the server rejects you with
Too many authentication failuresbefore it ever tries the right one. - Wrong key used for wrong host -- Without restriction, SSH might authenticate to a server with an unintended key (e.g. your personal key used on a work server).
- Security / audit -- You want to guarantee exactly which key is used for each host.
Add IdentitiesOnly yes to your SSH config to make SSH only use the key specified by IdentityFile, ignoring everything else in the agent:
# Per-host
Host github.com
IdentityFile ~/.ssh/<key>
IdentitiesOnly yes
# Or globally (recommended if you have many keys)
Host *
IdentitiesOnly yes
Note: With
IdentitiesOnly yes, every host entry must have anIdentityFile-- SSH won't fall back to agent keys. If you use a password manager agent (Bitwarden/1Password), you typically do NOT want this globally since the agent is the only source of keys.
| Scenario | IdentitiesOnly |
Why |
|---|---|---|
| Few keys, single agent | No (default) | Agent tries them all, no issue |
| Many keys in agent (5+) | Yes | Prevent "too many failures" |
| Multiple GitHub/GitLab accounts | Yes | Force correct key per host |
| Bitwarden/1Password agent only | No | Agent IS the identity source |
| Mix of file keys + agent keys | Yes per host | Explicit control |
1. Enable SSH Login to Remote¶
Standard key file¶
This appends the public key to ~/.ssh/authorized_keys on the remote.
YubiKey / hardware security key¶
If your key lives on a YubiKey (FIDO2 or PIV), there's no private key file on disk -- only the .pub file or the agent knows about it.
# If you have the .pub file (ed25519-sk / ecdsa-sk)
ssh-copy-id -i ~/.ssh/id_ed25519_sk.pub user@remote
# If the key is only in your agent (YubiKey PIV, gpg-agent, etc.)
# ssh-copy-id will automatically use keys from the agent
ssh-copy-id user@remote
SSH agent (Bitwarden, 1Password, gpg-agent, etc.)¶
When your keys are managed by an SSH agent and there's no .pub file on disk:
# List keys the agent currently holds
ssh-add -L
# ssh-copy-id picks up agent keys automatically
ssh-copy-id user@remote
# Or pipe a specific key from the agent
ssh-add -L | grep "<key comment or type>" | ssh user@remote 'mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys'
Note: Make sure
SSH_AUTH_SOCKpoints to the correct agent. See SSH Agent Fallback for how this repo managesSSH_AUTH_SOCK.
Then add to your local ~/.ssh/config:
Host rpi
HostName <IP or hostname>
User <user>
IdentityFile ~/.ssh/<key>
IdentitiesOnly yes # optional: use only this key, ignore agent
Verify: ssh rpi should connect without a password.
2. Copy the Key Pair to Remote¶
scp ~/.ssh/<key> ~/.ssh/<key>.pub rpi:~/.ssh/
ssh rpi 'chmod 600 ~/.ssh/<key> && chmod 644 ~/.ssh/<key>.pub'
3. Configure GitHub SSH on Remote¶
ssh rpi 'cat >> ~/.ssh/config << EOF
Host github.com
IdentityFile ~/.ssh/<key>
EOF'
ssh rpi 'chmod 600 ~/.ssh/config'
4. Switch Git Remote from HTTPS to SSH¶
General pattern: https://github.com/<user>/<repo>.git → git@github.com:<user>/<repo>.git
5. Verify¶
# Test GitHub SSH auth
ssh rpi 'ssh -T git@github.com'
# Expected: "Hi <user>! You've successfully authenticated..."
# Test git push
ssh rpi 'cd /path/to/repo && git push'
Automation: ssh-setup-remote¶
This repo includes a shell function that walks through all the steps above interactively:
It will prompt you to:
- Select or create an SSH key (algorithm, name, passphrase)
- ssh-copy-id the public key to enable passwordless login
- Copy the key pair to the remote (optional, for GitHub access)
- Add GitHub SSH config on the remote (optional)
- Wire the key into your local
~/.ssh/config— the wizard recognizes two cases: - If the alias is already configured (it follows
Include ~/.ssh/config.d/*recursively to find the right file), it adds theIdentityFileinto the existingHostblock in place instead of appending a duplicate. If the block already has anIdentityFile, it asks whether to replace / add another / skip, and re-running with the same key is a no-op. - If it's a new host (e.g.
user@ip), it appends a fresh alias block (to~/.ssh/configor a~/.ssh/config.d/drop-in) with an optionalIdentitiesOnly yes. When it writes a drop-in but your~/.ssh/configlacks theIncludeline, it offers to add it so the entry actually loads.
In-place editing uses
python3; if it is unavailable the wizard falls back to the append-only behavior.
Source: ~/.config/shell/96_ssh_setup.sh. A Windows PowerShell counterpart with the same
command name and the same flow ships in the companion dotfiles-windows repo.
ProxyJump chains¶
ssh(1) tunnels transparently through a ProxyJump host, so a naive setup on the final target
alone leaves the jump host asking for a password on every connection — the jump host never got a
key. ssh-setup-remote resolves the target's full jump chain first (recursively — a jump host
can have its own ProxyJump) and repeats the whole flow for every hop, outermost first:
Host zr-windows
HostName 61.169.38.122
User zhurun
Host zr
HostName 10.168.11.54
User hongxing
ProxyJump zr-windows
$ ssh-setup-remote zr
=== SSH Key Setup for zr ===
ProxyJump chain detected: zr-windows -> zr
Each hop needs its own key on its own authorized_keys — ProxyJump only
forwards TCP, so the jump host never sees your private key.
========================================
[1/2] zr-windows (jump)
========================================
...
========================================
[2/2] zr (target)
========================================
...
A jump host only gets steps 1 and 3 (install the key, wire local config) — ProxyJump authenticates end-to-end from your machine, so a jump host never needs the private key, and step 2 (copying the key pair) only runs for the final target. Already-passwordless hops are detected and offered a skip.
A private key with no .pub¶
If a key's public half is missing (a partial copy from another machine, an agent-only import), it
used to pass the existence check silently and only fail later inside ssh-copy-id with
failed to open ID file '…/key.pub': No such file or directory. The wizard now notices this right
after key selection and offers to regenerate it:
The public key is always derivable from the private key, so this is a safe, lossless repair — you
can also do it manually ahead of time: ssh-keygen -y -f ~/.ssh/some_key > ~/.ssh/some_key.pub.
Troubleshooting¶
The wizard says "Could not identify the remote OS" and then fails¶
Almost always a transport failure being reported as a remote-OS problem.
The usual cause on macOS is a ControlPath that overflows the 104-byte
sockaddr_un limit, which makes ssh exit 255 before it opens a socket:
Current ssh-setup-remote bounds the socket path and prints ssh's real stderr
instead of swallowing it, so the message above appears directly. To rule
multiplexing out entirely:
See pitfalls/ssh-controlpath-too-long-macos-tmpdir.md for the full story.
Remote Host Identification Has Changed¶
If you see WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! when connecting, the server's host key no longer matches what's stored in ~/.ssh/known_hosts. This commonly happens after reinstalling the OS or reprovisioning a server at the same IP.
Remove the old key and reconnect:
The error message tells you the offending line number (e.g. Offending ECDSA key in ~/.ssh/known_hosts:89), but ssh-keygen -R handles removal by hostname/IP automatically.
Grouped SSH configuration and dev integration¶
New machines receive a create-only SSH dispatcher with explicit Includes for
config.d/git/github.conf, config.d/git/gitlab.conf and
config.d/common/defaults.conf. Four spaces indent options. Include order,
not directory names or an implicit wildcard sort, defines precedence. The seed
works without dev installed. Existing flat/wildcard configurations retain their
legacy seeds and are never automatically migrated; moved seed files are not
recreated after their exact Include is removed. Grouped seed files are gated
by the dispatcher marker to avoid activating new definitions during an update.
With a dev build containing SSH management, run dev ssh format for the default
indentation preview or explicitly choose dev ssh organize to move complete Host
blocks and their comments into groups. Use --apply after reviewing the plan;
dev ssh restore <receipt> previews recovery. The new dev writes use its
macOS/Linux security backend. dev ssh manage separately selects fleet/Herdr
registrations and native Herdr profile actions; installing dotfiles does not
register hosts or start remote Herdr servers.
The Television SSH channel prefers dev ssh list --format tsv for active aliases
and falls back to a display-only flat/grouped file scan when the machine-management
command is unavailable (including older dev versions).
The shared SSH setup helper follows nested Includes and adds an exact Include for
a newly selected file, so wiring one file does not activate dormant neighbors.
Legacy directory-level helper subcommands remain available for existing callers.