An ACL is a short list of lines where the one you forgot matters more than the ones you wrote. This post builds an inbound edge ACL on IOS XE from a data model with the cisco.ios.ios_acls resource module, pushes a first version that leaves out BGP on purpose, watches the branch peer go down, fixes it, and then checks the result with hit counts from real test traffic. It also covers what each module state does, including the one that deletes ACLs you did not list.
It was built in my home lab on the six IOS XE lab routers from the earlier parts. The ACL goes on hq-edge1 Ethernet0/2, inbound, which is the link from br1-edge1 (10.66.103.2) to hq-edge1 (10.66.103.1). The two run eBGP across it. The routers are managed out of band, so a bad ACL on this link does not cut off Ansible. The previous post was part 17, port security on access ports.
Versions: ansible-core 2.21, cisco.ios 11.6.

The data model
What BR1 is allowed to send into HQ, as a vars file. Each entry is one ACE with an explicit sequence number:
---
# Part 18, fixed version: what BR1 may send into HQ through hq-edge1 Et0/2
edge_acls:
hq-edge1:
- afi: ipv4
acls:
- name: BR1-IN
acl_type: extended
aces:
- sequence: 10
grant: permit
protocol: tcp
source: {host: 10.66.103.2}
destination: {host: 10.66.103.1, port_protocol: {eq: bgp}}
- sequence: 20
grant: permit
protocol: tcp
source: {host: 10.66.103.2, port_protocol: {eq: bgp}}
destination: {host: 10.66.103.1}
- sequence: 30
grant: permit
protocol: icmp
source: {host: 10.66.255.21}
destination: {address: 10.66.255.0, wildcard_bits: 0.0.0.255}
protocol_options: {icmp: {echo: true}}
- sequence: 40
grant: permit
protocol: tcp
source: {host: 10.66.255.21}
destination: {host: 10.66.255.13, port_protocol: {eq: "22"}}
- sequence: 50
grant: deny
protocol: ip
source: {any: true}
destination: {any: true}
log: {set: true}
edge_acl_bindings:
hq-edge1:
- name: Ethernet0/2
access_groups:
- afi: ipv4
acls:
- {name: BR1-IN, direction: in}
The YAML is a lot longer than the five lines it becomes, but every field is something you can test or diff. Line 50 is an explicit deny ip any any log. The implicit deny at the end of every ACL does the same filtering, but it has no counter and no log, and you want both while you are rolling this out.
The rendered state turns the model into commands without connecting to anything, which is the quickest way to check the YAML says what you meant:
ok: [hq-edge1] => {
"r.rendered": [
"ip access-list extended BR1-IN",
"10 permit tcp host 10.66.103.2 host 10.66.103.1 eq bgp",
"20 permit tcp host 10.66.103.2 eq bgp host 10.66.103.1",
"30 permit icmp host 10.66.255.21 10.66.255.0 0.0.0.255 echo",
"40 permit tcp host 10.66.255.21 host 10.66.255.13 eq 22",
"50 deny ip any any log"
]
}
The playbook
Two resource modules: ios_acls for the entries and ios_acl_interfaces for the ip access-group binding. The vars file and the state are variables, so the same playbook does the first push, the fix and the cleanup check:
---
- name: Edge ACLs from the data model
hosts: hq-edge1
gather_facts: false
vars:
acl_file: acls.yml
acl_state: merged
vars_files:
- "../vars/{{ acl_file }}"
tasks:
- name: ACL entries
cisco.ios.ios_acls:
config: "{{ edge_acls[inventory_hostname] }}"
state: "{{ acl_state }}"
register: r_acl
- name: Bind ACLs to interfaces
cisco.ios.ios_acl_interfaces:
config: "{{ edge_acl_bindings[inventory_hostname] }}"
state: merged
register: r_bind
- name: Commands sent
ansible.builtin.debug:
msg: "{{ (r_acl.commands | default([])) + (r_bind.commands | default([])) }}"
The ACL task runs before the binding task. Binding a name that does not exist yet is allowed on IOS and means permit everything until the ACL appears, which is not a state you want to rely on.
The deliberate fault
The first version of the model, acls-v1.yml, is the same file without sequences 10 and 20. It has the pings and the SSH line someone asked for and nothing for the routing protocol that was already running on the link. Before the push, the BR1 peer had been up for almost three hours:
Neighbor V AS MsgRcvd MsgSent TblVer InQ OutQ Up/Down State/PfxRcd
10.66.103.2 4 65001 198 210 19 0 0 02:56:47 1
10.66.104.2 4 65002 198 209 19 0 0 02:57:04 1
The push, at 03:20:53 UTC:
TASK [ACL entries] *************************************************************
changed: [hq-edge1]
TASK [Bind ACLs to interfaces] *************************************************
changed: [hq-edge1]
TASK [Commands sent] ***********************************************************
ok: [hq-edge1] => {
"msg": [
"ip access-list extended BR1-IN",
"30 permit icmp host 10.66.255.21 10.66.255.0 0.0.0.255 echo",
"40 permit tcp host 10.66.255.21 host 10.66.255.13 eq 22",
"50 deny ip any any log",
"interface Ethernet0/2",
"ip access-group BR1-IN in"
]
}
Green, two changes, no errors. Nothing went down right away, which is the dangerous part. Three and a half minutes later:
Neighbor V AS MsgRcvd MsgSent TblVer InQ OutQ Up/Down State/PfxRcd
10.66.103.2 4 65001 0 0 1 0 0 00:00:55 Active
10.66.104.2 4 65002 202 214 20 0 0 03:00:53 1
Extended IP access list BR1-IN
30 permit icmp host 10.66.255.21 10.66.255.0 0.0.0.255 echo
40 permit tcp host 10.66.255.21 host 10.66.255.13 eq 22
50 deny ip any any log (42 matches)
And the log, which tells the whole story:
*Nov 1 03:21:43.053: %SEC-6-IPACCESSLOGP: list BR1-IN denied tcp 10.66.103.2(179) -> 10.66.103.1(36398), 1 packet
*Nov 1 03:23:46.791: %BGP-5-NBR_RESET: Neighbor 10.66.103.2 reset (BGP Notification sent)
*Nov 1 03:23:46.792: %BGP-5-ADJCHANGE: neighbor 10.66.103.2 Down BGP Notification sent
*Nov 1 03:23:53.483: %SEC-6-IPACCESSLOGP: list BR1-IN denied tcp 10.66.103.2(179) -> 10.66.103.1(20342), 1 packet
*Nov 1 03:23:59.430: %SEC-6-IPACCESSLOGP: list BR1-IN denied tcp 10.66.103.2(37394) -> 10.66.103.1(179), 1 packet
*Nov 1 03:24:31.379: %SEC-6-IPACCESSLOGP: list BR1-IN denied tcp 10.66.103.2(179) -> 10.66.103.1(24320), 1 packet
*Nov 1 03:24:37.325: %SEC-6-IPACCESSLOGP: list BR1-IN denied tcp 10.66.103.2(47885) -> 10.66.103.1(179), 1 packet
The existing session's keepalives were dropped from the first minute. The session went down about three minutes after the push, which fits the default 180 second BGP hold time. After that, the log shows both directions failing: br1-edge1 answering from port 179 to hq-edge1's attempts, and br1-edge1 opening its own connections to port 179. That is why the fix needs two lines. A BGP session can be opened by either side, so traffic from the peer can have 179 as the destination port or as the source port.
The 42 matches on line 50 are the cost of having that line: you can see something is being dropped before you know what.

