If I count the time I have spent typing IP addresses, long hostnames, -i key
paths and non-standard ports, it adds up to whole working days. The single best
productivity upgrade I ever made on the command line was not a new terminal or a
multiplexer — it was writing a ~/.ssh/config file and letting it pay the tax for
me. From there, the same file quietly unlocks the rest of advanced SSH: ssh
tunnels, ProxyJump through a bastion, and clean port forwarding without
memorized one-off flags.
This is the version of SSH I actually run on my own machines. If you are still hardening a fresh box, my VPS hardening checklist covers the server side; this post is the client side. And if some of your hosts are not routable at all, a mesh overlay like my EasyTier setup is the layer above this one.
~/.ssh/config: the single best productivity upgrade
SSH reads ~/.ssh/config automatically on every invocation. Anything you would
pass as -o Key=Value on the command line can live there permanently, scoped to
a matching host. The result is that ssh web replaces a command you never want to
type again.
Here is a config I would happily hand to a new teammate:
# ~/.ssh/config — chmod 600, chown you:you
# Applies to every host. Keep this small and safe.
Host *
ServerAliveInterval 30
ServerAliveCountMax 3
AddKeysToAgent yes
IdentitiesOnly yes
Host web
HostName 203.0.113.10
User deploy
Port 49222
IdentityFile ~/.ssh/id_ed25519
ServerAliveInterval 15
Host bastion
HostName bastion.example.com
User jump
Port 22
IdentityFile ~/.ssh/id_ed25519_bastion
Host db
HostName 10.0.0.5
User postgres
ProxyJump bastion
IdentityFile ~/.ssh/id_ed25519
What each directive buys you:
Hostis the alias you type, and it can match patterns (Host *.internal). It is not a hostname — it is a label.HostNameis the real address behind the label. Nowssh webreachesdeploy@203.0.113.10:49222without a single flag.UserandPortremove the two most common typos in ad-hoc commands.IdentityFilepins which key to use, per host. No more guessing.IdentitiesOnly yestells SSH to offer only that key, not every key the agent holds. This alone fixes mostToo many authentication failureserrors when yourssh-agenthas a dozen keys loaded.ProxyJumproutes through another host (more below).ServerAliveInterval 30sends a keepalive every 30 seconds so NAT and idle firewalls do not silently drop a session you left open.ServerAliveCountMax 3means you learn the link is dead within ~90 seconds instead of hanging forever.
Two habits make this safe: chmod 600 ~/.ssh/config (it can contain hostnames you
would rather not advertise), and ssh -G web to print the fully-resolved config
for a host when something is not behaving.
ed25519 keys, ssh-agent, and hardware keys
If you are generating keys today, generate ed25519. It is small, fast, and strictly better than RSA for new keys.
ssh-keygen -t ed25519 -C "deepseek@laptop-2026" -f ~/.ssh/id_ed25519
# ed25519-sk is the FIDO2 hardware-backed variant: touch required to use it
ssh-keygen -t ed25519-sk -C "deepseek yubikey" -f ~/.ssh/id_ed25519_sk
Set a passphrase. The comment (-C) is just a label so you can tell keys apart in
a log later. Then let ssh-agent hold the unlocked key so you type the
passphrase once per boot, not once per connection:
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519
ssh-add -l # list what the agent is holding
The temptation next is agent forwarding (ssh -A bastion), which forwards
your local agent to the remote host so you can hop onward without copying keys.
Do not do this casually. While you are connected, root on that remote host can
talk to your forwarded agent socket and use it to authenticate as you to
anything your keys can reach. A compromised bastion becomes a skeleton key. The
modern answer is ProxyJump, which keeps the private key on your laptop and
never exposes the agent to the intermediate host.
For the machines that matter most, use a hardware key (ed25519-sk). The private
key never leaves the token, and every use requires a physical touch — which also
means a stolen laptop is not a stolen identity.
Local port forwarding with -L
Local forwarding opens a listening socket on your machine and tunnels every connection to a destination reachable from the remote side. This is the tool for reaching a service that only listens on loopback.
# Syntax: -L [local_bind:]local_port:dest_host:dest_port
ssh -N -L 5432:127.0.0.1:5432 db
# in another terminal, the database now looks local:
psql -h 127.0.0.1 -p 5432 -U postgres mydb
The two flags worth knowing: -N means “do not run a shell, just forward”, and
-f pushes it to the background so your terminal stays usable. The destination is
resolved on the remote side, so -L 8080:internal-web:80 web lets your
browser hit http://localhost:8080 and reach a service that only exists inside
the private network. By default the listener binds to 127.0.0.1; do not bind it
to 0.0.0.0 unless you truly mean to expose that port to your whole LAN.
Remote port forwarding with -R
Remote forwarding is the mirror image: the listening socket lives on the remote, and connections to it come back down the tunnel to your machine. This is how you show a local dev server to someone, or reach a box behind NAT where only outbound connections are possible.
# Syntax: -R [remote_bind:]remote_port:dest_host:dest_port
ssh -N -R 8080:localhost:3000 web
# now, on the server 'web':
curl http://127.0.0.1:8080 # served by your laptop's :3000
By default the remote listener is loopback-only, which is the safe default. To
bind it wider you need GatewayPorts clientspecified in the server’s
sshd_config and an explicit bind address — think hard before doing that, because
you have just punched a hole through the firewall into your laptop.
Dynamic forwarding with -D: one tunnel, any destination
Local and remote forwarding are point-to-point. Dynamic forwarding turns SSH into a SOCKS5 proxy: you point your client at one local port and it can reach any host the remote can reach, chosen at connect time.
ssh -N -D 1080 bastion
# every request through this proxy exits from bastion's network
curl --socks5-hostname 127.0.0.1:1080 https://internal.example.com/
--socks5-hostname is the important part: it asks the proxy to resolve DNS on
the remote side too, so your internal names never leak to your local resolver and
split-horizon DNS works. Any browser can point at 127.0.0.1:1080 as a SOCKS
proxy for the same effect. This is the fastest way to reach a whole subnet behind
a jump host without forwarding each service individually.
ProxyJump for bastion hosts
The chain I use most: a private db reachable only from a public bastion. With
the config above, ssh db just works — ProxyJump bastion means SSH opens a TCP
connection through the bastion and then negotiates the SSH session end-to-end to
db. The private key stays on your machine.
On the command line, without any config, the same thing is:
ssh -J jump@bastion.example.com postgres@10.0.0.5
# chain two hops, comma-separated, for a nested network
ssh -J jump@bastion.example.com,jump2@inner-host deploy@10.0.1.20
Prefer ProxyJump over the older ssh -A bastion then ssh db pattern for two
reasons: no agent forwarding (see the risk above), and the session is end-to-end
encrypted to db rather than trusting the bastion’s shell. If you see an old
ProxyCommand nc %h %p in a config, ProxyJump is the modern replacement — it
handles IPv6, multiple hops and retries for you.
Hardening that also improves daily UX
A few of the changes I would make “for security” turn out to make the client nicer too, which is why I make them first:
-
Key-only auth plus
IdentitiesOnly yes. Safer on the server and it silences the auth-failure noise on the client. -
Non-default port, stored in
Host. Loaners get scanned less, and you typessh webinstead ofssh -p 49222. -
ServerAliveInterval. Keeps idle tunnels and long sessions alive across NAT timeout windows. -
Connection multiplexing. Add this to
Host *and every repeated connection to the same host reuses the first one, which makesscp,gitand nestedsshfeel instant:Host * ControlMaster auto ControlPath ~/.ssh/cm-%r@%h:%p ControlPersist 10m -
ProxyJump instead of agent forwarding. One fewer secret in the blast radius.
-
ed25519-skfor the crown jewels. Physical touch, nothing to steal.
None of this replaces the server-side work — read the
hardening checklist for sshd_config, a
default-deny firewall and fail2ban. The client config just makes the safe path
also the convenient path, which is the only kind of security that survives a busy
week.
Frequently asked questions
Where does ~/.ssh/config have to live? At exactly that path for your user; a
system-wide /etc/ssh/ssh_config applies to everyone. First matching value wins,
so put specific Host blocks before broad Host * ones.
Is a tunnel secure end to end? The SSH transport is encrypted to the remote endpoint you specify. Data past that endpoint — say, from the server to a local database on its own loopback — is not; it only travels as far as you asked.
Can I keep a tunnel alive in the background? Yes: ssh -fN -L ... detaches
after authentication. Pair it with ServerAliveInterval and autossh if you need
it to survive reconnects.
Why is ProxyJump better than ProxyCommand? It is the same mechanism with
the boring parts handled — multiple hops, IPv6, and no external nc binary to get
wrong.