Skip to main content
RunBook Academy

AnsibleXX · SSH Architecture and ConnectivitySSH architecture and connectivity

Keys and agents for unattended runs

Intermediate⏱ ~24 minansible-coreopenssh-client

What you'll learn

  • Scope an automation key to the environment it may reach, using group_vars rather than a global setting
  • State why the ssh connection plugin recommends ssh-agent and what it cannot do without one
  • Use ssh_agent, private_key and private_key_passphrase to run unattended without a passphrase-free key on disk
  • Judge a key rotation by what happens to the hosts that were unreachable during 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.

An automation SSH key is not a personal SSH key that happens to be used by a script. It has different properties and needs different handling, and conflating the two produces the arrangement most estates actually have: one key, on one laptop or one controller, that opens every machine in the company, with no passphrase, because a passphrase broke the cron job.

This lesson is about doing better than that without pretending the constraint that produced it does not exist. The constraint is real. An unattended run cannot type a passphrase.

Which key, for which hosts

Start with scope, because scope is the security control and everything else is mechanism.

The plugin exposes the key path as private_key_file, reachable from four directions:

Read-only / Safefour entry points, one setting
$ ansible-doc -t connection ansible.builtin.ssh
   private_key_file  Path to private key file to use for
                   authentication.
      set_via:
        cli:
        - name: private_key_file
          option: --private-key
        env:
        - name: ANSIBLE_PRIVATE_KEY_FILE
        ini:
        - key: private_key_file
          section: defaults
        vars:
        - name: ansible_private_key_file
        - name: ansible_ssh_private_key_file
      default: null

Only the last of those varies per host. That is the one to use.

Read-only / Safea key per environment, scoped in inventory
# inventory/production/group_vars/all.yml
ansible_ssh_private_key_file: ~/.ssh/automation-production
ansible_user: svc-ansible

# inventory/staging/group_vars/all.yml
ansible_ssh_private_key_file: ~/.ssh/automation-staging
ansible_user: svc-ansible

The difference between that and a single private_key_file in ansible.cfg is not tidiness. It is that the staging key is not accepted by production hosts, so a run pointed at the wrong inventory fails at the connection rather than succeeding against the wrong fleet.

That is a blast-radius control implemented in authorized_keys, and it is stronger than any control implemented in your playbook, because it does not depend on your playbook being correct. A typo in a --limit pattern, a hosts: all that inherited a merged inventory, a stale ANSIBLE_INVENTORY in a CI job — none of those can cross an environment boundary if the key cannot.

Why the plugin recommends an agent

The connection plugin’s own description says why, and it is a limitation rather than a preference:

Read-only / Saferead the first paragraph of the plugin doc
$ ansible-doc -t connection ansible.builtin.ssh
  This connection plugin allows Ansible to communicate to the target
machines through normal SSH command line.
Ansible does not expose a channel to allow communication between the
user and the SSH process to accept a password manually to decrypt an
SSH key when using this connection plugin (which is the default).
The use of 'ssh-agent' is highly recommended.

Read that carefully. It is not saying an agent is convenient. It is saying Ansible has no channel through which a passphrase prompt could be answered. The ssh process is spawned with its input wired to Ansible’s module transport, not to your terminal. There is nowhere for the prompt to appear and nowhere for your answer to go.

So with a passphrase-protected key and no agent, the run does not prompt you. It fails, and — because a failed authentication is an SSH error — it fails as UNREACHABLE, which reads exactly like a network problem and sends people to look at firewalls.

An agent resolves this by holding the decrypted key in a process, so the passphrase is entered once, by a human, and the SSH clients that follow ask the agent instead of asking you.

The unattended problem, stated honestly

An agent works beautifully for an engineer at a keyboard. Now put the same key behind a scheduler at 03:00.

There is no human to enter the passphrase. The traditional resolutions were all bad in the same direction:

What people doWhat it actually means
Remove the passphraseThe key is plaintext on disk. Anyone who reads the file has the fleet.
Store the passphrase in a file next to the keyThe passphrase is plaintext on disk, beside the thing it protects.
Run a long-lived agent as the service accountAny process running as that account can use the key, indefinitely, without the passphrase.
Pipe the passphrase in with an expect scriptPlaintext in the process table and probably in the repository.

