Skip to content
Log in Sign up My account

Ansible for Cisco IOS XE

Ansible runs tasks against an inventory of devices from plain YAML playbooks, with no agent on the device. For Cisco IOS and IOS XE the work is done by the cisco.ios collection. This article covers the inventory and connection variables, the main module families, what idempotency really means for network modules, how to keep credentials in Vault, and two complete playbooks: a configuration backup and a VLAN push.

Install

python3 -m venv .venv
source .venv/bin/activate
pip install ansible-core
ansible-galaxy collection install cisco.ios ansible.netcommon

Pin collection versions in a requirements.yml so every engineer and the pipeline run the same code.

collections:
  - name: cisco.ios
  - name: ansible.netcommon
ansible-galaxy collection install -r requirements.yml

Network modules run on the control node, not on the device. Ansible opens an SSH session from your machine and sends CLI commands, which is why the connection type below is different from the Linux default.

Inventory and connection variables

A small project layout:

net-ansible/
  ansible.cfg
  inventory/
    hosts.yml
    group_vars/
      all/
        vars.yml
        vault.yml
      switches.yml
  playbooks/
    backup.yml
    vlans.yml
  backups/

inventory/hosts.yml:

all:
  children:
    switches:
      hosts:
        sw-core-1:
          ansible_host: 192.0.2.11
        sw-access-1:
          ansible_host: 192.0.2.21
        sw-access-2:
          ansible_host: 192.0.2.22

inventory/group_vars/switches.yml:

ansible_network_os: cisco.ios.ios
ansible_connection: ansible.netcommon.network_cli
ansible_become: true
ansible_become_method: enable

inventory/group_vars/all/vars.yml holds the non-secret values and references the vaulted ones:

ansible_user: automation
ansible_password: "{{ vault_ansible_password }}"
ansible_become_password: "{{ vault_enable_password }}"

ansible.cfg:

[defaults]
inventory = inventory/hosts.yml
host_key_checking = False
retry_files_enabled = False

Warning

host_key_checking = False is convenient in a lab. In production, distribute SSH host keys through known_hosts instead, so a man-in-the-middle cannot impersonate a switch.

Test the connection with an ad-hoc command:

ansible switches -m cisco.ios.ios_command -a "commands='show version'"

Module families

The collection has three groups of modules worth knowing.

Command and config modules

  • cisco.ios.ios_command runs show commands and returns the output. It can also wait for a condition with wait_for.
  • cisco.ios.ios_config pushes configuration lines. It compares them against the running config and sends only what is missing.
  • cisco.ios.ios_facts gathers hardware, software and interface facts into variables.

Resource modules

Resource modules manage one feature as structured data. They are the better choice for anything they cover. Examples are ios_vlans, ios_interfaces, ios_l2_interfaces, ios_l3_interfaces, ios_acls, ios_ospfv2, ios_bgp_global and ios_static_routes. They share a state parameter:

State What it does
merged Add or update the given config, leave the rest
replaced Make the listed resources match exactly, leave other resources alone
overridden Make the whole feature on the device match the given data, remove everything else
deleted Remove the listed config
gathered Read the device and return it as structured data
rendered Turn data into CLI without touching a device
parsed Turn CLI text you supply into structured data

gathered, rendered and parsed do not change the device, so they are safe for audits and for building a source of truth from what already exists.

Utility modules: ios_ping, ios_banner, ios_user, ios_logging_global, ios_ntp_global, and others. Run ansible-doc -l cisco.ios to list everything in your installed version.

Idempotency

An idempotent task changes the device only when the device differs from the desired state. Run it a hundred times and the result is the same, and runs after the first report changed=0. That makes playbooks safe to schedule and gives you drift detection for free.

Resource modules are idempotent by design because they compare structured data. ios_config is idempotent for most lines because it checks the running config, but there are traps:

  • The lines must match how the device displays them. If you write ip address 10.0.0.1 255.255.255.0 and the device shows the same, fine. Abbreviations and different ordering can cause a task to report changed every time.
  • Nested config needs parents. Without it, the module cannot tell that description x belongs under a specific interface.
  • Commands that do not appear in the running config (for example no shutdown on some platforms, or one-shot exec commands) cannot be compared, so they send every time. Do not use ios_config for exec-mode actions.

Use check mode with diff to preview a change without applying it:

