Skip to content

Network Automation with Ansible

Ansible is often the first tool network engineers reach for when they move from typing commands into a terminal to automating them. This article covers how Ansible configures Cisco IOS XE devices — the state key that governs every change, idempotent resource modules, secrets, and how to preview a change before it touches production.

Why Ansible fits network work

Ansible is agentless. Nothing is installed on the router or switch; the module logic runs on your control node and pushes CLI or API calls to each device over a connection plugin — network_cli (SSH CLI, the usual choice for IOS XE), netconf, or httpapi. That means you can automate a large brownfield estate without deploying software to every box first.

A playbook is a YAML file of one or more plays. Each play targets a group of hosts and lists tasks; each task calls exactly one module. Data — inventory, group_vars, secrets — is deliberately kept separate from task logic, so the same playbook can drive dev, staging, and production unchanged.

The state key decides everything

Cisco IOS XE content lives in the cisco.ios collection, whose resource modules (ios_vlans, ios_interfaces, ios_ospfv2, ios_acls, …) are declarative and idempotent. The state key tells the module how to reconcile your input against what is already on the device:

State What it does
merged Adds/updates only what you list; leaves everything else intact. The safe default.
replaced Reconciles a single resource subsection to match your input.
overridden Replaces the entire resource — silently removes anything you didn't list.
deleted Removes the specified configuration.
gathered Reads current config back as structured data (no change).
rendered Produces the CLI commands without connecting to a device.

When a task says "add X without disturbing anything else," the answer is almost always merged, never overridden. Reserve overridden for greenfield or fully-known devices where your definition is genuinely complete.

Worked example: VLANs, interfaces, OSPF, ACLs

Every example below uses state: merged, so it is additive and safe to re-run.

# vlans.yml — ensure three access VLANs exist on every device in the group
---
- name: Configure access VLANs
  hosts: ios_xe
  gather_facts: false
  tasks:
    - name: Ensure VLANs are present
      cisco.ios.ios_vlans:
        config:
          - { name: SALES, vlan_id: 10 }
          - { name: VOICE, vlan_id: 20 }
          - { name: MGMT,  vlan_id: 99 }
        state: merged
# interfaces.yml — describe an uplink, then make Gi3 an access port
---
- name: Configure interfaces
  hosts: ios_xe
  gather_facts: false
  tasks:
    - name: Set description and enable the uplink
      cisco.ios.ios_interfaces:
        config:
          - name: GigabitEthernet2
            description: Uplink to core
            enabled: true
        state: merged

    - name: Make Gi3 an access port in VLAN 10
      cisco.ios.ios_l2_interfaces:
        config:
          - name: GigabitEthernet3
            access:
              vlan: 10
        state: merged

The same pattern extends to routing and filtering — ios_ospfv2 with a processes list for OSPF, and ios_acls with an aces list for an extended ACL. Because every module is declarative, you describe the end state and let Ansible compute the delta.

For asset management, gather structured inventory instead of pushing config:

# facts.yml — collect model, serial, and version as one CSV row per host
---
- name: Collect device inventory
  hosts: ios_xe
  gather_facts: false
  tasks:
    - name: Gather IOS facts
      cisco.ios.ios_facts:
        gather_subset: all

    - name: Save inventory to a file per host
      ansible.builtin.copy:
        content: "{{ ansible_net_serialnum }},{{ ansible_net_version }},{{ ansible_net_model }}"
        dest: "inventory/{{ inventory_hostname }}.csv"
      delegate_to: localhost

Prefer resource modules over ios_config

Use ios_config only for settings that lack a dedicated resource module. It is idempotent, but you own the matching logic — and there is a classic gotcha: abbreviated commands are not idempotent. If your config lines don't match the running-config exactly (including indentation), the module reports a change on every run. Resource modules avoid this because they parse the device's structured state for you.

Secrets and safe dry runs

Never hard-code passwords. Encrypt a vars file with Ansible Vault — it is ciphertext at rest, so the encrypted file can live in Git alongside your playbooks:

ansible-vault encrypt secrets.yml
ansible-playbook site.yml --ask-vault-pass

Before touching production, preview the change. --check simulates without applying, and --diff shows the exact line-by-line delta:

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

Don't confuse these with --syntax-check (parses YAML only) or -v (verbosity). A related tip from the field: run the resource module in gathered state first to record current state, and use rendered to review the generated commands offline before you run for real.

changed=0 is the proof of idempotency

A correctly written resource-module playbook reports changed=0 on its second run: the device already matches the declared state, so nothing is done. If repeat runs keep reporting changes, the task isn't truly idempotent.

Key takeaways

  • Ansible is agentless — modules run on the control node; only CLI/API calls reach the device.
  • Playbooks are YAML; the unit of work is a task calling one module.
  • Prefer resource modules over raw ios_config — they're idempotent and return structured data.
  • The state key is decisive: merged adds, overridden removes anything not listed.
  • Keep data apart from logic (group_vars/host_vars), store secrets in Ansible Vault, and always preview with --check --diff.

Sources: Ansible Network Resource Modules docs, cisco.ios.ios_config module, How to Use Ansible Network Resource Modules.