Skip to content

SSH Config: Wildcards and Dynamic Variable Substitution

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 defaults
Host *
    ServerAliveInterval 60
    ServerAliveCountMax 3
    IdentitiesOnly yes

# All hosts in the example.com domain
Host *.example.com
    User ubuntu
    Port 22

# Hosts matching a pattern: web-01, web-02, web-03
Host 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 general
Host 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:

VariableValueExample
%hHostname from command linessh web-01 → web-01
%rRemote usernameubuntu
%lLocal usernamealex

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 blocks
Host 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 hosts
Match 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 prod
Match exec "[ '%h' = prod-* ]"
    AddKeysToAgent yes
    IdentityAgent SSH_AUTH_SOCK
# Different routing depending on network
Match exec "hostname -I | grep -q 192.168.1"
    ProxyCommand none  # local network, direct access

Match 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/bash
HOST="$1"
if [[ "$HOST" == prod-* ]]; then
    exit 1  # prod needs different logic
fi
exit 0

Escaping the percent sign

When you need to pass a literal % character — in RemoteCommand, ProxyCommand, or LocalCommand — use %%.

# SSH into Docker container with eval
Host docker-*
    HostName %h
    User docker
    RemoteCommand docker exec -it %h /bin/sh -c "eval $(ssh-agent -s) && exec /bin/sh"
# Add timestamp in remote command
Host *
    RemoteCommand echo "%%Y-%%m-%%d %%H:%%M:%%S connected to %h" >> /tmp/ssh-log
# Port forwarding via nc on remote host
Host 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 staging
Match 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 correctly
ssh -G dev-app-01 | grep -E "^hostname|^user|^proxyjump"

# Test connection without executing remote command
ssh -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.