Notice that the first and the second are the same outcome reached by different routes. This is worth naming, because the second is frequently presented as the responsible version of the first.

The third is the least bad of the four and is what most estates run. Its honest description: the protection provided by the passphrase lasts until the agent is started, and after that the key is available to anything on the controller that can reach the agent socket. That is a real reduction in exposure — the key is no longer readable as a file, and a stolen backup or a leaked repository does not contain it — but it is not what people usually think they have bought.

What 2.21 actually gives you

Three settings, added in 2.19, change the shape of this problem. They are recent enough that most material predates them.

Read-only / SafeAnsible can run its own agent
$ ansible-config dump | grep SSH_AGENT
SSH_AGENT(default) = none
SSH_AGENT_EXECUTABLE(default) = ssh-agent
SSH_AGENT_KEY_LIFETIME(default) = None

The description of SSH_AGENT, from ansible-config list:

Manage an SSH Agent via Ansible. A configuration of none will not interact with an agent, auto will start and destroy an agent via ssh-agent binary during the run, and a path to an SSH_AUTH_SOCK will allow interaction with a pre-existing agent.

auto is the interesting value: an agent whose lifetime is the run. When the run ends the agent is destroyed, and with it the decrypted key. There is no long-lived agent socket sitting on the controller between runs for something else to use.

That pairs with two more:

Read-only / Safekey material, not a key path
$ ansible-doc -t connection ansible.builtin.ssh
   private_key  Private key contents in PEM format. Requires the
              'SSH_AGENT' configuration to be enabled.
      set_via:
        env:
        - name: ANSIBLE_PRIVATE_KEY
        vars:
        - name: ansible_private_key
        - name: ansible_ssh_private_key

 private_key_passphrase  Private key passphrase, dependent on
                         'private_key'.
                         This does NOT have any effect when used
                         with 'private_key_file'.
      set_via:
        env:
        - name: ANSIBLE_PRIVATE_KEY_PASSPHRASE
        vars:
        - name: ansible_private_key_passphrase
        - name: ansible_ssh_private_key_passphrase

Two constraints, both easy to trip over:

  • private_key takes the contents of the key and requires SSH_AGENT to be something other than none. Without an agent there is nowhere to put it.
  • private_key_passphrase applies to private_key only. The doc is emphatic — “This does NOT have any effect when used with private_key_file. Setting a passphrase alongside a key path does nothing at all, silently. If your run still fails to authenticate after you set a passphrase, check which of the two options you set the key with.

Put together, an unattended run can now hold an encrypted key as a variable, decrypt it at run time, load it into an agent that exists only for the duration of the run, and never write plaintext key material to disk at any point.

Read-only / Safethe shape it takes, with the secret held by Vault
# ansible.cfg
[connection]
ssh_agent = auto
ssh_agent_key_lifetime = 300

# inventory/production/group_vars/all.yml
# Both values are vault-encrypted; see the secrets part of this course.
ansible_ssh_private_key: "{{ vault_automation_private_key }}"
ansible_ssh_private_key_passphrase: "{{ vault_automation_key_passphrase }}"
ansible_user: svc-ansible

ssh_agent_key_lifetime bounds it further — “define a lifetime, in seconds, that the key may remain in the agent” — so a run that hangs does not leave a usable key in an agent for the rest of the night.

Knowledge check

Knowledge check · 4 questions

  1. Q1. Why does the ssh connection plugin describe ssh-agent as highly recommended rather than optional?

  2. Q2. On ansible-core 2.21.3, which statements about private_key and private_key_passphrase are correct? Select all that apply.

  3. Q3. A rotation added a new automation key fleet-wide, but eleven hosts were unreachable at the time. What is the correct next step?

  4. Q4. Enabling agent forwarding so a managed node can reach onward hosts is an acceptable substitute for ProxyJump, because a forwarded agent never exposes the private key itself.

Passing score: 75%. Answers are checked in this browser.