●moray: ssh, sftp & terminal

The ~/.ssh/config File, Explained

Stop retyping hostnames, ports and key paths. What each useful option in an SSH config does, how first-match-wins really works, and a config worth copying.

Basics · Bartłomiej Żabicki · updated 2026-07-27 · 10 min read

If you are still typing ssh -p 2222 -i ~/.ssh/prod_ed25519 deploy@web1.example.com, you are doing work a text file should be doing for you. ~/.ssh/config turns that into ssh web1, and it is read by everything that speaks SSH — scp, rsync, git, and any client that imports it.

Here is what actually goes in it, the rule that silently breaks most people's config, and a starting point worth stealing.

The shape of the file

The file is a list of Host blocks. Each block matches one or more aliases, and the indented options below it apply when that alias matches.

Host web1
    HostName web1.example.com
    User deploy
    Port 2222
    IdentityFile ~/.ssh/prod_ed25519

Now ssh web1 is enough. So is scp deploy.tar.gz web1:/tmp/ — see SFTP vs SCP for which transfer tool to reach for — and so is git clone web1:/srv/git/app.git. They all resolve the alias through the same file.

Two housekeeping details that bite people:

chmod 700 ~/.ssh
chmod 600 ~/.ssh/config

SSH refuses to use a config file that other users can write to, and it does so with an error that does not obviously point at permissions.

The rule that breaks configs: first match wins

This is the single most important thing on this page, and it is the opposite of what most people assume.

SSH does not use the most specific match. It uses the first value it finds for each option, in file order, and then stops looking for that option.

So this config does not do what it looks like it does:

# WRONG — the wildcard is on top
Host *
    User root

Host web1
    HostName web1.example.com
    User deploy          # never applied: User was already set to root

ssh web1 connects as root, because Host * matched first and set User. Flip it:

# RIGHT — specific blocks first, wildcards last
Host web1
    HostName web1.example.com
    User deploy

Host *
    ServerAliveInterval 30
    ServerAliveCountMax 3

The habit to build: specific hosts at the top, Host * at the very bottom, containing only options you genuinely want everywhere.

The options worth knowing

Identity

Host web1
    HostName web1.example.com
    User deploy
    Port 2222
    IdentityFile ~/.ssh/prod_ed25519
    IdentitiesOnly yes

IdentitiesOnly yes deserves a paragraph of its own. Without it, SSH offers every key it can find — the agent's keys, then each default filename — before the one you named. Servers commonly set MaxAuthTries 6, and each offered key counts as an attempt. With five keys loaded you can be disconnected before your correct key is ever tried, and the error you get is Permission denied (publickey), which sends people hunting in entirely the wrong place. Set it on any host where you have named an IdentityFile.

Reaching hosts you cannot reach directly

Host bastion
    HostName bastion.example.com
    User deploy

Host db
    HostName db.internal
    User deploy
    ProxyJump bastion

ProxyJump tunnels an end-to-end connection through the bastion, verifying both host keys and keeping your key on your own machine — see jump hosts and ProxyJump for why that matters more than it sounds.

Tunnels, declared once

Host prod-db
    HostName bastion.example.com
    User deploy
    LocalForward 5432 localhost:5432
    LocalForward 6379 localhost:6379
    ExitOnForwardFailure yes

Note the syntax difference that catches everyone: on the command line it is -L 5432:localhost:5432 with colons; in the config it is LocalForward 5432 localhost:5432 with a space after the local port. ExitOnForwardFailure yes makes SSH refuse to connect at all if a forward could not be set up, instead of connecting and leaving you to wonder why nothing is listening. The three forwarding types are covered in port forwarding explained.

Keeping the connection alive

Host *
    ServerAliveInterval 30
    ServerAliveCountMax 3

Every 30 seconds the client asks the server whether it is still there; after three unanswered probes it gives up. This keeps NAT and firewall state from quietly dropping an idle session, and makes a genuinely dead connection fail in ninety seconds rather than hanging forever.

Reusing a connection

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

