This is part 10 of the Ansible for network engineers series. Up to now the series has mostly treated config as lines of text: templates, ios_config, and the parsers in part 8. This post switches to the resource modules for a small HQ campus. One YAML file describes the VLANs, the trunks, the access ports and the SVIs, and five module calls turn it into config on three switches.
Built in my home lab with ansible-core 2.21.5, cisco.ios 11.6.0, ansible.netcommon 8.7.1 and Python 3.14, against three Cisco IOS XE 17.18.2 Layer 2 lab images.
The campus

Three switches. hq-dist1 is the distribution switch and holds the SVIs. hq-acc1 and hq-acc2 are access switches, each with one uplink trunk to hq-dist1.
| Switch | Port | Role |
|---|---|---|
| hq-dist1 | Et0/1 | Trunk to hq-acc1 Et0/1 |
| hq-dist1 | Et0/2 | Trunk to hq-acc2 Et0/1 |
| hq-acc1 | Et0/2 | Desk: data VLAN 10, voice VLAN 20 |
| hq-acc1 | Et0/3 | Printer: VLAN 30 |
| hq-acc2 | Et0/2, Et0/3 | Desks: data VLAN 10, voice VLAN 20 |
VLAN 90 is switch management. hq-dist1 is 10.66.190.1, the access switches are .11 and .12. VLAN 999 is a parking VLAN used only as the trunk native VLAN, so untagged traffic on a trunk lands somewhere with nothing in it.
Why resource modules
ios_config compares lines. It can tell you a line is missing, but it can't tell you that a trunk allows VLAN 40 when it shouldn't. Resource modules parse the device into structured data, compare that with what you asked for and generate only the commands needed. The state you pick decides how strict that comparison is:
mergedadds what you list and leaves everything else alone.replacedmakes each listed interface match your data exactly and removes anything extra on it.overriddendoes the same for every interface on the box, listed or not.deletedremoves it.
I use merged for VLANs and SVIs, where adding is the normal case, and replaced for the switchport settings, because a stray allowed VLAN or a leftover access VLAN is exactly the drift you want removed. I'd stay away from overridden on access switches until every port is in the data model, or it will wipe the ones that aren't.
One data model
Everything about the campus lives in vars/campus.yml:
# vars/campus.yml
---
# One data model for the HQ campus: VLANs, trunks, access ports, SVIs
campus_vlans:
- {vlan_id: 10, name: USERS}
- {vlan_id: 20, name: VOICE}
- {vlan_id: 30, name: PRINTERS}
- {vlan_id: 90, name: SW-MGMT}
- {vlan_id: 999, name: NATIVE-PARK}
trunk_vlans: [10, 20, 30, 90]
native_vlan: 999
svis:
- {vlan: 10, ip: 10.66.110.1/24}
- {vlan: 20, ip: 10.66.120.1/24}
- {vlan: 30, ip: 10.66.130.1/24}
- {vlan: 90, ip: 10.66.190.1/24}
ports:
hq-dist1:
- {name: Ethernet0/1, mode: trunk, desc: "TRUNK: hq-acc1 Et0/1"}
- {name: Ethernet0/2, mode: trunk, desc: "TRUNK: hq-acc2 Et0/1"}
hq-acc1:
- {name: Ethernet0/1, mode: trunk, desc: "UPLINK: hq-dist1 Et0/1"}
- {name: Ethernet0/2, mode: access, vlan: 10, voice: 20, desc: "DESK: 2F-14"}
- {name: Ethernet0/3, mode: access, vlan: 30, desc: "PRINTER: 2F-copy room"}
hq-acc2:
- {name: Ethernet0/1, mode: trunk, desc: "UPLINK: hq-dist1 Et0/2"}
- {name: Ethernet0/2, mode: access, vlan: 10, voice: 20, desc: "DESK: 3F-02"}
- {name: Ethernet0/3, mode: access, vlan: 10, voice: 20, desc: "DESK: 3F-03"}
mgmt_svi:
hq-acc1: 10.66.190.11/24
hq-acc2: 10.66.190.12/24
Nothing in there is IOS syntax. It's the same facts you'd put in a port spreadsheet.
The playbook
The resource modules take a list of interfaces in one call, so the first two tasks reshape the data model into that list. One module call per resource is much faster than looping the module once per port, which I tried first and gave up on.
# playbooks/100-campus.yml
---
- name: HQ campus VLANs, trunks, access ports and SVIs
hosts: campus
gather_facts: false
vars_files:
- ../vars/campus.yml
vars:
my_ports: "{{ ports[inventory_hostname] }}"
my_svis: "{{ svis if inventory_hostname == 'hq-dist1' else [{'vlan': 90, 'ip': mgmt_svi[inventory_hostname]}] }}"
trunk_def:
encapsulation: dot1q
allowed_vlans: "{{ trunk_vlans | map('string') | list }}"
native_vlan: "{{ native_vlan }}"
tasks:
- name: Ports from the data model into module input
ansible.builtin.set_fact:
if_cfg: "{{ if_cfg | default([]) + [{'name': item.name, 'description': item.desc, 'enabled': true}] }}"
l2_cfg: >-
{{ l2_cfg | default([]) + [{'name': item.name, 'mode': item.mode}
| combine({'trunk': trunk_def} if item.mode == 'trunk' else {'access': {'vlan': item.vlan}})
| combine({'voice': {'vlan': item.voice}} if item.voice is defined else {})] }}
loop: "{{ my_ports }}"
loop_control:
label: "{{ item.name }}"
- name: SVIs from the data model into module input
ansible.builtin.set_fact:
svi_cfg: "{{ svi_cfg | default([]) + [{'name': 'Vlan' ~ item.vlan, 'enabled': true}] }}"
svi_l3: "{{ svi_l3 | default([]) + [{'name': 'Vlan' ~ item.vlan, 'ipv4': [{'address': item.ip}]}] }}"
loop: "{{ my_svis }}"
loop_control:
label: "Vlan{{ item.vlan }}"
- name: VLAN database
cisco.ios.ios_vlans:
config: "{{ campus_vlans }}"
state: merged
register: r_vlans
- name: Port descriptions, ports enabled
cisco.ios.ios_interfaces:
config: "{{ if_cfg }}"
state: merged
register: r_ports
- name: Trunk or access, per the data model
cisco.ios.ios_l2_interfaces:
config: "{{ l2_cfg }}"
state: replaced
register: r_l2
- name: SVIs exist and are up
cisco.ios.ios_interfaces:
config: "{{ svi_cfg }}"
state: merged
register: r_svi
- name: SVI addresses
cisco.ios.ios_l3_interfaces:
config: "{{ svi_l3 }}"
state: merged
register: r_l3
- name: Commands sent, or that check mode would send
ansible.builtin.debug:
msg: >-
{{ (r_vlans.commands | default([])) + (r_ports.commands | default([]))
+ (r_l2.commands | default([])) + (r_svi.commands | default([]))
+ (r_l3.commands | default([])) }}
Two details that bit me:
allowed_vlanswants a list of strings. Passing the integer list straight from the data model fails validation, hencemap('string') | list.- The VLAN interfaces and their addresses are two modules.
ios_interfacescreates the SVI and brings it up,ios_l3_interfacesputs the address on it.
The last task prints the commands each module generated. Resource modules don't show anything with --diff, but they do return a commands list, in check mode too.
Check mode first
ansible-playbook -i campus-inv/hosts.yml playbooks/100-campus.yml --check
Against the three bare switches, this is what hq-acc1 would get:
ok: [hq-acc1] => {
"msg": [
"vlan 20",
"name VOICE",
"vlan 90",
"name SW-MGMT",
"vlan 999",
"name NATIVE-PARK",
"vlan 10",
"name USERS",
"vlan 30",
"name PRINTERS",
"interface Ethernet0/1",
"description UPLINK: hq-dist1 Et0/1",
"interface Ethernet0/2",
"description DESK: 2F-14",
"interface Ethernet0/3",
"description PRINTER: 2F-copy room",
"interface Ethernet0/1",
"switchport trunk encapsulation dot1q",
"switchport mode trunk",
"switchport trunk native vlan 999",
"switchport trunk allowed vlan 10,20,30,90",
"interface Ethernet0/2",
"switchport access vlan 10",
"switchport voice vlan 20",
"switchport mode access",
"interface Ethernet0/3",
"switchport access vlan 30",
"switchport mode access",
"interface Vlan90",
"no shutdown",
"interface Vlan90",
"ip address 10.66.190.11 255.255.255.0"
]
}
hq-dist1 got the same VLANs, two trunks, four SVIs with no shutdown and the four gateway addresses. That list is what I review before anything touches a switch.
Apply, then run it again
My first apply hit a command timeout on hq-acc2 halfway through. Running the playbook again finished the job, and because the modules compare state, the switches that were already done reported nothing to change. The recap from that second run:
hq-acc1 : ok=8 changed=0 unreachable=0 failed=0
hq-acc2 : ok=8 changed=3 unreachable=0 failed=0
hq-dist1 : ok=9 changed=1 unreachable=0 failed=0
hq-dist1 shows changed=1 here for a reason worth knowing about. My first version had an ios_config task pushing ip routing so the SVIs would route. On this image ip routing is on by default and doesn't appear in the running config, so ios_config never sees the line and sends it on every run. A playbook that always reports a change is useless for spotting drift, so I removed the task and checked routing with show ip route connected instead. If your platform really has routing off by default, keep the task and check show ip protocols first.
The real test is the next run, with nothing changed in the data:
PLAY RECAP *********************************************************************
hq-acc1 : ok=8 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
hq-acc2 : ok=8 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
hq-dist1 : ok=8 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
changed=0 on all three. That run took 2 minutes 40 seconds for three switches, mostly the modules reading the config back.
Connection settings that made it reliable
My lab host was busy while I wrote this, and the default timeouts weren't enough. These are in group_vars/all/connection.yml:
ansible_connection: ansible.netcommon.network_cli
ansible_network_os: cisco.ios.ios
ansible_user: netauto
ansible_become: true
ansible_become_method: enable
ansible_command_timeout: 120
ansible_ssh_timeout: 60
ansible_connect_timeout: 120
ansible_network_cli_ssh_type: paramiko
The 120 second command timeout is there because under load a full running config took about a minute to come back. The resource modules read config on every call, so that matters more here than with ios_command.
The paramiko line fixed a failure I couldn't fix with timeouts. With the default libssh transport, the fourth module call in the play kept failing with command timeout triggered, timeout value is 120 secs on the same task, run after run, while the same module run on its own finished in about 5 seconds. Switching ansible_network_cli_ssh_type to paramiko gave a clean run first time. If you see a task time out reliably at the same place mid play, try the other transport before you raise the timeout again.
Prove it works
A second playbook reads the result back:
# playbooks/101-campus-verify.yml
---
- name: Prove the campus works
hosts: campus
gather_facts: false
tasks:
- name: VLANs and trunks on every switch
cisco.ios.ios_command:
commands:
- show vlan brief
- show interfaces trunk
register: l2
- name: Print them
ansible.builtin.debug:
var: l2.stdout_lines
- name: SVIs and routes on the distribution switch, ping both access switches
cisco.ios.ios_command:
commands:
- show ip interface brief | include Vlan
- show ip route connected | include Vlan
- ping 10.66.190.11 repeat 5
- ping 10.66.190.12 repeat 5
register: l3
when: inventory_hostname == 'hq-dist1'
- name: Print them
ansible.builtin.debug:
var: l3.stdout_lines
when: inventory_hostname == 'hq-dist1'
VLANs and the trunk on hq-acc1. The desk port is in 10 and 20, the printer in 30, and the trunk carries only the four VLANs in the data model with 999 native:
VLAN Name Status Ports
---- -------------------------------- --------- -------------------------------
1 default active
10 USERS active Et0/2
20 VOICE active Et0/2
30 PRINTERS active Et0/3
90 SW-MGMT active
999 NATIVE-PARK active
Port Mode Encapsulation Status Native vlan
Et0/1 on 802.1q trunking 999
Port Vlans allowed on trunk
Et0/1 10,20,30,90
Port Vlans in spanning tree forwarding state and not pruned
Et0/1 10,20,30,90
Both trunks on hq-dist1:
Port Mode Encapsulation Status Native vlan
Et0/1 on 802.1q trunking 999
Et0/2 on 802.1q trunking 999
Port Vlans allowed on trunk
Et0/1 10,20,30,90
Et0/2 10,20,30,90
The SVIs on hq-dist1, their connected routes, and a ping to each access switch across the management VLAN:
Vlan10 10.66.110.1 YES manual up up
Vlan20 10.66.120.1 YES manual up up
Vlan30 10.66.130.1 YES manual up up
Vlan90 10.66.190.1 YES manual up up
C 10.66.110.0/24 is directly connected, Vlan10
C 10.66.120.0/24 is directly connected, Vlan20
C 10.66.130.0/24 is directly connected, Vlan30
C 10.66.190.0/24 is directly connected, Vlan90
Sending 5, 100-byte ICMP Echos to 10.66.190.11, timeout is 2 seconds:
.!!!!
Success rate is 80 percent (4/5), round-trip min/avg/max = 1/1/2 ms
Sending 5, 100-byte ICMP Echos to 10.66.190.12, timeout is 2 seconds:
.!!!!
Success rate is 80 percent (4/5), round-trip min/avg/max = 1/1/2 ms
The first dot on each ping is ARP resolving, not loss. Those pings cross the trunk, so they prove the trunk, the native VLAN choice and both ends of VLAN 90 at once.
What I'd do in production
Keep the data model in Git and review the check mode command list in the pull request. Use replaced on the ports you manage and add ports to the model before you ever think about overridden. Don't put a line in a playbook that the platform hides from the running config, or you'll lose changed=0 as a signal. Rerun after every change and expect nothing to change.
Next in the series: compliance and drift checks across every device, then fixing what they find.