The fix with replaced
The fixed file is the model at the top of this post. It is pushed with state: replaced, which makes each ACL in the model match exactly and leaves other ACLs alone:
TASK [ACL entries] *************************************************************
changed: [hq-edge1]
TASK [Bind ACLs to interfaces] *************************************************
ok: [hq-edge1]
TASK [Commands sent] ***********************************************************
ok: [hq-edge1] => {
"msg": [
"ip access-list extended BR1-IN",
"10 permit tcp host 10.66.103.2 host 10.66.103.1 eq bgp",
"20 permit tcp host 10.66.103.2 eq bgp host 10.66.103.1"
]
}
Only the two missing lines were sent. Lines 30 to 50 already matched, so the module did not delete and re-add the ACL. The binding was already there, so that task reported ok. The peer came back within 20 seconds of the push:
10.66.103.2 4 65001 5 11 21 0 0 00:00:15 1
A second run with the same file sent nothing:
TASK [ACL entries] *************************************************************
ok: [hq-edge1]
TASK [Bind ACLs to interfaces] *************************************************
ok: [hq-edge1]
TASK [Commands sent] ***********************************************************
ok: [hq-edge1] => {
"msg": []
}
PLAY RECAP *********************************************************************
hq-edge1 : ok=3 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Verify with hit counts
Config that matches the model does not prove the ACL does what you think. So the test sends two kinds of traffic from br1-edge1 and reads the counters on hq-edge1:
---
- name: Send test traffic from BR1
hosts: br1-edge1
gather_facts: false
tasks:
- name: Ping hq-edge1 Lo0 from Lo0 (should pass) and from Et0/1 (should be dropped)
cisco.ios.ios_command:
commands:
- ping 10.66.255.13 source Loopback0 repeat 5
- ping 10.66.255.13 source Ethernet0/1 repeat 5
register: p
- ansible.builtin.debug:
msg: "{{ p.stdout_lines | map('last') | list }}"
- name: Read the counters on hq-edge1
hosts: hq-edge1
gather_facts: false
tasks:
- cisco.ios.ios_command:
commands:
- show access-lists BR1-IN
- show ip bgp summary | begin Neighbor
register: c
- ansible.builtin.debug:
msg: "{{ c.stdout_lines }}"
Before the ACL went on, both pings got 5 out of 5. I cleared the counters with clear ip access-list counters BR1-IN and ran the test:
ok: [br1-edge1] => {
"msg": [
"Success rate is 100 percent (5/5), round-trip min/avg/max = 2/2/2 ms",
"Success rate is 0 percent (0/5)"
]
}
Extended IP access list BR1-IN
10 permit tcp host 10.66.103.2 host 10.66.103.1 eq bgp
20 permit tcp host 10.66.103.2 eq bgp host 10.66.103.1 (2 matches)
30 permit icmp host 10.66.255.21 10.66.255.0 0.0.0.255 echo (10 matches)
40 permit tcp host 10.66.255.21 host 10.66.255.13 eq 22
50 deny ip any any log (5 matches)
The ping from the loopback hit line 30 and got through. The ping from the link address is not in any permit line, so it fell to line 50: five echoes, five matches, 0 percent success. Line 20 picked up the BGP keepalives from the restored session. Line 10 stayed at zero because in this session br1-edge1 is the side using port 179 as its source.
Line 30 shows 10 matches for 5 pings. I cleared the counters and sent a single ping to check, and it counted 2. On this image each echo to the router's own address is counted twice on the permit line. I don't know if physical platforms do the same, so read the counter as "this line is being used" rather than as an exact packet count, and test on your own hardware before you alert on exact numbers. Line 50's count did match the five dropped echoes.
What the states do
- merged adds the lines in the model and touches nothing else. It is safe for a first push, but it never removes a line, so an ACE you delete from the model stays on the router.
- replaced makes each ACL named in the model match exactly. Lines on the router that are missing from the model get removed. ACLs not named in the model are left alone. This is the state for normal changes.
- overridden makes the whole set of ACLs on the device match the model. Any ACL that is not in the model gets deleted.
- deleted removes the ACLs you name, or all of them if you name none.
- rendered, gathered and parsed do not change anything. They turn the model into commands, read the device into the model format, or parse saved config.
overridden is the one to be careful with. To show it, I added a small standard ACL by hand that is not in the model:
Standard IP access list OLD-MGMT
10 permit 10.66.200.0, wildcard bits 0.0.0.255
Then ran the playbook with -e acl_state=overridden --check:
TASK [Commands sent] ***********************************************************
ok: [hq-edge1] => {
"msg": [
"no ip access-list standard OLD-MGMT"
]
}
On a real router that ACL could be the access-class on the VTY lines, an SNMP community filter or a NAT rule, and overridden would delete it without asking. Check mode shows the command list without sending it, so run that first. The real run sent the same one line and left only BR1-IN on the router.
Cleaning up
The ACL came off with state: deleted, binding first:
---
- name: Remove the edge ACL, binding first
hosts: hq-edge1
gather_facts: false
tasks:
- name: Unbind from the interface
cisco.ios.ios_acl_interfaces:
config:
- name: Ethernet0/2
state: deleted
register: r_bind
- name: Delete the ACL
cisco.ios.ios_acls:
config:
- afi: ipv4
acls:
- name: BR1-IN
state: deleted
register: r_acl
- name: Commands sent
ansible.builtin.debug:
msg: "{{ r_bind.commands + r_acl.commands }}"
ok: [hq-edge1] => {
"msg": [
"interface Ethernet0/2",
"no ip access-group BR1-IN in",
"no ip access-list extended BR1-IN"
]
}
The BR1 peer stayed up through the removal.
Where to take it
- Before any ACL push on a link that carries a routing protocol, put the protocol lines first in the model and keep them there. Both ports for BGP, and OSPF or EIGRP by protocol if they run on the link.
- Run the push with
--checkand read the command list. For replaced and overridden, anynoline is something being taken away. - Keep the logged deny at the end while you roll out, and check its counter a few minutes after the change, not just right after it. This fault took three minutes to show.
Part 19 covers dry runs with check mode and diff on IOS XE, including where they miss. The rest of the series is on the series page.