I have been using SSH for over twenty years and I still occasionally forget the flag for tunneling. Not because the syntax is hard, but because SSH has so many little features that you only reach for once every few months — and by the time you need them again, your fingers have moved on.

So this post is the field guide I wish I could hand to past-me. Not “how SSH works under the hood” — there are excellent treatises for that. Just the dozen or so commands and config patterns I genuinely reach for during a normal week of operations work.

1. Generate a Key — and Pick the Right Algorithm

The default in 2026 is straightforward: Ed25519. It is faster, smaller, and has none of the historical baggage of RSA. Use RSA only when you must connect to something stuck in 2014.

ssh-keygen -t ed25519 -C "you@example.com"

The -C is a free-form comment that gets baked into the public key. I use my email so I can identify the key later when it shows up in an authorized_keys file on a server I half-remember.

Two flags worth knowing:

  • -a 100 — increases the KDF rounds. Makes the passphrase harder to brute-force if your private key ever leaks.
  • -f ~/.ssh/id_work — write the key somewhere other than the default. Essential as soon as you have more than one identity.
ssh-keygen -t ed25519 -a 100 -f ~/.ssh/id_work -C "alice@company.com"

Always set a passphrase. A private key without a passphrase is a credential anyone with file-read access can walk away with. The agent (next section) means you only type it once per session anyway.


2. Deploy the Public Key with ssh-copy-id

The first SSH command newcomers learn is the wrong one. They paste their public key over nano on the remote host and pray about line endings. There is a tool for this:

ssh-copy-id -i ~/.ssh/id_ed25519.pub user@server

It logs in once with your password, appends your public key to ~/.ssh/authorized_keys, and fixes the file permissions. After that, you never type the password again.

If ssh-copy-id is unavailable (some minimal images, BSDs, embedded boxes), the manual equivalent is:

cat ~/.ssh/id_ed25519.pub | ssh user@server "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"

3. The SSH Agent — Type Your Passphrase Once

The agent holds your decrypted private key in memory so the rest of your tools can use it without prompting. On Linux it’s usually already running; on macOS it integrates with Keychain.

# Start it (if not running) and load a key
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519

Useful flags:

ssh-add -l                    # list loaded keys
ssh-add -D                    # remove all loaded keys
ssh-add -t 3600 ~/.ssh/id_ed25519   # auto-expire after 1 hour

On macOS, this one-time setup makes the agent persistent and pulls the passphrase from Keychain:

ssh-add --apple-use-keychain ~/.ssh/id_ed25519

Pair it with this in ~/.ssh/config:

Host *
    UseKeychain yes
    AddKeysToAgent yes
    IdentityFile ~/.ssh/id_ed25519

4. The Config File — The Single Best SSH Productivity Boost

If you take only one habit from this post, take this one. ~/.ssh/config lets you give hosts short names, default users, ports, keys, and dozens of other options.

Host bastion
    HostName bastion.prod.example.com
    User alice
    Port 2849
    IdentityFile ~/.ssh/id_work
    IdentitiesOnly yes

Host db-prod
    HostName 10.0.4.12
    User alice
    ProxyJump bastion
    IdentityFile ~/.ssh/id_work

Now ssh bastion and ssh db-prod Just Work. The db-prod entry even tunnels through bastion automatically via ProxyJump.

A few directives I rely on heavily:

DirectiveWhat it does
IdentitiesOnly yesOnly offer the listed key. Without this, the agent throws every loaded key at the server and you may hit MaxAuthTries before the right one is tried.
ServerAliveInterval 60Send a keepalive every 60s so flaky NATs don’t kill idle sessions.
ControlMaster auto + ControlPath + ControlPersist 10mMultiplex connections — second ssh to the same host is instant.
ProxyJump bastionTransparent jump host (replaces the older, uglier ProxyCommand for this case).

Connection multiplexing in full:

Host *
    ControlMaster auto
    ControlPath ~/.ssh/cm-%r@%h:%p
    ControlPersist 10m

The first connection takes the usual second or two. Subsequent ones — including scp, rsync, and git — open in milliseconds because they reuse the same TCP session.


5. Multiple Identities (e.g. Two GitHub Accounts)

A frequent papercut: you have a personal GitHub account and a work one, both using SSH, and Git keeps picking the wrong key.

The trick is to invent two host aliases pointing at the same real host, each pinned to its own key:

Host github.com-personal
    HostName github.com
    User git
    IdentityFile ~/.ssh/id_personal
    IdentitiesOnly yes

Host github.com-work
    HostName github.com
    User git
    IdentityFile ~/.ssh/id_work
    IdentitiesOnly yes

Then clone with the alias:

git clone git@github.com-work:company/repo.git
git clone git@github.com-personal:alice/dotfiles.git

IdentitiesOnly yes is non-negotiable here — without it both keys get tried and GitHub authenticates you as whichever key matched first, which is often the wrong account.


6. Copy Files: scp, sftp, and rsync

Quick truth: scp has been deprecated since OpenSSH 9.0. It still works, and likely will for years, but the protocol underneath has known quirks. For new scripts, prefer sftp or rsync.

# Pull a file down
scp user@server:/var/log/app.log ./
sftp user@server:/var/log/app.log ./

# Push a directory up
scp -r ./build user@server:/var/www/
rsync -avz ./build/ user@server:/var/www/build/

