← Blog

~/.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.