> ## 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.

# IOS XE ACLs with the Ansible ios_acls Resource Module
- URL: https://www.pinglabz.com/ansible-ios-acls-resource-module/
- Published: 2026-11-01T03:32:43.000Z
- Updated: 2026-11-01T04:41:43.000Z
- Description: An edge ACL from one data model, pushed with ios_acls. The first version forgot BGP and the branch peer went down three minutes later. The fix, the hit counts from real traffic, and what overridden would have deleted.
- Author: Jaime
- Tags: Network Automation, Cisco IOS XE, Cisco

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](https://www.pinglabz.com/ansible-port-security-ios/).

Versions: ansible-core 2.21, cisco.ios 11.6.

[![Lab diagram: br1-edge1 (AS 65001, Lo0 10.66.255.21, Et0/1 10.66.103.2) peers over eBGP with hq-edge1 (AS 65000, Et0/2 10.66.103.1, Lo0 10.66.255.13). The ACL BR1-IN filters hq-edge1 Et0/2 inbound. Its hit counts after the test: line 10 permit tcp bgp (dst 179) 0, line 20 permit tcp bgp (src 179) 2, line 30 permit icmp echo from Lo0 10, line 40 permit ssh 0, line 50 deny ip any any log 5. A ping from br1-edge1's loopback gets 5 of 5 replies; a ping from Et0/1 gets 0 of 5 and is denied by line 50.](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/11/diagram-topology-6.png)](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/11/diagram-topology-6.png?ref=pinglabz.com)

BR1-IN inbound on hq-edge1 Et0/2, with the hit counts after the ping test.

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

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

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

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

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

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

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

```

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

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

[![Lab diagram: a timeline from the hq-edge1 log, in UTC. At 03:20:53 the first version of ACL BR1-IN is pushed without the two BGP permit lines. At 03:21:43 the first BGP keepalive from 10.66.103.2 port 179 is denied and logged. At 03:23:46 the BGP neighbor 10.66.103.2 goes down. The fix pushes the ACL with state replaced, adding only lines 10 and 20 (permit tcp eq bgp in both directions), and the peer is back up within 20 seconds, showing 00:00:15 with 1 prefix received. During the fault, before the fix, the logged deny at line 50 had 42 matches.](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/11/diagram-topology-7.png)](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/11/diagram-topology-7.png?ref=pinglabz.com)

The push at 03:20:53 UTC, the first denied keepalive, the BGP drop about three minutes later, and the fix.

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

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

```text
10.66.103.2     4        65001       5      11       21    0    0 00:00:15        1

```

A second run with the same file sent nothing:

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

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

```text
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)"
    ]
}

```

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

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

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

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

```

```text
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 `--check` and read the command list. For replaced and overridden, any `no` line 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](https://www.pinglabz.com/ansible-check-mode-diff-ios/) 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](https://www.pinglabz.com/ansible-network-automation/).