Skip to main content
RunBook Academy

AnsibleXX · SSH Architecture and ConnectivitySSH architecture and connectivity

Reaching a fleet through a bastion

Advanced⏱ ~24 minansible-coreopenssh-client

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

Not yet marked complete on this device.

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:

Read-only / Safethe four, verbatim
$ 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.

Read-only / Safethe group that lives behind the bastion
[web]
web1.example.com ansible_host=192.0.2.10

[web:vars]
ansible_ssh_common_args=-o ProxyJump=bastion@198.51.100.7
Read-only / Safethe command line Ansible built
$ 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.

ProxyJumpProxyCommand
Syntax-o ProxyJump=user@bastiona shell command string
Target host keyverified by your client, end to endverified by your client if the command is ssh -W; not if the command terminates the session
Bastion host keyverified by your clientverified by your client
Multiple hopscomma-separated listnested commands
Failure messagesname the hop that failedcome 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.

Read-only / Safetwo hops, when the estate has an inner bastion
# 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.com

The 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 unreachable on each run.
  • It is a different set each time.
  • Re-running with --limit on 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 ssh test.
  • 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:

  1. Reduce forks for 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.
  2. Raise MaxStartups on 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.
  3. 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

  1. Q1. Why is ansible_ssh_common_args the right place for a ProxyJump, rather than ssh_args?

  2. 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.

  3. 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.

  4. 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.