ansible-playbook playbooks/vlans.yml --check --diff

Check mode support depends on the module. Resource modules and ios_config support it; ios_command does not make changes anyway.

Vault

Credentials stay out of Git in vault.yml, encrypted with ansible-vault.

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

Contents before encryption:

vault_ansible_password: "the-ssh-password"
vault_enable_password: "the-enable-secret"

Run playbooks with the vault password:

ansible-playbook playbooks/backup.yml --ask-vault-pass

For automation, point to a password file outside the repository, for example --vault-password-file ~/.vault_pass, or have your CI system supply it from its secret store. Use ansible-vault edit to change the file later and ansible-vault rekey to rotate the vault password. Commit the encrypted file; never commit the password file.

Playbook 1: back up configurations

playbooks/backup.yml:

---
- name: Back up IOS XE running configs
  hosts: switches
  gather_facts: false

  tasks:
    - name: Save running config to a timestamped file
      cisco.ios.ios_config:
        backup: true
        backup_options:
          dir_path: "{{ playbook_dir }}/../backups/{{ inventory_hostname }}"
          filename: "{{ inventory_hostname }}-{{ lookup('pipe', 'date +%Y%m%d-%H%M') }}.cfg"
      register: result

    - name: Show where the backup went
      ansible.builtin.debug:
        msg: "Saved {{ result.backup_path }}"

ios_config with backup: true and no lines only downloads the config; it changes nothing on the device. The directory is created if it does not exist. Commit the backups/ directory to Git and you have a configuration history with diffs, which is the first step toward the workflow in Git and CI/CD for network configs. Running configs can contain password hashes, SNMP communities and keys, so review what your devices show in show running-config and scrub or restrict the repository before you share it.

Playbook 2: push VLANs

Put the desired VLANs in data, not in the playbook. inventory/group_vars/switches.yml gains:

vlans:
  - vlan_id: 10
    name: USERS
  - vlan_id: 20
    name: VOICE
  - vlan_id: 30
    name: SERVERS

playbooks/vlans.yml:

---
- name: Ensure standard VLANs exist
  hosts: switches
  gather_facts: false

  tasks:
    - name: Merge VLANs
      cisco.ios.ios_vlans:
        config: "{{ vlans }}"
        state: merged
      register: vlan_result

    - name: Save config if something changed
      cisco.ios.ios_config:
        save_when: modified
      when: vlan_result.changed

    - name: Verify the VLANs are present
      cisco.ios.ios_command:
        commands:
          - show vlan brief
      register: show_vlan

    - name: Fail if an expected VLAN is missing
      ansible.builtin.assert:
        that:
          - item.name in show_vlan.stdout[0]
        fail_msg: "VLAN {{ item.vlan_id }} not found"
      loop: "{{ vlans }}"

Run it in three steps:

ansible-playbook playbooks/vlans.yml --check --diff --ask-vault-pass
ansible-playbook playbooks/vlans.yml --limit sw-access-1 --ask-vault-pass
ansible-playbook playbooks/vlans.yml --ask-vault-pass

The first previews the change. The second applies it to a single switch. The third runs against the whole group. The second run of any of them should show changed=0.

state: merged only adds. To make the switch hold exactly these VLANs and delete the rest, use state: overridden. That is a destructive choice: it will remove a VLAN somebody created by hand, so run it with --check --diff first and make sure the data covers every VLAN in use. The module is for VLANs in the VLAN database on switches that use it, so check your platform's behaviour in a lab before pointing it at production.

Tip

Use ios_vlans with state: gathered against a known-good switch and save the output. It gives you the correct data structure to start your own variable files from.

Operational notes

  • Keep forks in ansible.cfg reasonable (the default is 5). Raise it to speed up large runs, but watch your AAA servers.
  • Use serial: 5 at play level to roll out to a few devices at a time, and max_fail_percentage to stop a bad change early.
  • Tag tasks so you can run subsets, for example --tags backup.
  • Use ansible-lint in CI to catch style and syntax problems.
  • Ansible says what it changed; it does not know whether the change was wise. Always add validation tasks after the change, as the VLAN playbook does.

Ansible fits teams that want a shared, readable format for routine changes and audits. If you need loops, error handling and logic that is awkward in YAML, drop to Netmiko or NAPALM in Python, or write a custom module.

References