> ## Content Index
> Fetch the complete content index at: https://www.pinglabz.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Ansible Network Lab Setup: Control Node, Collections and a First Playbook on IOS XE
- URL: https://www.pinglabz.com/ansible-network-lab-setup-first-playbook-ios-xe/
- Published: 2026-10-31T18:14:22.000Z
- Updated: 2026-11-01T04:41:14.000Z
- Description: Install ansible-core and the Cisco collections in a venv, build a three-router inventory with a vault, and run a first playbook on IOS XE 17.18. Real runs, including the two failures every first attempt hits, and the idempotent second run.
- Author: Jaime
- Tags: Network Automation, Cisco IOS XE, Cisco

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](https://www.pinglabz.com/ansible-network-automation/) 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   |

[![Lab diagram: The ansible control node (10.66.10.1 · ansible-core 2.21.5 · cisco.ios 11.6.0) opens SSH · network_cli sessions to hq-core1, hq-core2 and hq-edge1 (IOS XE 17.18.2) in HQ · OOB mgmt 10.66.10.0/24. Run log: run 1 UNREACHABLE, fixed with ansible_network_os: cisco.ios.ios; run 2 FAILED on privilege escalation, fixed with become: enable; run 3 ok changed=0 on the second apply.](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/10/ansible-lab-setup-topology.png)](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/10/ansible-lab-setup-topology.png?ref=pinglabz.com)

Lab topology: the control node reaches three HQ routers over SSH with network\_cli

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:

```text
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.

```bash
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:

```text
$ 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:

```text
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

```yaml
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:

```bash
ansible-vault create inventory/group_vars/all/vault.yml

```

```yaml
# 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:

```yaml
---
- 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"

```text
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.

```yaml
  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:

```ini
[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.

```text
$ 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.

```yaml
---
- 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"

```text
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:

```yaml
    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](https://www.pinglabz.com/ise-tacacs-ios-device-admin-command-sets/) 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:

```text
$ 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

```text
$ 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:

```text
$ 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:

```text
$ 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](https://www.pinglabz.com/vty-lines-busy-ssh-exec-timeout-tcp-keepalives/).

## 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_cli` and `ansible_network_os`. Without the OS you get an UNREACHABLE error that has nothing to do with reachability.
- `gather_facts: false` and `ios_facts`, never the default Python fact gathering.
- A privilege-1 account needs `become` with `become_method: enable`.
- Passwords live in Ansible Vault and are referenced by variable. Keep host key checking on.
- Run with `--check` first, 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)](https://www.pinglabz.com/ansible-vs-puppet-vs-chef/) covers that. If you've been scripting with Python, [the Netmiko tutorial](https://www.pinglabz.com/netmiko-ssh-automation-cisco/) 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](https://www.pinglabz.com/ansible-network-inventory-site-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.