SSH reads ~/.ssh/config line by line, but without variables the file quickly becomes copy-paste hell. Here’s how Host patterns, Match exec, and substitution tokens like %h, %r, %l cut config size by orders of magnitude while covering real scenarios — from dynamic routing to agent forwarding through bastion hosts.
Templates and wildcards in SSH config
SSH supports glob-like patterns in the Host directive. The most common is Host *, but compound patterns work too.
# All hosts without explicit configuration get these defaultsHost *
ServerAliveInterval 60 ServerAliveCountMax 3 IdentitiesOnly yes
# All hosts in the example.com domainHost *.example.com
User ubuntu
Port 22# Hosts matching a pattern: web-01, web-02, web-03Host web-0?
IdentityFile ~/.ssh/id_ed25519_web
Note
SSH reads config top-down and applies the first matching Host directive. Put more specific rules above general ones.
# Correct order: specific before generalHost bastion
HostName bastion.example.com
User admin
Host *.internal
ProxyJump bastion
Host *
ServerAliveInterval 60
Patterns can be combined in a single Host line via spaces — this works as logical OR.
Host dev-* staging-*
User deploy
IdentityFile ~/.ssh/id_ed25519_deploy
Substitution variables: %h, %r, %l
SSH substitutes variables in directive values during config processing. Three core ones:
Variable
Value
Example
%h
Hostname from command line
ssh web-01 → web-01
%r
Remote username
ubuntu
%l
Local username
alex
Substitution works in most directives — ProxyCommand, IdentityFile, LocalCommand, RemoteCommand.
Host jump-*
HostName %h.internal
ProxyJump bastion.example.com
User deploy
Host backup-*
HostName %h.backup.local
User backup
# Login matches local username IdentityFile ~/.ssh/id_ed25519_%r
Variable %h substitutes what you passed after ssh. This lets you write one rule for hundreds of hosts.
# Instead of five separate blocksHost server-{01..05} HostName %h.internal.corp
User ansible
ProxyJump bastion
Tip
%h substitutes what you typed, not the resolved HostName. ssh web-01 substitutes web-01, not its IP.
Match exec and dynamic routing
The Match directive lets you apply rules based on conditions. Without exec, it checks user, host, localuser. With Match exec, you get arbitrary logic via shell command.
# Route through bastion only for internal hostsMatch host 10.* exec"echo true" ProxyJump bastion.example.com
The exec condition runs on your local machine. The command must return 0 (success) for the Match block to apply.
# Forward agent only when connecting to prodMatch exec"[ '%h' = prod-* ]" AddKeysToAgent yes
IdentityAgent SSH_AUTH_SOCK
# Different routing depending on networkMatch exec"hostname -I | grep -q 192.168.1" ProxyCommand none # local network, direct accessMatch exec"[ '%h' != prod-* ]" ProxyJump office-jump
Warning
Match exec runs through the shell. Backticks and variables expand. For complex logic, extract the check into a separate script.
Match exec"/home/alex/.ssh/route-check.sh %h" ProxyJump bastion
# route-check.sh#!/bin/bashHOST="$1"if[["$HOST"== prod-* ]];thenexit1# prod needs different logicfiexit0
Escaping the percent sign
When you need to pass a literal % character — in RemoteCommand, ProxyCommand, or LocalCommand — use %%.
# SSH into Docker container with evalHost docker-*
HostName %h
User docker
RemoteCommand docker exec -it %h /bin/sh -c "eval $(ssh-agent -s) && exec /bin/sh"
# Add timestamp in remote commandHost *
RemoteCommand echo"%%Y-%%m-%%d %%H:%%M:%%S connected to %h" >> /tmp/ssh-log
# Port forwarding via nc on remote hostHost relay
HostName relay.example.com
ProxyCommand ssh -W %h:%p user@bastion
# or classic nc ProxyCommand nc -Z %h 22
Examples for dev, staging, prod
Typical setup: one file, three environments, shared base.
# ============================================# Base settings for all hosts# ============================================Host *
ServerAliveInterval 60 ServerAliveCountMax 3 IdentitiesOnly yes
AddKeysToAgent yes
# ============================================# Environments: dynamic HostName# ============================================Host dev-*
HostName %h.dev.internal
User developer
IdentityFile ~/.ssh/id_ed25519
Host staging-*
HostName %h.staging.internal
User deploy
IdentityFile ~/.ssh/id_ed25519_deploy
# Additional jump through staging-bastion ProxyJump staging-bastion
Host prod-*
HostName %h.prod.internal
User deploy
IdentityFile ~/.ssh/id_ed25519_prod
ProxyJump prod-bastion
# Agent only, no key file on disk IdentityAgent SSH_AUTH_SOCK
# ============================================# Match: dynamic logic# ============================================# Forward agent only on prod and stagingMatch exec"[ '%h' = prod-* ] || [ '%h' = staging-* ]" ForwardAgent yes
# Disable ForwardAgent for dev (paranoid setting)Match host dev-* exec"true" ForwardAgent no
# ============================================# Substitution variables in commands# ============================================Host db-*
HostName %h.internal
User dbadmin
RemoteCommand psql -U postgres -c "SELECT now(), '%r'"# ============================================# Escaping %% in arguments# ============================================Host log-*
HostName %h
RemoteCommand echo"Connected at %%Y-%%m-%%d" >> /var/log/ssh-connections.log
After this config, ssh prod-web-03 connects to prod-web-03.prod.internal via prod-bastion with key id_ed25519_prod, while ssh dev-app-01 connects directly.
# Verify SSH sees your config correctlyssh -G dev-app-01 | grep -E "^hostname|^user|^proxyjump"# Test connection without executing remote commandssh -v prod-db-01 echo"connected"
Note
-G outputs all parameters SSH will use after parsing the config for the given host. Use it for debugging before making real connections.
Combining Host patterns, variable substitution, and Match exec transforms SSH config from a collection of copy-paste blocks into a dynamic routing system. One file covers dev/staging/prod without duplication, and %h, %r, %l eliminate manual edits when adding new hosts.