Ansible SSH Keys and become
Two EX294 objectives in one sequence: create and distribute SSH keys to managed nodes, and configure privilege escalation on them. Both failures are produced first - the login that is refused and the sudo that is refused - so the fixes are answers to something rather than steps in a list. A new `deploy` user is used throughout, so nothing about the existing working connection is disturbed.
Control Node and Managed Nodes Guide 5 of 45 Intermediate
- OSUbuntu 26.04 LTS
- ansible-core2.20.1
- Python3.14.4
- TimeAbout 17 min
- Reviewed23 August 2026
Written against the versions above. The `authorized_key` module manages one key at a time and is idempotent - running it twice reports `ok`, not a duplicated entry. `exclusive: true` makes it the only key for that user, which is a much bigger hammer than it looks and will lock out anything already there.
| Server Name | IP Address | OS | Roles | CPU | RAM | HDD |
|---|---|---|---|---|---|---|
| ANS-CTL01 | 192.168.0.36 | Ubuntu 26.04 LTS | Ansible Control Node | 2 Core | 3 GB | 50 GB |
| ANS-A01 | 192.168.0.37 | Ubuntu 26.04 LTS | Managed Node (group: web) | 2 Core | 3 GB | 50 GB |
| ANS-B01 | 192.168.0.38 | Ubuntu 26.04 LTS | Managed Node (group: db) | 2 Core | 3 GB | 50 GB |
Before you start
- A working Ansible connection as an existing user - this guide uses it to bootstrap a new one, which is exactly how it works in practice.
- The session creates a
deployuser on both managed nodes, adeploy_keypair on the control node, and a sudoers file.
-
A user that cannot log in yet
The
usermodule creates the account on both nodes at once:ans-a01 | CHANGED ans-b01 | CHANGEDand
idconfirms it exists. But an account is not access - there is no password set and no key authorised, so nothing can log in asdeployyet. That is the correct state for a service account and the starting point for the rest of the guide.The key pair is generated on the control node, because that is where the private half has to live:
-rw------- 1 sysadmin sysadmin ... /home/sysadmin/.ssh/deploy_key -rw-r--r-- 1 sysadmin sysadmin ... /home/sysadmin/.ssh/deploy_key.pub600and644. If the private key is more permissive than600, OpenSSH refuses to use it - a common self-inflicted failure when keys are copied around.bash Example session ansible all -m ansible.builtin.user -a "name=deploy shell=/bin/bash state=present" --become 2>&1 | grep -E "CHANGED|SUCCESS|name|home" | head -8ans-a01 | CHANGED => { "create_home": true, "home": "/home/deploy", "name": "deploy",ans-b01 | CHANGED => { "create_home": true, "home": "/home/deploy", "name": "deploy",ansible all -m command -a "id deploy" --becomeans-a01 | CHANGED | rc=0 >>uid=1001(deploy) gid=1001(deploy) groups=1001(deploy)ans-b01 | CHANGED | rc=0 >>uid=1001(deploy) gid=1001(deploy) groups=1001(deploy)ssh-keygen -t ed25519 -N "" -C "deploy@ans-ctl01" -f ~/.ssh/deploy_key <<< y 2>&1 | grep -E "public key|fingerprint" | head -2Your public key has been saved in /home/sysadmin/.ssh/deploy_key.pubThe key fingerprint is:ls -l ~/.ssh/deploy_key ~/.ssh/deploy_key.pub-rw------- 1 sysadmin sysadmin 411 Aug 23 07:12 /home/sysadmin/.ssh/deploy_key-rw-r--r-- 1 sysadmin sysadmin 98 Aug 23 07:12 /home/sysadmin/.ssh/deploy_key.pubExpected resultThe user created on both hosts and a key pair on the control node.
Success conditionYou have an account and a key that have not met yet.
-
It fails before the key is there
ans-a01 | UNREACHABLE! => { "msg": "Task failed: Failed to connect to the host via ssh: deploy@ans-a01: Permission denied (publickey,password)."UNREACHABLE!, notFAILED!- and the distinction matters.FAILEDmeans Ansible got in and the task did not work.UNREACHABLEmeans it never got in at all, so nothing was attempted. When you see it, stop debugging the task and start debugging the connection.The message is OpenSSH's own:
Permission denied (publickey,password)lists the methods the server was willing to accept. We have neither.bash Example session ansible all -m ping -u deploy --private-key ~/.ssh/deploy_key 2>&1 | grep -E "UNREACHABLE|Permission denied|msg" | head -4[ERROR]: Task failed: Failed to connect to the host via ssh: deploy@ans-a01: Permission denied (publickey,password).ans-a01 | UNREACHABLE! => { "msg": "Task failed: Failed to connect to the host via ssh: deploy@ans-a01: Permission denied (publickey,password).",[ERROR]: Task failed: Failed to connect to the host via ssh: deploy@ans-b01: Permission denied (publickey,password).Expected result
UNREACHABLE!with a permission denied from SSH itself.Success conditionYou can tell a connection failure from a task failure at a glance.
-
Distribute the key with a module
The
authorized_keymodule, reading the public key with alookup:ansible all -m ansible.builtin.authorized_key \ -a "user=deploy state=present key={{ lookup('file', '/home/sysadmin/.ssh/deploy_key.pub') }}" --becomeans-a01 | CHANGED ans-b01 | CHANGEDand now:
ans-a01 | SUCCESS => { "changed": false, "ping": "pong" }Two details worth taking away.
lookup('file', ...)runs on the control node. It reads the local file and passes the contents; the managed node never sees a path. Lookups are always local, which surprises people the first time.--becomewas required. Writing into/home/deploy/.ssh/needs privileges thesysadminaccount does not have over another user's home directory.This is also the answer to "how do I bootstrap the first key" - you use an existing working connection to install the next one.
ssh-copy-idis the manual equivalent and needs a password.bash Example session ansible all -m ansible.builtin.authorized_key -a "user=deploy state=present key={{ lookup('file', '/home/sysadmin/.ssh/deploy_key.pub') }}" --become 2>&1 | grep -E "CHANGED|SUCCESS|key_options|exclusive" | head -6ans-a01 | CHANGED => { "exclusive": false, "key_options": null,ans-b01 | CHANGED => { "exclusive": false, "key_options": null,ansible all -m ping -u deploy --private-key ~/.ssh/deploy_key 2>&1 | grep -E "UNREACHABLE|Permission denied|msg" | head -4[ERROR]: Task failed: Failed to connect to the host via ssh: deploy@ans-a01: Permission denied (publickey,password).ans-a01 | UNREACHABLE! => { "msg": "Task failed: Failed to connect to the host via ssh: deploy@ans-a01: Permission denied (publickey,password).",[ERROR]: Task failed: Failed to connect to the host via ssh: deploy@ans-b01: Permission denied (publickey,password).Expected resultThe key installed on both, and
pongas the new user.Success conditionYou can distribute a key to a fleet with one command.
-
But it still cannot become root
ans-a01 | FAILED! => { ans-b01 | FAILED! => {FAILED!this time, notUNREACHABLE!. Ansible logged in perfectly well - the key works - and thensudorefused. That is the second half of managed-node setup, and it is a completely separate problem from the first.Authentication and authorisation are configured independently, which is why the exam lists them as two objectives. A key gets you in as the user; nothing about it grants root.
bash Example session ansible all -m command -a "id -un" -u deploy --private-key ~/.ssh/deploy_key --become 2>&1 | grep -E "FAILED|password is required|rc=" | head -4ans-a01 | FAILED! => {ans-b01 | FAILED! => {Expected resultA task failure caused by sudo, not by the connection.
Success conditionYou can separate 'cannot log in' from 'cannot escalate'.
-
Configure privilege escalation
A file in
/etc/sudoers.d/, written withcopy- and note the last argument:validate='visudo -cf %s'validateruns the checker against the temporary file before it is moved into place. If the syntax is wrong the file is never installed. Editing sudoers badly can lock everybody out of root on the machine, and this option is the difference between a typo and an outage. Use it every single time you touch sudoers.ans-a01 | CHANGED => { "dest": "/etc/sudoers.d/deploy", "mode": "0440",0440is what sudo requires - it refuses to read a sudoers file that is group- or world-writable.And now:
ans-a01 | CHANGED | rc=0 >> rootdeploy ALL=(ALL) NOPASSWD: ALLA drop-in file rather than editing
/etc/sudoersdirectly, because it is removable, survives package upgrades of the sudo configuration, and can be managed per user.bash Example session ansible all -m ansible.builtin.copy -a "dest=/etc/sudoers.d/deploy content='deploy ALL=(ALL) NOPASSWD: ALL\n' owner=root group=root mode=0440 validate='visudo -cf %s'" --become 2>&1 | grep -E "CHANGED|SUCCESS|dest|mode" | head -6ans-a01 | CHANGED => { "dest": "/etc/sudoers.d/deploy", "mode": "0440",ans-b01 | CHANGED => { "dest": "/etc/sudoers.d/deploy", "mode": "0440",ansible all -m command -a "id -un" -u deploy --private-key ~/.ssh/deploy_key --becomeans-a01 | CHANGED | rc=0 >>rootans-b01 | CHANGED | rc=0 >>rootansible all -m command -a "cat /etc/sudoers.d/deploy" --becomeans-a01 | CHANGED | rc=0 >>deploy ALL=(ALL) NOPASSWD: ALLans-b01 | CHANGED | rc=0 >>deploy ALL=(ALL) NOPASSWD: ALLExpected resultA validated sudoers file, and
rootfrom the deploy user.Success conditionYou have privilege escalation configured, safely.
-
Make it the default
ans-a01 | SUCCESS => { "ping": "pong" }with
--become --become-method sudo --become-user rootspelled out in full. All three are already the defaults, so in practice--become(or-b) alone is what you type.Once this works, stop passing it on the command line. In
ansible.cfg:[defaults] remote_user = deploy private_key_file = ~/.ssh/deploy_key [privilege_escalation] become = True become_method = sudo become_user = root become_ask_pass = FalseOr per play, with
become: true, which is usually better - most plays need root for some tasks and not others, and being explicit in the playbook makes that visible to the next reader.What you have built is the standard shape of a managed fleet: one service account, one key, passwordless sudo, and nothing installed on the far side.
bash Example session ansible all -m ansible.builtin.ping -u deploy --private-key ~/.ssh/deploy_key --become --become-method sudo --become-user rootans-a01 | SUCCESS => { "changed": false, "ping": "pong"}ans-b01 | SUCCESS => { "changed": false, "ping": "pong"}Expected resultA successful ping with escalation fully specified.
Success conditionYou can move the flags into configuration and stop typing them.
Troubleshooting
UNREACHABLE!withPermission denied (publickey).Why: The key is not authorised for that user on that host, or the wrong private key is being offered.
Fix:
--private-keyto be explicit, and check/home/<user>/.ssh/authorized_keyson the target.FAILED!mentioning a sudo password.Why: Privilege escalation is not configured for that user.
Fix:A
NOPASSWDentry in/etc/sudoers.d/, or supply one with--ask-become-pass.SSH refuses your private key.
Why: Permissions are too open.
Fix:
chmod 600on the private key and700on~/.ssh.A sudoers edit locked everyone out.
Why: Invalid syntax written directly into place.
Fix:Always pass
validate='visudo -cf %s'. It is why the module has the option.authorized_keyremoved keys that were already there.Why:
exclusive: truemakes the supplied key the only one.Fix:Leave
exclusiveoff unless you genuinely mean to replace every key.