The first connection to a host does the full handshake; subsequent ones ride the same TCP connection and open instantly. If you run a lot of git push or rsync against one server, this is the single biggest speed-up available. The socket lingers for ten minutes after the last session closes.

One caveat: some servers dislike many channels on one connection, and a stale socket can wedge. ssh -O exit web1 clears it.

Wildcards and patterns

Host *.internal
    ProxyJump bastion

Host prod-*
    User deploy
    IdentityFile ~/.ssh/prod_ed25519

Host !prod-legacy prod-*
    StrictHostKeyChecking yes

* matches any run of characters, ? matches one, and a leading ! excludes. Patterns match against the alias you typed, not the resolved hostname — a common source of confusion when a pattern "does not fire".

Splitting the file up

Include ~/.ssh/config.d/*

Include pulls in other files at that point in the ordering — useful for keeping work and personal hosts separate, or for a directory that a tool manages. Remember first-match-wins still applies: an Include near the top can shadow everything below it.

A config worth copying

# ---- specific hosts -------------------------------------------------
Host bastion
    HostName bastion.example.com
    User deploy
    IdentityFile ~/.ssh/prod_ed25519
    IdentitiesOnly yes

Host web1 web2
    HostName %h.example.com
    User deploy
    Port 2222
    IdentityFile ~/.ssh/prod_ed25519
    IdentitiesOnly yes

Host db
    HostName db.internal
    User deploy
    ProxyJump bastion
    IdentityFile ~/.ssh/prod_ed25519
    IdentitiesOnly yes
    LocalForward 5432 localhost:5432
    ExitOnForwardFailure yes

Host pi
    HostName 192.168.1.42
    User bart
    IdentityFile ~/.ssh/home_ed25519
    IdentitiesOnly yes

# ---- defaults, last ------------------------------------------------
Host *
    ServerAliveInterval 30
    ServerAliveCountMax 3
    AddKeysToAgent yes
    HashKnownHosts yes

%h in HostName expands to the alias you typed, which is why web1 and web2 share one block.

Checking what SSH will actually do

Stop guessing and ask it:

# every option that will apply to this host, fully resolved
ssh -G web1

# just the ones you care about
ssh -G db | grep -E '^(user|hostname|port|identityfile|proxyjump)'

ssh -G prints the effective configuration after all matching and precedence. If a setting is not what you expect, this shows you in one line — and nine times out of ten it is a Host * block sitting too high in the file.

Taking it with you

The config is portable, which is the point. Copying it to a new laptop reproduces your whole setup, and a client that parses it — including Moray on iPhone, iPad and Mac — can import your hosts, ports, users, identity files, jump hosts and forwards in one step rather than making you retype them on a touchscreen.

If you write only one thing today, make it IdentitiesOnly yes on your named hosts, and move Host * to the bottom.

Hosts grouped into Homelab and Production, each with a colored icon, tags and a username
The same config after import — hosts, ports, users and jump hosts as entries rather than a file you retype on a touchscreen.

Written by Bartłomiej Żabicki

iOS developer writing Swift since 2015, with a homelab he administers from an iPhone more often than he would like to admit. Moray is the SSH client he wanted to exist.

Frequently asked

Where does the SSH config file live?
Per-user settings go in ~/.ssh/config, which should be mode 600. System-wide defaults live in /etc/ssh/ssh_config. The per-user file wins for anything it sets, and both are read for every outgoing connection.
Why is my Host * block being ignored?
It almost certainly is not — the opposite is happening. SSH uses the first value it finds for each option, so a Host * block placed at the top of the file wins over every specific block below it. Wildcards belong at the bottom.
What does IdentitiesOnly yes do?
It stops SSH offering every key it knows about and limits it to the IdentityFile entries you named. Without it, a server with MaxAuthTries 3 can reject you before your correct key is ever tried, which surfaces as a confusing permission denied.

try it yourself

Moray does all of this natively

A real terminal, dual-pane SFTP, tunnels and jump hosts on iPhone, iPad and Mac. Keys stay in the Keychain, hosts sync over your own iCloud, and there is no account to make.