Configs ssh

ssh config

A ~/.ssh/config for every day: short names for your hosts, one key per host, a bastion hop, keepalives, and one shared connection per host. Each entry says what was run against a test bastion and an internal host with OpenSSH 9.2.

The whole file

Change the HostName, User and IdentityFile lines. ssh keeps the first value it finds for each option, so specific hosts go at the top and Host * goes last.

# ~/.ssh/config
# ssh uses the FIRST value it finds for each option, so specific hosts go
# at the top and the Host * defaults go at the end.

# The bastion: the one machine you can reach directly
Host bastion
    HostName bastion.example.com
    User lab
    # Offer this key and only this key, even if the agent holds others
    IdentityFile ~/.ssh/id_ed25519
    IdentitiesOnly yes

# An internal host, reached through the bastion ("ssh target")
Host target
    HostName target.internal.example.com
    User lab
    IdentityFile ~/.ssh/id_ed25519
    IdentitiesOnly yes
    # Connect to bastion first, then tunnel to this host from there
    ProxyJump bastion

# Defaults for every host. Keep this block last.
Host *
    # Ask the server for a sign of life after 30 s of silence,
    # and hang up after 3 unanswered tries (about 90 s)
    ServerAliveInterval 30
    ServerAliveCountMax 3
    # Put a key into the running ssh-agent the first time ssh reads it from disk
    AddKeysToAgent yes
    # Don't hand your agent to remote hosts. Use ProxyJump to get past a bastion
    ForwardAgent no
    # Share one connection per host: the first ssh becomes the master
    ControlMaster auto
    # One socket per host, user and port. Create the directory first:
    #   mkdir -p -m 700 ~/.ssh/cm
    ControlPath ~/.ssh/cm/%C
    # Keep the master open for 10 minutes after the last session closes
    ControlPersist 10m

WHERE: ~/.ssh/config, mode 600 (chmod 600 ~/.ssh/config). No reload: ssh reads it every time it starts. Run mkdir -p -m 700 ~/.ssh/cm once before the first connection.

One key per host: IdentitiesOnly

IdentityFile adds a key to try, but ssh still offers every key in your agent too, and a server that allows only a few attempts can shut you out with "Too many authentication failures" before it reaches the right one. IdentitiesOnly yes offers only the files you configured. In the test, with two keys in the agent, ssh -v listed one key to try with it and both without it.

Through a bastion: ProxyJump

ssh target connects to bastion first, using the bastion block's user and key, then tunnels to the target. The target's name is looked up on the bastion, so internal names work. It's the config form of ssh -J and needs OpenSSH 7.3 or later. Older clients: ProxyCommand ssh -W %h:%p bastion.

Keepalives: ServerAliveInterval and ServerAliveCountMax

After 30 seconds without data, ssh asks the server for a reply through the encrypted channel. After 3 unanswered asks it hangs up, so a dead connection ends after about 90 seconds instead of leaving your terminal hanging. The default interval is 0, which means off.

One connection per host: ControlMaster, ControlPath, ControlPersist

The first ssh to a host becomes the master and listens on a socket. Later ssh runs to that host go through it and skip the connection and login: 0.308 s for the first connection and 0.004 s for the next ones in the test. ControlPersist 10m keeps the master around for 10 minutes after the last session. %C is a hash of the connection details (it includes the jump host since OpenSSH 9.6), so each host gets its own short socket name. Since OpenSSH 10.0, scp and sftp use an existing master but don't start one.

Create ~/.ssh/cm first. Without it ssh can't create the socket and quits with exit 255: unix_listener: cannot bind to path ...: No such file or directory. Through ProxyJump you only see Connection closed by UNKNOWN port 65535, so run ssh -v to find the real error. Keep the path short: Unix sockets are limited to 108 bytes on Linux (104 on macOS) and ssh adds a 17-character suffix while creating one. Keep it out of /tmp: the socket directory shouldn't be writable by other users. With ProxyJump the bastion gets its own master, so ssh -O exit target and ssh -O exit bastion close both.

Host * goes last

ssh reads the command line, then ~/.ssh/config, then /etc/ssh/ssh_config, and keeps the first value it finds for each option (IdentityFile, the forward options and SendEnv add up instead). With Host * and User nobody at the top, ssh -G bastion showed user nobody even though the bastion block below said User lab. Moved to the end, the bastion got lab and every other host got nobody.

AddKeysToAgent yes, ForwardAgent no

AddKeysToAgent yes loads a key into your running agent the first time ssh reads it, so you type the passphrase once per agent session. ForwardAgent is different: it lets the remote host use your agent while you're connected. In the test, with -A the target had an agent socket and could use both keys in the agent. Anyone with root there can authenticate elsewhere as you while you're connected, without ever seeing the keys. The ssh manual suggests a jump host as "a safer alternative", which is what ProxyJump is. Turn forwarding on for one trusted host's block if you must, never in Host *.

macOS: Apple's ssh also has UseKeychain yes to keep passphrases in the Keychain. OpenSSH builds without it reject it, so a config shared with Linux needs IgnoreUnknown UseKeychain at the top of the file.

Check the result: ssh -G

ssh -G target | grep -iE '^(hostname|user|identityfile|identitiesonly|proxyjump|serveraliveinterval|serveralivecountmax|addkeystoagent|forwardagent|controlmaster|controlpath|controlpersist) '

-G prints the final value of every option for that host, after every Host block, Include and the system file, and exits without connecting. It shows ControlPath fully expanded and ControlPersist 10m as 600. If ssh says Bad owner or permissions on ~/.ssh/config, run chmod 600 ~/.ssh/config.

Tested with OpenSSH 9.2p1 on Debian 12 (aarch64), client to bastion to internal target. Related: ssh cheat sheet, ssh one-liners, network one-liners, rsync, tmux config.