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.