~/.ssh/config: 10 lines that saved me a million keystrokes
After 8 years of SSHing into dozens of servers across many clients, here's the ~/.ssh/config setup I wish I knew on day one — aliases, ProxyJump, ControlMaster, and the speed tricks.
In 2017 I was a fresh grad doing freelance work for 4 clients. Each client had their own VPS, their own IP, their own SSH port (because “security through obscurity”), their own key. Every morning my terminal looked like this:
ssh -p 2222 -i ~/.ssh/client_a_rsa deploy@45.123.45.67
ssh -p 2200 -i ~/.ssh/client_b_rsa root@srv.client-b.com
ssh -p 22 -i ~/.ssh/personal nhatdote@my-vps.com
To switch between clients I’d open Notion, copy a command, paste into the terminal. Slow. Stupid. And worst of all, I couldn’t remember any of it after a week away — what’s the IP, what port, which key. My Notion was full of ssh-commands.md files.
Year two, a senior watched me do this and asked: “Why aren’t you using ~/.ssh/config?” I said: “What’s that?”
This post is for 2017-me — and for anyone still copy-pasting SSH commands out of a Notion doc.
Where the config file lives
~/.ssh/config
Doesn’t exist? Create it:
touch ~/.ssh/config
chmod 600 ~/.ssh/config
chmod 600 matters. SSH refuses to read the config if it’s world-readable — security baked in.
Lesson 1: Alias every server
Instead of:
ssh -p 2222 -i ~/.ssh/client_a_rsa deploy@45.123.45.67
Add to ~/.ssh/config:
Host client-a
HostName 45.123.45.67
User deploy
Port 2222
IdentityFile ~/.ssh/client_a_rsa
Now just:
ssh client-a
Tab-complete works too — type ssh cli then Tab and your shell suggests the rest.
scp and rsync both read this config:
scp file.tar.gz client-a:/tmp/
rsync -avz ./dist/ client-a:/var/www/
No need to repeat port/user/key.
Lesson 2: Wildcards for groups
I have 5 servers under one client (web1, web2, db1, db2, redis). Same port, same key, different IPs:
Host client-a-web1
HostName 45.123.45.67
Host client-a-web2
HostName 45.123.45.68
Host client-a-db1
HostName 45.123.45.69
Host client-a-*
User deploy
Port 2222
IdentityFile ~/.ssh/client_a_rsa
Specific blocks first, wildcard last. SSH merges all matching blocks — HostName from the specific one, User/Port/IdentityFile from the wildcard.
Lesson 3: ProxyJump through a bastion
Client B has 1 public bastion host and 5 private servers with no public IPs. Old way:
ssh bastion
# inside bastion:
ssh internal-web1
Two hops. Copying files? Forget it — scp through 2 hops is painful.
New way — ProxyJump:
Host bastion-b
HostName bastion.client-b.com
User admin
IdentityFile ~/.ssh/client_b_rsa
Host internal-*
User deploy
IdentityFile ~/.ssh/client_b_rsa
ProxyJump bastion-b
Host internal-web1
HostName 10.0.1.10
Host internal-db1
HostName 10.0.1.20
Now ssh internal-web1 automatically hops through the bastion. scp file.sql internal-db1:/tmp/ jumps too.
Lesson 4: ControlMaster — connection multiplexing
Each SSH connect costs ~200–500ms in TCP + SSH handshake. When you git pull then git push then scp then ssh to the same server within a minute, that’s 4 handshakes.
ControlMaster reuses the first connection:
Host *
ControlMaster auto
ControlPath ~/.ssh/cm-%r@%h:%p
ControlPersist 10m
Put it at the bottom (Host * matches every host). After the first SSH, a socket gets created at ~/.ssh/cm-.... The next SSH/scp/rsync within 10 minutes reuses it — no handshake. Speed jumps from 500ms to ~10ms.
Heads up: if MFA is on, ControlMaster can keep a session alive after you think you logged out. Set ControlPersist no for sensitive servers.
Lesson 5: Keep-alive — stop mid-session disconnects
Every dev knows this feeling: SSH into a server, open vim, go grab coffee, come back to “Write failed: Broken pipe”. A NAT box somewhere between you and the server timed out the idle connection after ~5 minutes.
Fix:
Host *
ServerAliveInterval 60
ServerAliveCountMax 3
Every 60 seconds the SSH client sends a “still there?” packet. Server replies → the connection counts as alive, NATs keep their slot. Only after 3 missed replies (3 minutes) does it give up.
Lesson 6: AddKeysToAgent
On macOS, every time you SSH with a passphrase-protected key, you have to retype the passphrase. Maddening. Add:
Host *
AddKeysToAgent yes
UseKeychain yes
IdentitiesOnly yes
AddKeysToAgent yes: first SSH loads the key into ssh-agent → subsequent connections skip the prompt.
UseKeychain yes (macOS only): stores the passphrase in Keychain → survives reboot.
IdentitiesOnly yes: SSH only tries the IdentityFile declared in the block. Without this, ssh-agent throws every loaded key at the server — strict policies (GitHub) will rate-limit your IP after 5 fails.
My full config today
# Defaults for everyone
Host *
AddKeysToAgent yes
UseKeychain yes
IdentitiesOnly yes
ServerAliveInterval 60
ServerAliveCountMax 3
ControlMaster auto
ControlPath ~/.ssh/cm-%r@%h:%p
ControlPersist 10m
# Personal
Host my-vps
HostName my-vps.com
User nhatdote
IdentityFile ~/.ssh/personal
# Client A
Host client-a-*
User deploy
Port 2222
IdentityFile ~/.ssh/client_a_rsa
Host client-a-web1
HostName 45.123.45.67
Host client-a-web2
HostName 45.123.45.68
# Client B (via bastion)
Host bastion-b
HostName bastion.client-b.com
User admin
IdentityFile ~/.ssh/client_b_rsa
Host internal-*
User deploy
IdentityFile ~/.ssh/client_b_rsa
ProxyJump bastion-b
Host internal-web1
HostName 10.0.1.10
About 30 lines. Replaces 4 Notion docs and a year of memory.
Closing
~/.ssh/config is one of those files where the effort (15 minutes of setup) versus the payoff (a lifetime of saved keystrokes) is so lopsided it’s almost embarrassing. I just regret not using it from day one.
One last thing: this file is documentation for future-you. Comment liberally — two years from now you’ll thank yourself. I keep a comment at every block: “client-x — purpose — date added”.
Every server deserves one decent line in this file. The ssh-commands Notion folder can go to the trash.