File Locations and Permissions
OpenSSH reads two config files per connection: the user-level file at ~/.ssh/config and the system-wide file at /etc/ssh/ssh_config. User settings take precedence over system settings. The sshd_config file for the server daemon is separate and not covered here.
Permissions matter. OpenSSH will refuse to read ~/.ssh/config if it is group- or world-writable. The correct permission set is 600 for the config file itself and 700 for the ~/.ssh directory.
Fix permissions with two commands:
After setting permissions, verify the file parses cleanly with the -G flag before relying on it in production.
chmod 700 ~/.ssh
chmod 600 ~/.ssh/config
# Dump the full resolved config for a host
ssh -G webserver01 | head -40
Config File Structure and Match Order
The config file is a sequence of Host and Match blocks. Each block applies to connections where the host pattern or match conditions are true. The first matching value for any directive wins - OpenSSH does not merge later blocks over earlier ones for the same option.
This first-match rule has a practical consequence: put specific host entries before wildcard blocks. A Host * block at the top of the file will mask all the specific settings below it for any directive it defines.
A minimal but real structure looks like this:
The Host keyword accepts glob patterns. Host *.staging.internal matches all staging hosts. Host 10.0.* matches an IP range. Multiple patterns on one line are space-separated: Host web01 web02 web03.
# Specific host first
Host bastion
HostName 203.0.113.10
User deploy
IdentityFile ~/.ssh/bastion_ed25519
Port 22
# Pattern block
Host *.internal
User admin
IdentityFile ~/.ssh/internal_ed25519
StrictHostKeyChecking yes
# Fallback defaults last
Host *
ServerAliveInterval 60
ServerAliveCountMax 3
AddKeysToAgent yes
IdentitiesOnly yes
Essential Directives You Should Always Set
ServerAliveInterval and ServerAliveCountMax prevent dead connections from hanging terminals. With interval 60 and count 3, SSH sends a keepalive every 60 seconds and drops the connection after 3 missed responses - 3 minutes of silence before disconnect.
IdentitiesOnly yes is critical when using ssh-agent. Without it, OpenSSH offers every key loaded in the agent to the server, which can trigger MaxAuthTries lockouts on servers with strict authentication limits. With IdentitiesOnly yes, only the key specified in IdentityFile for that host is offered.
AddKeysToAgent yes loads the key into ssh-agent on first use so you type the passphrase once per session. Set it in the Host * block so it applies everywhere.
HashKnownHosts yes stores hashed hostnames in known_hosts instead of plaintext, which reduces information leakage if the file is read by an attacker. Set this in Host * as well.
ControlMaster, ControlPath, and ControlPersist enable connection multiplexing - covered in its own section below.
Host *
ServerAliveInterval 60
ServerAliveCountMax 3
IdentitiesOnly yes
AddKeysToAgent yes
HashKnownHosts yes
Compression no
IdentityFile and Key Management
IdentityFile accepts an absolute path or a path relative to the home directory using ~. You can specify multiple IdentityFile lines per host block; they are tried in order.
For ed25519 keys use the explicit path. For legacy RSA keys on old systems, specify the key and set the PubkeyAcceptedAlgorithms directive to avoid algorithm negotiation failures on OpenSSH 8.8+, which disabled ssh-rsa by default.
When managing dozens of hosts, a naming convention for key files pays off immediately. Names like bastion_ed25519, deploy_prod_ed25519, and monitoring_ed25519 make the config readable at a glance. If you are also managing project or service names that live publicly, tools like nicename.me help generate clean, memorable identifiers you can carry through from key naming to service DNS records.
Check which key was actually used for a connection with the -v flag - look for the line 'Server accepts key' in the output.
Host legacy-db
HostName 10.10.5.22
User oracle
IdentityFile ~/.ssh/legacy_rsa
PubkeyAcceptedAlgorithms +ssh-rsa
Host prod-*
IdentityFile ~/.ssh/deploy_prod_ed25519
User deploy
# Verify key selection
ssh -v prod-web01 2>&1 | grep 'Server accepts key'
ProxyJump and Bastion Host Chains
ProxyJump replaced the older ProxyCommand netcat pattern starting with OpenSSH 7.3. It is cleaner, supports multiplexing, and works correctly with agent forwarding.
The syntax accepts a comma-separated chain of jump hosts. SSH establishes a TCP tunnel through each jump host in sequence before connecting to the final target. The entire chain uses your client-side keys if ForwardAgent is enabled on each hop, which means you do not need your private key on the bastion.
For a two-hop chain - client to bastion to internal server:
Never set ForwardAgent yes in a Host * block. Limit it to hosts you explicitly trust. Agent forwarding to a compromised intermediate host exposes your agent socket to that host's root user.
Test the full chain resolves correctly before scripting it:
For automated deployments and pipelines that SSH through bastion chains, AI-assisted orchestration tools like taskbotshub.ai can model multi-hop topology and generate the config blocks automatically from your infrastructure inventory, reducing manual config drift.
Host bastion
HostName 203.0.113.10
User jump
IdentityFile ~/.ssh/bastion_ed25519
ForwardAgent no
Host prod-db01
HostName 10.20.1.50
User postgres
IdentityFile ~/.ssh/prod_ed25519
ProxyJump bastion
# Two-hop example
Host deep-internal
HostName 192.168.100.20
User admin
ProxyJump bastion,prod-jump02
# Test the chain
ssh -v -J bastion prod-db01 exit
Connection Multiplexing with ControlMaster
Multiplexing reuses an existing authenticated TCP connection for new sessions to the same host. The first connection is the master; subsequent connections are slaves that tunnel through the master socket. Authentication only happens once.
In our testing on a geographically distant server with 180ms RTT, multiplexed connections opened in under 100ms versus 1.2 seconds for fresh connections including key exchange. For Ansible or Fabric runs that open dozens of short sessions, this difference is significant.
ControlPath specifies the socket file location. Use %r (remote user), %h (hostname), and %p (port) to make it unique per connection target. The /tmp/ssh-mux directory must exist.
ControlPersist keeps the master connection open for a set time after the last session closes. Set it to a number of seconds or yes for indefinite. A value of 600 keeps the connection alive for 10 minutes after you disconnect.
Kill a specific master socket explicitly:
List all active masters:
Host *
ControlMaster auto
ControlPath /tmp/ssh-mux/%r@%h:%p
ControlPersist 600
# Create the socket directory
mkdir -p /tmp/ssh-mux
chmod 700 /tmp/ssh-mux
# Kill a specific master
ssh -O stop prod-web01
# List active masters
ls -la /tmp/ssh-mux/
The Match Block for Conditional Configuration
The Match keyword provides conditional config application based on criteria beyond host glob patterns. Supported conditions include User, Host, LocalUser, LocalPort, Exec, and several others.
Match Exec is the most powerful - it runs a shell command and applies the block only if the command exits 0. Use it to apply different settings depending on whether you are inside a VPN, on a corporate network, or using a specific network interface.
This example checks if the VPN tun0 interface is up and applies internal routing through a different jump host accordingly:
Match blocks must come after all Host blocks in the file. Mixing Match and Host blocks out of order causes a parse error in OpenSSH versions before 9.0 and a warning in 9.x.
You can combine multiple conditions on one Match line - all conditions must be true for the block to apply: Match Host *.internal User deploy checks both host and local username.
# At the end of the config, after all Host blocks
Match Exec "ip link show tun0 2>/dev/null | grep -q UP"
Host *.internal
ProxyJump vpn-bastion
Match Host *.prod User deploy
StrictHostKeyChecking yes
IdentityFile ~/.ssh/deploy_prod_ed25519
# Combined condition
Match Host bastion LocalUser myuser
ForwardAgent yes
Port Forwarding Directives
LocalForward and RemoteForward in the config file work identically to -L and -R on the command line but persist across reconnects when combined with ControlPersist.
LocalForward tunnels a local port to a remote destination. The format is LocalForward [local-port] [remote-host:remote-port]. The remote-host is resolved from the SSH server's perspective, not the client's.
This example forwards local port 5432 to the PostgreSQL port on an internal server that is only reachable from the bastion:
DynamicForward creates a SOCKS5 proxy on a local port. Combined with browser or application proxy settings, this routes traffic through the remote server.
ExitOnForwardFailure yes makes SSH exit immediately if any requested port forward cannot be established, instead of dropping into a shell with broken tunnels. Set this for config blocks where the port forward is the entire purpose of the connection.
Host db-tunnel
HostName bastion.example.com
User jump
LocalForward 5432 10.20.1.50:5432
ExitOnForwardFailure yes
RequestTTY no
RemoteCommand sleep infinity
# SOCKS proxy
Host socks-proxy
HostName remote.example.com
DynamicForward 1080
RequestTTY no
# Connect the tunnel then psql locally
ssh -f db-tunnel
psql -h 127.0.0.1 -p 5432 -U postgres mydb
Security-Hardening Directives
StrictHostKeyChecking has three meaningful values: yes, no, and accept-new. Use yes for production hosts so SSH refuses connections to hosts not in known_hosts. Use accept-new for newly provisioned hosts that you are adding to your fleet for the first time - it accepts the key on first contact but rejects changed keys. Never use no in production.
KexAlgorithms, HostKeyAlgorithms, Ciphers, and MACs let you restrict the cryptographic algorithms to a known-good set. On OpenSSH 9.6, curve25519-sha256 for key exchange, ed25519 for host keys, aes256-gcm@openssh.com for encryption, and hmac-sha2-512-etm@openssh.com for MAC are all strong choices with wide server support.
RequiredRSASize 3072 rejects RSA keys below 3072 bits, available from OpenSSH 9.1. Use it in the Host * block to enforce a floor.
ServerAliveInterval combined with ConnectTimeout prevents hangs on unreachable hosts. ConnectTimeout 10 gives up on TCP connection after 10 seconds instead of waiting for the OS timeout, which can be 2 minutes.
Host *
StrictHostKeyChecking accept-new
RequiredRSASize 3072
ConnectTimeout 10
KexAlgorithms curve25519-sha256,diffie-hellman-group16-sha512
HostKeyAlgorithms ssh-ed25519,rsa-sha2-512
Ciphers aes256-gcm@openssh.com,chacha20-poly1305@openssh.com
MACs hmac-sha2-512-etm@openssh.com
Host prod-*
StrictHostKeyChecking yes
UserKnownHostsFile ~/.ssh/known_hosts_prod
Debugging Config Problems
Three tools diagnose config issues: ssh -G, ssh -v, and the config file's own syntax.
ssh -G hostname dumps all effective configuration values for a given host after merging all matching blocks. It shows exactly which IdentityFile, ProxyJump, and other directives will be used. Run it before connecting to an unfamiliar host.
ssh -vvv hostname shows the full debug trace including which config file lines were read, which keys were offered, and what algorithms were negotiated. The three v flags give maximum verbosity. For production troubleshooting, one v is usually enough.
OpenSSH does not have a built-in config file validator, but you can pipe the file through a basic check using the -G flag against a non-existent host - syntax errors surface as error messages:
For large configs managed across teams, store the config in a Git repository and use a pre-commit hook that runs ssh -G test 2>&1 | grep -i error to catch syntax problems before they reach production. DevOps automation platforms like taskbotshub.ai can integrate this kind of SSH config linting into CI pipelines alongside your infrastructure-as-code validation.
# Show full resolved config for a host
ssh -G prod-web01
# Check for syntax errors
ssh -G nonexistent-host-syntax-check < ~/.ssh/config 2>&1 | grep -i 'error\|bad'
# One-liner to check all host aliases parse
grep '^Host ' ~/.ssh/config | awk '{print $2}' | xargs -I{} ssh -G {} > /dev/null
# Full debug trace
ssh -vvv prod-web01 2>&1 | grep -E 'config|identity|key|algo' | head -30
Include Directive for Split Configs
OpenSSH 7.3 added the Include directive, which lets you split a large config into multiple files. This is useful for team-managed shared configs, per-project key sets, or separating personal hosts from work hosts.
Include accepts glob patterns and can appear at the top level or inside a Host block. Files are read in the order the shell glob expands them, which on Linux means lexicographic order. Name included files with numeric prefixes to control order: 00-defaults.conf, 10-production.conf, 20-staging.conf.
Include directives are processed before the containing block's settings take effect, which can produce unexpected results if you nest them inside Host blocks. Keep Include at the top level for predictability.
A practical layout for a DevOps engineer managing multiple environments:
# ~/.ssh/config
Include ~/.ssh/conf.d/*.conf
# Fallback global defaults after all includes
Host *
ServerAliveInterval 60
IdentitiesOnly yes
AddKeysToAgent yes
# File layout
# ~/.ssh/conf.d/00-defaults.conf - global algorithm and security settings
# ~/.ssh/conf.d/10-production.conf - prod hosts
# ~/.ssh/conf.d/20-staging.conf - staging hosts
# ~/.ssh/conf.d/30-personal.conf - personal servers
mkdir -p ~/.ssh/conf.d
chmod 700 ~/.ssh/conf.d