rsync is the one I reach for 90% of the time:

  • Resumes interrupted transfers (--partial).
  • Skips unchanged files (essential for repeated deploys).
  • Composes naturally with --delete, --exclude, and dry runs (-n).
rsync -avz --delete --exclude='.git/' ./site/ user@server:/var/www/site/

The trailing slashes matter — ./site/ means “contents of site”, ./site means “the directory itself”.


7. SSH Tunneling — Local, Remote, Dynamic

This is the feature I forget most often, so here it is laid out clearly.

Local forwarding: reach a remote service from your laptop

You want to query a database on db-prod that only listens on localhost. From the bastion, the DB is reachable at 10.0.4.12:5432:

ssh -L 15432:10.0.4.12:5432 alice@bastion

Now psql -h localhost -p 15432 on your laptop hits the remote database. The mnemonic: -L localport:targethost:targetport, where targethost is resolved from the SSH server’s perspective.

Add -N -f to push it into the background without opening an interactive shell:

ssh -N -f -L 15432:10.0.4.12:5432 alice@bastion

Remote forwarding: expose your laptop to a remote host

Less common but occasionally invaluable — for instance, exposing a local dev server to a server-side webhook tester:

ssh -R 8080:localhost:3000 alice@bastion

Now anything hitting localhost:8080 on the bastion is forwarded to port 3000 on your laptop. Requires GatewayPorts yes on the server if you want it bound to anything other than loopback.

Dynamic forwarding: a personal SOCKS proxy

ssh -D 1080 alice@bastion

Point your browser at localhost:1080 (SOCKS5) and all your traffic egresses from the bastion. Cheaper than a VPN for one-off browsing through a specific network.


8. Jump Hosts the Modern Way

The legacy way to go through a bastion was ProxyCommand with nc gymnastics. Forget it. Since OpenSSH 7.3 (2016), there’s ProxyJump:

ssh -J alice@bastion alice@db-prod

Or persistently in the config (see section 4). Multiple hops are comma-separated:

ssh -J alice@bastion1,alice@bastion2 alice@deepserver

Files transfer transparently too:

scp -J alice@bastion ./app.tar.gz alice@db-prod:/tmp/

9. Agent Forwarding (and Why You Should Be Careful)

When you SSH into server A and from there git pull from a private repo, you need credentials on server A. Agent forwarding lets server A talk to your laptop’s SSH agent:

ssh -A user@serverA

Or in config:

Host serverA
    ForwardAgent yes

The catch: anyone with root on serverA can use your forwarded agent for as long as you’re connected. They can’t extract the keys, but they can sign authentication challenges with them. Only forward to hosts you fully trust. For shared bastions, prefer ProxyJump — it never exposes your agent to the bastion at all.


10. Running One Command and Leaving

You don’t need an interactive shell to run a single remote command:

ssh user@server "uptime"
ssh user@server "tail -f /var/log/syslog"
ssh user@server "sudo systemctl restart nginx"

Combine with local pipes for surprisingly powerful one-liners:

# Stream a remote log into local grep
ssh server "tail -f /var/log/app.log" | grep ERROR

# Back up a remote database to a local file
ssh dbserver "pg_dump mydb | gzip" > backup-$(date +%F).sql.gz

That last pattern — compressing on the remote, streaming over the SSH pipe, writing locally — is one of the most underused tricks in the operations toolkit.


11. Killing a Hung SSH Session

Your network drops, your terminal freezes, Ctrl+C does nothing. The escape sequence is <Enter> then ~. (tilde, dot):

[hit Enter]
~.

The session terminates immediately. Other useful escapes from the same family:

SequenceAction
~.Disconnect
~^ZBackground the SSH session
~#List forwarded connections
~?Show all escape sequences

The ~ only works as the first character after a newline — that’s why you press Enter first.


12. Verifying Host Keys (and Avoiding the Worst Habit)

The first time you connect to a host, SSH asks you to verify a fingerprint. Most people type “yes” without looking. That’s the entire host-key trust model, defeated by impatience.

The right workflow: get the fingerprint from a trusted side channel (the cloud console, the provisioning script’s output) and compare. To inspect a key file directly:

ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub

When a key legitimately changes — server rebuild, OS reinstall — clean it out cleanly rather than blindly accepting a new one:

ssh-keygen -R server.example.com

13. Debugging When SSH Just Won’t Connect

When something fails, increase verbosity:

ssh -v user@server      # one v: useful
ssh -vv user@server     # two: very useful
ssh -vvv user@server    # three: every byte, every algorithm negotiation

The verbose output tells you exactly which key was offered, which the server accepted or refused, what algorithms negotiated, and where the handshake gave up. Ninety percent of “I can’t SSH in” tickets resolve themselves after a single -vvv run.

On the server side, watch the log live while connecting:

sudo tail -f /var/log/auth.log    # Debian/Ubuntu
sudo tail -f /var/log/secure      # RHEL/Rocky

Closing Thoughts

SSH is one of those tools where the 20% you learn in a weekend covers 95% of daily use, and the remaining 5% is what separates a smooth incident response from a stressful one. The config file alone is worth an afternoon of investment — most of the discomfort people associate with SSH disappears the moment they stop typing usernames, ports, and key paths on the command line.

If you want to harden the server side after settling into these client workflows, my SSH hardening guide covers the nine sshd techniques I apply on every production box.

Inspired by Marco Behler’s excellent SSH guide, which I rediscover from my bookmarks roughly twice a year.