← All TILs · ssh

Useful ~/.ssh/config patterns for IaC-provisioned hosts

ssh - 2026-09-17

Plain default SSH behavior assumes a small, stable set of hosts you connect to by hand. IaC (Terraform/OpenTofu, Ansible) breaks that assumption constantly: hosts get created and destroyed, the same private IP gets reused by a completely different VM a week later, and half the fleet sits behind a bastion. A few ~/.ssh/config options exist specifically for this.

The ephemeral-host-key problem

Two bad defaults show up in copy-pasted configs:

StrictHostKeyChecking accept-new is the middle ground built for exactly this: it auto-accepts a host key it has never seen, but still refuses a connection if a known host's key changes. New IaC-provisioned host → seamless first connection. Existing host with a suddenly different key → still stops you, per the OpenSSH ssh_config manual.

Host *.lab.internal
    StrictHostKeyChecking accept-new
    UserKnownHostsFile ~/.ssh/known_hosts.d/lab.known_hosts

The second line matters as much as the first. When a destroyed-and-recreated VM gets the same IP a different host used last month, your permanent ~/.ssh/known_hosts still has that host's key on file — accept-new won't save you there, because from ssh's point of view the key genuinely changed. Pointing UserKnownHostsFile at a separate, per-environment file means wiping that one file (or letting it get rebuilt every cycle) doesn't touch the known-hosts entries for anything that isn't ephemeral.

ProxyJump for hosts behind a bastion

Host bastion
    HostName bastion.example.com
    User ops

Host 10.0.1.*
    ProxyJump bastion
    User ops

ProxyJump makes ssh open a connection to bastion first, then tunnel the real connection to the target through it — one config block instead of a hand-rolled ssh -J on every invocation, and it also makes scp, rsync -e ssh, and Ansible's own ssh connections go through the bastion automatically, since they all read this same file.

Reusing connections: ControlMaster + ControlPersist

Host *.lab.internal
    ControlMaster auto
    ControlPath ~/.ssh/control/%r@%h:%p
    ControlPersist 10m

The first connection to a host opens a real TCP+SSH handshake and keeps it alive in the background for 10 minutes after you disconnect; every connection after that reuses that same socket instead of renegotiating SSH from scratch. Ansible cares enough about this that it's not just a tip — ansible-core's own ssh connection plugin defaults ssh_args to -C -o ControlMaster=auto -o ControlPersist=60s, baking the same reuse into every playbook run whether or not ~/.ssh/config says anything. Setting it here too gets you the identical speedup for everything else that shells out to ssh against the same fleet — a plain manual ssh, scp, rsync -e ssh, git over an ssh:// remote — and lets you push the persistence window past Ansible's 60-second default for a long series of ad hoc commands.

Include: per-environment config Terraform/Ansible can own

# top of ~/.ssh/config
Include ~/.ssh/config.d/*.conf

Host *
    ForwardAgent no

Include pulls in every matching file, expanded and processed in lexical order, with relative filenames resolved against ~/.ssh. That makes it a clean handoff point: a terraform output or an Ansible inventory-generation step writes ~/.ssh/config.d/staging.conf for one environment without ever touching the file you edit by hand — tear an environment down, delete its one generated file, done. The one rule that matters: for any option, the first match wins, so Include needs to come before a catch-all Host * block, not after it — a value already set by an earlier block is never overwritten by a later one.

IdentitiesOnly, so automation doesn't get locked out

Host *.lab.internal
    IdentityFile ~/.ssh/id_lab_ed25519
    IdentitiesOnly yes

A dev workstation's ssh-agent often ends up holding a handful of keys for different systems. Without IdentitiesOnly yes, ssh offers all of them to the server before falling back to IdentityFile, and a hardened sshd with a low MaxAuthTries can reject the connection for too many failed attempts before it ever gets to the one key that would've worked. Pinning the exact key for automation-facing hosts makes the connection deterministic instead of order-of-keys-in-agent-dependent.

Ansible layers its own SSH settings on top

Ansible's default connection plugin shells out to the system ssh binary, so everything above still applies underneath — but ansible-core's ssh connection plugin also has its own settings, each independently useful:

The four places to set an Ansible SSH setting, and which one wins

Every setting above can be set as an ansible.cfg key, an environment variable, an inventory/host variable, or (for a couple of them) a command-line flag — and which one wins if more than one is set is fixed and, in one place, genuinely counter-intuitive.

# ansible.cfg
[ssh_connection]
ssh_args = -o ControlMaster=auto -o ControlPersist=60s
control_path_dir = ~/.ansible/cp
pipelining = True
# environment variable
export ANSIBLE_SSH_ARGS="-o ControlMaster=auto -o ControlPersist=60s"
# group_vars/lab.yml — inventory variable
ansible_ssh_common_args: "-o ProxyJump=bastion"
ansible_user: ops

Per Ansible's own precedence documentation, the four categories rank, lowest to highest: configuration settings (ansible.cfg, with an environment variable outranking a same-named ansible.cfg entry) → command-line options → playbook keywords → variables (inventory, group_vars/host_vars, -e). The surprising part: an inventory variable like ansible_ssh_common_args set on a single host outranks a --ssh-common-args flag passed on the command line — the flag only overrides ansible.cfg, nothing set as a variable. Debugging "why is my --ssh-common-args being ignored on this one host" almost always ends at a group_vars/host_vars file setting the same thing.

Created 2026-09-17T13:42:53+02:00 · Edit