AnsibleXX · SSH Architecture and ConnectivitySSH architecture and connectivity
Reaching a fleet through a bastion
What you'll learn
- Distinguish ssh_common_args from ssh_extra_args, sftp_extra_args and scp_extra_args by which tools they reach
- Configure ProxyJump per inventory group without discarding the ssh_args defaults
- Explain why ProxyJump keeps end-to-end host key verification while a shell ProxyCommand can undermine it
- Recognise a bastion MaxStartups drop from its signature and size forks against it
Prerequisites
Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11
Most production fleets are not directly reachable from the machine you run Ansible on, and that is deliberate. A segment whose SSH port is open to the office network is a segment whose SSH port is open to whoever is on the office network.
So there is a bastion: one hardened, monitored, aggressively logged host that is reachable from outside, and through which everything else is reached. The security argument is sound. The operational consequence is that your entire automation run now depends on one machine, and that is a fact worth designing around rather than discovering.
The four argument settings, and which tools they reach
Ansible does not run one program. Depending on the transfer method it runs
ssh, and it may run sftp or scp. Four settings let you add options,
and they differ in which of those they reach:
$ ansible-doc -t connection ansible.builtin.ssh ssh_common_args Common extra args for all SSH CLI tools.
vars:
- name: ansible_ssh_common_args
default: ''
ssh_extra_args Extra exclusive to the SSH CLI.
vars:
- name: ansible_ssh_extra_args
default: ''
sftp_extra_args Extra exclusive to the 'sftp' CLI
vars:
- name: ansible_sftp_extra_args
default: ''
scp_extra_args Extra exclusive to the 'scp' CLI
vars:
- name: ansible_scp_extra_args
default: ''The documentation for the connection details states the behaviour that makes them usable: “Ansible always appends this setting to the default sftp, scp, and ssh.”
Appends. Not replaces. This is the entire difference between these
settings and ssh_args, and it is the reason ssh_common_args is the
correct place to put a ProxyJump while ssh_args is not.
Routing belongs in ssh_common_args, because a file transfer needs to
reach the host by the same path the command did. If you put ProxyJump in
ssh_extra_args instead, your tasks will connect and your file copies will
fail, in a way that looks like a module bug.
scp_extra_args has one common legitimate use, mentioned in the transport
lesson: -O, for an estate pinned to scp on OpenSSH 9.0 or later.
Proving that it appends
Do not take this on trust — the failure mode when a setting replaces
instead of appending is silent, and losing ControlPersist this way is one
of the most confusing performance regressions in this course.
[web]
web1.example.com ansible_host=192.0.2.10
[web:vars]
ansible_ssh_common_args=-o ProxyJump=bastion@198.51.100.7$ ansible-playbook -i inv2.ini ping.yml -vvvv | grep -m1 'SSH: EXEC'<192.0.2.10> SSH: EXEC ssh -C -o ControlMaster=auto -o ControlPersist=60s
-o KbdInteractiveAuthentication=no
-o PreferredAuthentications=gssapi-with-mic,gssapi-keyex,hostbased,publickey
-o PasswordAuthentication=no -o ConnectTimeout=10
-o ProxyJump=bastion@198.51.100.7
-o 'ControlPath="/home/ebrandi/.ansible/cp/fa537f329b"'
-o NumberOfPasswordPrompts=1 192.0.2.10 '/bin/sh -c '"'"'echo ~'"'"''-C, ControlMaster=auto and ControlPersist=60s are all still there,
with ProxyJump added among them. Compare that to what happens when
somebody writes the same thing into ssh_args: the defaults vanish,
multiplexing stops, and every task pays a full handshake — through the
bastion, which is where it hurts most.
ProxyJump versus ProxyCommand
Older material uses ProxyCommand with nc or ssh -W. ProxyJump
(-J) is the modern form and is what you should write. Both establish a
connection through an intermediate host; the differences that matter are
about verification and legibility.
ProxyJump | ProxyCommand | |
|---|---|---|
| Syntax | -o ProxyJump=user@bastion | a shell command string |
| Target host key | verified by your client, end to end | verified by your client if the command is ssh -W; not if the command terminates the session |
| Bastion host key | verified by your client | verified by your client |
| Multiple hops | comma-separated list | nested commands |
| Failure messages | name the hop that failed | come from whatever you invoked |
The verification row is the one to internalise.
With ProxyJump, the bastion forwards an encrypted stream. Your client
completes the SSH handshake with the target, so it checks the target’s
host key itself, and the bastion is a router that cannot read or alter the
traffic. You get two independent verifications: the bastion, then the
target.
That property is what makes a bastion an acceptable design rather than a mandatory trust anchor. A compromised bastion can see that you connected and to where, and it can deny service — but it cannot substitute itself for the target without failing the target’s host key check on your controller.
A hand-rolled ProxyCommand that opens its own session and runs something
on the bastion loses this. Now the bastion is an endpoint, it holds
plaintext, and you are trusting it fully. ssh -W %h:%p retains the
property; almost every other shape does not. Use ProxyJump and the
question does not arise.
# inventory/production/group_vars/pci_segment.yml
# Controller -> edge bastion -> PCI bastion -> host.
# Every hop is host-key verified independently by the controller.
ansible_ssh_common_args: >-
-o ProxyJump=svc-ansible@bastion-edge.example.com,svc-ansible@bastion-pci.example.comThe bastion is a single point of failure for the whole run
This is the part that belongs in a design review.
Two hundred hosts behind one bastion means every task, on every host, for
the entire run, traverses one machine. Its CPU, its sshd limits, its
network interface and its uptime are now properties of your automation.
That signature is worth memorising, because it is one of the most commonly misdiagnosed failures in this whole course:
- A varying number of hosts come back
unreachableon each run. - It is a different set each time.
- Re-running with
--limiton the failed hosts succeeds, because there are now few enough of them. - Nothing is wrong with any individual host, and each one passes a manual
sshtest. - The bastion is not obviously loaded, because the connections are refused before they consume anything.
The last two are what send people down the wrong path. Every host you test individually works, so it must be intermittent, so it must be the network.
Three fixes, and you should probably do the first and third:
- Reduce
forksfor the groups behind that bastion, to something the bastion can accept. This costs wall-clock time and requires no change to a shared piece of infrastructure. - Raise
MaxStartupson the bastion, deliberately, with the understanding that the limit exists as a denial-of-service defence and you are trading some of it away. Not something to do silently. - Enable connection reuse, which is the next lesson and the biggest single lever here. With multiplexing, a run against 200 hosts opens 200 connections once, not one per task — which removes most of the contention rather than accommodating it.
Knowledge check
Knowledge check · 4 questions
Q1. Why is ansible_ssh_common_args the right place for a ProxyJump, rather than ssh_args?
Q2. A run against 200 hosts behind one bastion returns a different set of roughly 30 unreachable hosts each time. Every failed host succeeds when tested individually. Which of these fit the evidence? Select all that apply.
Q3. With ProxyJump, the controller completes the SSH handshake with the target itself, so a compromised bastion cannot substitute itself for the target without failing host key verification.
Q4. Tasks run fine against a bastioned group but file copies fail. Which configuration mistake fits?
Passing score: 75%. Answers are checked in this browser.