My first Ansible playbook against IOS XE failed twice before it changed a single line of config. I kept both failures in this post, with the exact error and the fix for each.
This post is the first build in the PingLabz Ansible for Network Engineers series. By the end you'll have a control node with ansible-core and the Cisco collections, a small inventory, a vault for the device password, a read-only playbook that pulls facts from three IOS XE routers, and a write playbook that pushes a baseline you can run again and again without changing anything.
I built this in my home lab running Cisco IOS XE 17.18.2, ansible-core 2.21.5, cisco.ios 11.6.0 and ansible.netcommon 8.7.1 on Python 3.14. Every output below is from real runs. The passwords shown are placeholders.
The lab
Three routers at the HQ site, all reachable on an out-of-band management subnet, 10.66.10.0/24. The next post in the series adds two branch sites and three more devices to the same lab.
| Device | Role | Management IP |
|---|---|---|
| ansible control node | Linux, Python 3.14, ansible-core 2.21.5 | 10.66.10.1 |
| hq-core1 | HQ core router, IOS XE 17.18.2 | 10.66.10.11 |
| hq-core2 | HQ core router, IOS XE 17.18.2 | 10.66.10.12 |
| hq-edge1 | HQ edge router, IOS XE 17.18.2 | 10.66.10.13 |

On each router there's a local account for automation. It's deliberately privilege 1, the way many shops hand out service accounts, so Ansible has to enter enable mode before it can configure anything:
username netauto privilege 1 secret <NETAUTO_PASSWORD>
enable secret <ENABLE_SECRET>
ip domain name lab.example.net
SSH has to work by hand before Ansible can use it. If ssh netauto@10.66.10.11 doesn't get you a prompt, fix that first. Ansible won't do it for you.
Step 1: Install Ansible in a virtual environment
Keep Ansible out of the system Python. A venv means you can pin versions per project and upgrade without breaking anything else on the box.
python3 -m venv ~/ansible-venv
source ~/ansible-venv/bin/activate
pip install ansible-core ansible-pylibssh
ansible-galaxy collection install cisco.ios ansible.netcommon ansible.utils
ansible-core is the engine with only the builtin modules. The Cisco modules come from collections. ansible.netcommon provides the network_cli connection plugin that turns SSH into a persistent CLI session, and ansible-pylibssh is the faster SSH library it prefers. Without it, Ansible falls back to paramiko and warns you about it on every run.
Check what you got:
$ ansible --version
ansible [core 2.21.5]
python version = 3.14.4
jinja version = 3.1.6
$ ansible-galaxy collection list | grep -E "cisco|ansible\."
ansible.netcommon 8.7.1
ansible.utils 6.1.1
cisco.ios 11.6.0
If you'll automate other platforms later, add cisco.nxos, cisco.asa and cisco.ise the same way. This series uses all of them.
Step 2: Project layout
One directory per project, with the inventory and its variables next to each other:
ansible-lab/
├── ansible.cfg
├── inventory/
│ ├── hosts.yml
│ └── group_vars/
│ └── all/
│ └── vault.yml (encrypted)
└── playbooks/
├── 01-show-version.yml
└── 02-baseline.yml
The group_vars folder sits next to the inventory file, not at the project root. Ansible looks for group_vars beside the inventory and beside the playbook. My playbooks live in playbooks/, so a group_vars folder at the root would be silently ignored, and the vault variables would come back undefined.
Step 3: The inventory, first attempt
all:
children:
hq:
hosts:
hq-core1:
ansible_host: 10.66.10.11
hq-core2:
ansible_host: 10.66.10.12
hq-edge1:
ansible_host: 10.66.10.13
vars:
ansible_connection: ansible.netcommon.network_cli
ansible_user: netauto
ansible_password: "{{ vault_ios_password }}"
The password is a reference to a vault variable, never the value. The vault file holds the real one:
ansible-vault create inventory/group_vars/all/vault.yml
# what's inside, decrypted (placeholder values)
vault_ios_password: "<NETAUTO_PASSWORD>"
vault_enable_password: "<ENABLE_SECRET>"
On disk it's just an $ANSIBLE_VAULT;1.1;AES256 header and hex. The vault password lives in a file outside the project with mode 600, so it never lands in Git.
Step 4: A read-only first playbook
The first playbook against real gear should change nothing. This one collects the minimum fact set and prints the version:
---
- name: First contact with the HQ routers
hosts: hq
gather_facts: false
tasks:
- name: Collect basic facts
cisco.ios.ios_facts:
gather_subset: min
- name: Report platform and version
ansible.builtin.debug:
msg: >-
{{ inventory_hostname }} ({{ ansible_facts.net_hostname }})
runs {{ ansible_facts.net_iostype }} {{ ansible_facts.net_version }}
gather_facts: false matters. The default fact gathering runs Python on the target, and a router has no Python for it to run. Network facts come from ios_facts, which runs show commands over the CLI.
Failure 1: "Unable to automatically determine host network os"
TASK [Collect basic facts] *****************************************************
[ERROR]: Task failed: Unable to automatically determine host network os. Please manually configure ansible_network_os value for this host
fatal: [hq-core1]: UNREACHABLE! => {"changed": false, "msg": "Task failed: Unable to automatically determine host network os. Please manually configure ansible_network_os value for this host", "unreachable": true}
fatal: [hq-core2]: UNREACHABLE! => ...
fatal: [hq-edge1]: UNREACHABLE! => ...
PLAY RECAP *********************************************************************
hq-core1 : ok=0 changed=0 unreachable=1 failed=0 skipped=0
network_cli is only a transport. It needs ansible_network_os to know which prompts, error strings and terminal settings to expect. Note that the result is UNREACHABLE, not FAILED, even though SSH was fine. When you see "unreachable" on network gear, read the message before you go chasing routes and ACLs.
Fix: add one line to the group vars.
vars:
ansible_connection: ansible.netcommon.network_cli
ansible_network_os: cisco.ios.ios
Run it again
I also added an ansible.cfg so I don't have to pass the inventory and the vault password file every time:
[defaults]
inventory = inventory/hosts.yml
vault_password_file = ~/.vault-pass-netlab
host_key_checking = True
gathering = explicit
[persistent_connection]
command_timeout = 30
connect_timeout = 30
Keep host_key_checking on. A lot of guides turn it off. On production gear, host key checking is what stops a man-in-the-middle from collecting your automation password.
$ ansible-playbook playbooks/01-show-version.yml
TASK [Collect basic facts] *****************************************************
ok: [hq-core2]
ok: [hq-core1]
ok: [hq-edge1]
TASK [Report platform and version] *********************************************
ok: [hq-core1] => {
"msg": "hq-core1 (hq-core1) runs IOS 17.18.2"
}
ok: [hq-edge1] => {
"msg": "hq-edge1 (hq-edge1) runs IOS 17.18.2"
}
ok: [hq-core2] => {
"msg": "hq-core2 (hq-core2) runs IOS 17.18.2"
}
PLAY RECAP *********************************************************************
hq-core1 : ok=2 changed=0 unreachable=0 failed=0 skipped=0
hq-core2 : ok=2 changed=0 unreachable=0 failed=0 skipped=0
hq-edge1 : ok=2 changed=0 unreachable=0 failed=0 skipped=0
Use ansible_facts.net_version, not the old top-level ansible_net_version. ansible-core 2.21 already prints a deprecation warning for the injected top-level facts, and they're scheduled to go away.
Step 5: The first change
Now a small, low-risk baseline: a login banner, an NTP server, a syslog host, millisecond log timestamps, and the HTTP server off.
---
- name: Push a small baseline to HQ
hosts: hq
gather_facts: false
tasks:
- name: Login banner
cisco.ios.ios_banner:
banner: motd
text: "Authorized access only - PingLabz HQ"
state: present
- name: NTP and logging baseline
cisco.ios.ios_config:
lines:
- ntp server 10.66.0.123
- logging host 10.66.0.51
- service timestamps log datetime msec localtime
- no ip http server
notify: Save config
handlers:
- name: Save config
cisco.ios.ios_config:
save_when: modified
The handler only saves the config when something changed, so a clean rerun doesn't write to NVRAM for nothing.
Failure 2: "operation requires privilege escalation"
TASK [Login banner] ************************************************************
[ERROR]: Task failed: Action failed: operation requires privilege escalation
fatal: [hq-edge1]: FAILED! => {"changed": false, "msg": "operation requires privilege escalation"}
fatal: [hq-core1]: FAILED! => {"changed": false, "msg": "operation requires privilege escalation"}
fatal: [hq-core2]: FAILED! => {"changed": false, "msg": "operation requires privilege escalation"}
The facts playbook worked because show commands run fine at privilege 1. Config mode doesn't. On IOS, Ansible's privilege escalation is become with the enable method:
ansible_become: true
ansible_become_method: enable
ansible_become_password: "{{ vault_enable_password }}"
If your automation account lands at privilege 15 straight from AAA, you don't need this. If it doesn't, this is the fix. The ISE TACACS+ for IOS XE lab shows how the privilege level gets assigned in the first place.
Dry run first
--check asks every module to predict what it would change without touching the device:
$ ansible-playbook playbooks/02-baseline.yml --check --diff
TASK [Login banner] ************************************************************
changed: [hq-core2]
changed: [hq-edge1]
changed: [hq-core1]
TASK [NTP and logging baseline] ************************************************
[WARNING]: To ensure idempotency and correct diff the input configuration lines should be similar to how they appear if present in the running configuration on device
changed: [hq-edge1]
changed: [hq-core1]
changed: [hq-core2]
RUNNING HANDLER [Save config] **************************************************
[WARNING]: Skipping command `copy running-config startup-config` due to check_mode. Configuration not copied to non-volatile storage
Two things to notice. --diff printed no line-level diff for ios_config. With lines, it only reports that the task would change something. Line-by-line diffs need diff_against, or the resource modules that come later in this series. And the warning is worth taking seriously: ios_config compares your lines as text against the running config, so a line that IOS rewrites (abbreviations, different spacing) will report "changed" forever.
Apply, then run it again
$ ansible-playbook playbooks/02-baseline.yml
TASK [Login banner] ************************************************************
changed: [hq-edge1]
changed: [hq-core1]
changed: [hq-core2]
TASK [NTP and logging baseline] ************************************************
changed: [hq-core1]
changed: [hq-edge1]
changed: [hq-core2]
RUNNING HANDLER [Save config] **************************************************
changed: [hq-core1]
changed: [hq-edge1]
changed: [hq-core2]
PLAY RECAP *********************************************************************
hq-core1 : ok=3 changed=3 unreachable=0 failed=0 skipped=0
The second run is the one that matters:
$ ansible-playbook playbooks/02-baseline.yml
TASK [Login banner] ************************************************************
ok: [hq-edge1]
ok: [hq-core1]
ok: [hq-core2]
TASK [NTP and logging baseline] ************************************************
ok: [hq-edge1]
ok: [hq-core2]
ok: [hq-core1]
PLAY RECAP *********************************************************************
hq-core1 : ok=2 changed=0 unreachable=0 failed=0 skipped=0
hq-core2 : ok=2 changed=0 unreachable=0 failed=0 skipped=0
hq-edge1 : ok=2 changed=0 unreachable=0 failed=0 skipped=0
changed=0 everywhere, and the save handler didn't fire. That's idempotency: you can run the playbook every night as a compliance check, and it only reports something when a router has drifted.
Verify on the device
Trust the device, not the play recap. An ad-hoc ios_command reads it back:
$ ansible hq-core1 -m cisco.ios.ios_command \
-a 'commands="show running-config | include ^ntp|^logging host|^banner|^service timestamps log"'
hq-core1 | SUCCESS => {
"changed": false,
"stdout_lines": [
[
"service timestamps log datetime msec localtime",
"logging host 10.66.0.51",
"banner motd ^C",
"ntp server 10.66.0.123"
]
]
}
Troubleshooting the first run
| Symptom | Likely cause | Fix |
|---|---|---|
| UNREACHABLE, "Unable to automatically determine host network os" | ansible_network_os not set |
Set ansible_network_os: cisco.ios.ios |
vault_ios_password is undefined |
group_vars in the wrong folder, or no vault password given |
Put group_vars next to the inventory and set vault_password_file |
| "operation requires privilege escalation" | Account isn't privilege 15 | ansible_become: true and ansible_become_method: enable |
| Host key verification failed | New or rebuilt device | Check the fingerprint, then add it to known_hosts. Don't turn checking off |
command timeout triggered |
Slow command or a large config | Raise command_timeout under [persistent_connection] |
| "ansible-pylibssh not installed, falling back to paramiko" | Optional library missing | pip install ansible-pylibssh in the venv |
| A task reports "changed" on every run | ios_config lines don't match how IOS stores them |
Copy the exact line from show running-config |
The SSH side of these failures is the same as for a human. If all the VTY lines are taken up by stale sessions, Ansible gets refused just like you would. See All VTY Lines Busy on Cisco IOS XE.
Key takeaways
- Install ansible-core and the collections in a venv. Collections, not ansible-core, provide the Cisco modules.
- Every network host needs
ansible_connection: ansible.netcommon.network_cliandansible_network_os. Without the OS you get an UNREACHABLE error that has nothing to do with reachability. gather_facts: falseandios_facts, never the default Python fact gathering.- A privilege-1 account needs
becomewithbecome_method: enable. - Passwords live in Ansible Vault and are referenced by variable. Keep host key checking on.
- Run with
--checkfirst, and judge a playbook by its second run:changed=0.
If you're deciding whether Ansible is the right tool at all, Ansible vs Puppet vs Chef (and Terraform) covers that. If you've been scripting with Python, the Netmiko tutorial does the same SSH push in code, and it's a useful comparison.
Next in the series: Ansible Inventory for Network Engineers: Groups, group_vars and host_vars by Site and Role, where this three-router lab grows to two more sites and the inventory has to describe both where a device is and what it does.