AnsibleXX · SSH Architecture and ConnectivitySSH architecture and connectivity
Keys and agents for unattended runs
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
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:
$ 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: nullOnly the last of those varies per host. That is the one to use.
# 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-ansibleThe 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:
$ 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 do | What it actually means |
|---|---|
| Remove the passphrase | The key is plaintext on disk. Anyone who reads the file has the fleet. |
| Store the passphrase in a file next to the key | The passphrase is plaintext on disk, beside the thing it protects. |
| Run a long-lived agent as the service account | Any process running as that account can use the key, indefinitely, without the passphrase. |
| Pipe the passphrase in with an expect script | Plaintext 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.
$ ansible-config dump | grep SSH_AGENTSSH_AGENT(default) = none
SSH_AGENT_EXECUTABLE(default) = ssh-agent
SSH_AGENT_KEY_LIFETIME(default) = NoneThe description of SSH_AGENT, from ansible-config list:
Manage an SSH Agent via Ansible. A configuration of
nonewill not interact with an agent,autowill start and destroy an agent viassh-agentbinary during the run, and a path to anSSH_AUTH_SOCKwill 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:
$ 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_passphraseTwo constraints, both easy to trip over:
private_keytakes the contents of the key and requiresSSH_AGENTto be something other thannone. Without an agent there is nowhere to put it.private_key_passphraseapplies toprivate_keyonly. The doc is emphatic — “This does NOT have any effect when used withprivate_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.
# 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-ansiblessh_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
Q1. Why does the ssh connection plugin describe ssh-agent as highly recommended rather than optional?
Q2. On ansible-core 2.21.3, which statements about private_key and private_key_passphrase are correct? Select all that apply.
Q3. A rotation added a new automation key fleet-wide, but eleven hosts were unreachable at the time. What is the correct next step?
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.