This is part 7 of the Ansible for network engineers series. Parts 1 to 6 were all IOS XE routers. This one adds a Cisco ASA: interface addressing, object groups, an inbound ACL, NAT and a save that only happens when something changed. A firewall is where a careless playbook hurts most, so every change below went through check mode first and every playbook was run twice to prove the second run changes nothing.
Built in my home lab with ansible-core 2.21.5, cisco.asa 7.0.0, ansible.netcommon 8.7.1 and Python 3.14, against an ASAv running 9.24(1). Behind it are two small Linux hosts: one inside on 10.10.10.10 and one outside on 203.0.113.100, so I could test the rules with real traffic.

Inventory and connection
The firewall gets its own inventory so none of the IOS playbooks from earlier parts can target it by accident.
# fw-inv/hosts.yml
all:
children:
firewalls:
hosts:
hq-fw1: {ansible_host: 10.66.10.41}
# fw-inv/group_vars/firewalls/connection.yml
ansible_connection: ansible.netcommon.network_cli
ansible_network_os: cisco.asa.asa
ansible_user: admin
ansible_password: "{{ vault_asa_password }}"
ansible_become: true
ansible_become_method: enable
ansible_become_password: "{{ vault_asa_enable_password }}"
The two vault_ values live in an encrypted fw-inv/group_vars/firewalls/vault.yml, the same pattern as the routers in part 2. In the post they're placeholders:
vault_asa_password: "<asa-admin-password>"
vault_asa_enable_password: "<asa-enable-password>"
The first connection hangs: a forced password change
My first asa_command run failed after 30 seconds with command timeout triggered. The SSH login worked, and the persistent log showed the session stuck waiting for a prompt. Logging in by hand showed why:
This is your first login. Please set a new password before proceeding.
Enter old password:
Enter new password:
Confirm new password:
ERROR: Password must contain:
ERROR: a value that has less than 3 repetitive or sequential ASCII characters.
ASA 9.24 made me change the bootstrap password on first login, and it rejected my first new password for having three sequential characters. Ansible has no prompt pattern for this, so it waits until command_timeout. Log in once by hand, set a compliant password, put it in the vault, and then run Ansible. The same thing will bite you on any freshly built ASA in an automated pipeline.
There's a second first contact error worth knowing. With host_key_checking on and libssh in use (see case 8 in part 6), libssh refused the very first run because it had never seen the ASA's host key. I added the key to known_hosts before going further.
The baseline after that:
"Cisco Adaptive Security Appliance Software Version 9.24(1) ",
"GigabitEthernet0/0 unassigned YES unset administratively down up ",
"GigabitEthernet0/1 unassigned YES unset administratively down up ",
"access-list MGMT_IN extended permit tcp any any eq ssh"
"access-group MGMT_IN in interface management"
Note MGMT_IN. It was there before Ansible touched the box, and it comes back later.
Interfaces: no shutdown is never idempotent
The first version of the interface playbook used asa_config with nameif, security-level, ip address and no shutdown. It worked, but the second run still reported changed=2:
changed: [hq-fw1] => {"changed": true, "updates": ["interface GigabitEthernet0/0", "no shutdown"]}
changed: [hq-fw1] => {"changed": true, "updates": ["interface GigabitEthernet0/1", "no shutdown"]}
asa_config compares your lines against the running config, and the ASA never prints no shutdown. An enabled interface just has no shutdown line:
interface GigabitEthernet0/0
nameif inside
security-level 100
ip address 10.10.10.1 255.255.255.0
So no shutdown is never found and always gets sent. A playbook that always reports changed is a problem on a firewall, because you stop reading the recap. I split it: addressing with asa_config, then a read of the interface, then no shutdown only where the running config actually contains shutdown.
---
- name: Data interfaces (inside and outside)
hosts: firewalls
gather_facts: false
vars:
asa_ifaces:
- {name: GigabitEthernet0/0, nameif: inside, level: 100, ip: 10.10.10.1 255.255.255.0}
- {name: GigabitEthernet0/1, nameif: outside, level: 0, ip: 203.0.113.1 255.255.255.0}
tasks:
- name: Addressing
cisco.asa.asa_config:
parents: "interface {{ item.name }}"
lines:
- "nameif {{ item.nameif }}"
- "security-level {{ item.level }}"
- "ip address {{ item.ip }}"
loop: "{{ asa_ifaces }}"
loop_control:
label: "{{ item.name }}"
- name: Read the shutdown state
cisco.asa.asa_command:
commands: "show running-config interface {{ item.name }}"
loop: "{{ asa_ifaces }}"
loop_control:
label: "{{ item.name }}"
register: ifrun
- name: No shutdown, only where the running config says shutdown
cisco.asa.asa_config:
parents: "interface {{ item.item.name }}"
lines: [no shutdown]
match: none
loop: "{{ ifrun.results }}"
loop_control:
label: "{{ item.item.name }}"
when: item.stdout[0] is search("^ shutdown$", multiline=True)
On a clean rerun the last task skips both interfaces and the recap says changed=0. To prove the other branch, I shut Gi0/1 by hand and ran it again. Only Gi0/1 changed:
skipping: [hq-fw1] => (item=GigabitEthernet0/0)
changed: [hq-fw1] => (item=GigabitEthernet0/1)
hq-fw1 : ok=3 changed=1 unreachable=0 failed=0 skipped=0
Object groups with asa_ogs
asa_ogs is a resource module, so it takes structured data instead of CLI lines and supports the same states as the IOS modules in part 3.
---
- name: Object groups
hosts: firewalls
gather_facts: false
tasks:
- name: Web servers and web ports
cisco.asa.asa_ogs:
state: merged
config:
- object_type: network
object_groups:
- name: WEB-SERVERS
description: Public web servers in inside
network_object:
host:
- 10.10.10.10
- 10.10.10.11
- object_type: service
object_groups:
- name: WEB-PORTS
protocol: tcp
port_object:
- eq: www
- eq: https
--check -v shows the commands before anything is sent:
"commands": ["object-group network WEB-SERVERS", "description Public web servers in inside", "network-object host 10.10.10.10", "network-object host 10.10.10.11", "object-group service WEB-PORTS tcp", "port-object eq www", "port-object eq https"]
The real run returned changed=1 and the rerun changed=0.
The inbound ACL with asa_acls
The ACL references both groups by name, so it has to run after the objects exist. Binding it to the interface is a plain access-group line, which asa_acls doesn't manage.
---
- name: Inbound ACL
hosts: firewalls
gather_facts: false
tasks:
- name: OUTSIDE-IN
cisco.asa.asa_acls:
state: "{{ acl_state | default('merged') }}"
config:
acls:
- name: OUTSIDE-IN
acl_type: extended
aces:
- grant: permit
line: 1
protocol: tcp
source:
any: true
destination:
object_group: WEB-SERVERS
service_object_group: WEB-PORTS
- grant: deny
line: 2
protocol_options:
ip: true
source:
any: true
destination:
any: true
log: default
- name: Bind it to outside
cisco.asa.asa_config:
lines:
- access-group OUTSIDE-IN in interface outside
Check mode:
"commands": ["access-list OUTSIDE-IN line 1 extended permit tcp any object-group WEB-SERVERS object-group WEB-PORTS", "access-list OUTSIDE-IN line 2 extended deny ip any any log default"]
"updates": ["access-group OUTSIDE-IN in interface outside"]
Apply gave changed=2, the rerun changed=0.
What overridden would have done
merged only adds. overridden makes the device match your list exactly, and my list has one ACL. In check mode:
ansible-playbook -i fw-inv playbooks/62-asa-acls.yml --check -v -e acl_state=overridden
"commands": ["no access-list MGMT_IN line 1 extended permit tcp any any eq ssh"]
That's the ACL bound to the management interface, the one that was there before I started. On this ASAv my SSH session would have survived, because traffic to the box itself is controlled by the ssh command and not by interface ACLs. On a real firewall that ACL is probably there for a reason someone else owns. Use overridden only when your playbook really holds every ACL on the box, and run it in check mode first every time.
The same goes for anything that can cut off your own session. None of these playbooks touch ssh, aaa, username, http or the management interface. If you do need to change those, have console access open first and change them in a separate play.
NAT with asa_config
cisco.asa 7.0.0 has no NAT resource module, so object NAT goes through asa_config with the object as the parent:
---
- name: NAT
hosts: firewalls
gather_facts: false
tasks:
- name: PAT for the inside subnet
cisco.asa.asa_config:
parents: object network INSIDE-NET
lines:
- subnet 10.10.10.0 255.255.255.0
- nat (inside,outside) dynamic interface
- name: Static NAT for web1
cisco.asa.asa_config:
parents: object network WEB1
lines:
- host 10.10.10.10
- nat (inside,outside) static 203.0.113.10
Check mode showed exactly those six lines. Apply returned changed=2, and the rerun changed=0. I'd expected this one to be noisy, because show run prints the object definition and its nat line in separate blocks:
object network INSIDE-NET
subnet 10.10.10.0 255.255.255.0
object network WEB1
host 10.10.10.10
...
object network INSIDE-NET
nat (inside,outside) dynamic interface
object network WEB1
nat (inside,outside) static 203.0.113.10
It wasn't. asa_config matched both lines under each object.
Test it with traffic
From the outside host, HTTP to the static NAT address worked and a TCP port the ACL doesn't allow was dropped:
wget -q -T 5 -O - http://203.0.113.10/ # returned "web1", the inside listener
nc -w 5 -z 203.0.113.10 2222 # exit code 1, dropped by line 2
The hit counters agree. The object groups expand into four entries, and only www to 10.10.10.10 was hit:
access-list OUTSIDE-IN line 1 extended permit tcp any object-group WEB-SERVERS object-group WEB-PORTS (hitcnt=1)
access-list OUTSIDE-IN line 1 extended permit tcp any host 10.10.10.10 eq www (hitcnt=1)
access-list OUTSIDE-IN line 1 extended permit tcp any host 10.10.10.10 eq https (hitcnt=0)
access-list OUTSIDE-IN line 1 extended permit tcp any host 10.10.10.11 eq www (hitcnt=0)
access-list OUTSIDE-IN line 1 extended permit tcp any host 10.10.10.11 eq https (hitcnt=0)
access-list OUTSIDE-IN line 2 extended deny ip any any log default (hitcnt=5)
NAT from inside:10.10.10.10 to outside:203.0.113.10
Save only when something changed
Nothing above is in the startup config yet. A reload would undo all of it. save_when: modified compares running and startup and runs write memory only if they differ:
---
- name: Save only if the running config differs from startup
hosts: firewalls
gather_facts: false
tasks:
- name: Save
cisco.asa.asa_config:
save_when: modified
--check changed: [hq-fw1] => {"changed": true}
run changed: [hq-fw1] => {"changed": true}
rerun ok: [hq-fw1] => {"changed": false}
Check mode reported a change but didn't save, which is why the real run still had something to do. After the save, show startup-config contained the new access-group, both access-list lines and both NAT objects. I keep the save as its own playbook run at the end, after I've looked at the verification output. If a change breaks something, a reload still gets you back to the last good config.
What I'd do on a production ASA
Put the firewall in its own inventory. Log in once by hand after a fresh build to clear the forced password change. Run every playbook with --check -v and read the commands. Rerun each one and expect changed=0; if you don't get it, find out why before you trust the recap, like the no shutdown case. Don't use overridden unless the playbook owns every ACL. Leave management access out of these playbooks. Save last, with save_when: modified, after you've tested traffic.
Next in the series: Ansible Gathered, Rendered and Parsed on Cisco IOS XE: Read State Without Changing Anything, reading device state into structured data without changing anything.