Automating security hardening and compliance: implementing CIS benchmarks with the ansible-lockdown collection, SSH and firewall hardening, patch management with automatic reboot, compliance as code (STIG, audit logging, AIDE), and integration with OpenSCAP and Lynis security scanners.

After episode 26, where we covered cloud infrastructure provisioning — creating EC2, GCP VMs, and Azure instances declaratively — in this episode we'll cover the side most often neglected after infrastructure is standing: security hardening and compliance automation.
Imagine you've just built a magnificent house. Electricity is installed, internet is fast, furniture is complete. But the doors still use cheap padlocks, the windows aren't locked, and there's no fire alarm. Anyone can get in, and when something happens, you have no evidence of what occurred. That's the picture of a server running in production without hardening: infrastructure available, but security weak and no audit trail.
The problem is, manually hardening dozens or hundreds of servers is boring work, prone to oversight, and inconsistent results — exactly the problem Ansible is meant to solve. Moreover, many industries (banking, healthcare, government, e-commerce) are now required to meet compliance standards like CIS Benchmarks, STIG, PCI-DSS, or ISO 27001. Managing this compliance "as code" — not with Excel checklists — is the only scalable way.
In this episode we'll cover implementing CIS benchmarks using the ansible-lockdown collection, SSH and firewall hardening (UFW, firewalld), patch management automation complete with reboot management, compliance as code for STIG and file integrity monitoring (AIDE), and integration of security scanners like OpenSCAP and Lynis.
Hardening is the process of shrinking a system's attack surface: closing unnecessary services, strengthening authentication, applying the least privilege principle, and locking down weak default configurations. A default Linux installation is usually "comfortable but fragile": SSH with passwords, root login allowed, many open ports, and security updates not automatic.
The key: Ansible makes hardening repeatable and verifiable. The same playbook can run against a new server in minutes, with identical results, and it's idempotent — rerunning doesn't change anything already correct. That's what distinguishes hardening as code from manual checklists.
The CIS (Center for Internet Security) Benchmark is a collection of industry-consensus hardening recommendations for various operating systems — from kernel configuration, file permissions, sshd, to audit policies. Each recommendation has a level: Level 1 (recommended basic practices) and Level 2 (defense-in-depth for highly sensitive environments).
Writing thousands of CIS controls manually is a huge job. That's where the ansible-lockdown community comes in. This collection provides ready-made hardening roles that already implement CIS Benchmarks for various operating systems:
| Collection Role | Target OS | Benchmark |
|---|---|---|
ansible-lockdown.redhatcis_rhel9 | RHEL 9 / Rocky 9 / AlmaLinux 9 | CIS RHEL 9 Benchmark |
ansible-lockdown.redhatcis_rhel8 | RHEL 8 / Rocky 8 / AlmaLinux 8 | CIS RHEL 8 Benchmark |
ansible-lockdown.ubuntu_cis | Ubuntu 22.04 / 24.04 | CIS Ubuntu Benchmark |
ansible-lockdown.windows_cis | Windows Server 2019 / 2022 | CIS Windows Server Benchmark |
ansible-lockdown.stig_rhel9 | RHEL 9 | DISA STIG for RHEL 9 |
How it works: the role registers a list of CIS rules as boolean variables, then you enable/disable rules as needed. The role reads those variables and adjusts system configuration idempotently.
Install the collections:
ansible-galaxy collection install ansible-lockdown.ubuntu_cis
ansible-galaxy collection install ansible-lockdown.redhatcis_rhel9After that, use the role in a playbook. The following example hardens Ubuntu 24.04 servers against CIS Level 2:
- name: Hardening Ubuntu dengan CIS Benchmark
hosts: ubuntu_servers
become: true
vars:
ubtu20cis_rule_6_1_2: false
ubtu20cis_auditd_enabled: true
ubtu20cis_system_audit_rules:
- "-w /etc/passwd -p wa -k identity"
- "-w /etc/shadow -p wa -k identity"
ubtu20cis_sysctl_rules:
net.ipv4.ip_forward: 0
net.ipv4.conf.all.send_redirects: 0
roles:
- ansible-lockdown.ubuntu_cisImportant
Roles from ansible-lockdown are very aggressive — some rules change permissions, disable services, or lock users. Always run with --check mode first, test in a staging environment, and make sure you understand every rule you enable. Running a full CIS role in production without testing is the fastest way to bring production down. The variable name prefixes follow the role version (e.g., ubtu20cis_* for Ubuntu 20.04/22.04), so check the role documentation for the version you use.
For a lighter, more controlled SSH hardening role, we can write our own. The following example uses the ansible.builtin.lineinfile module and an sshd_config template:
- name: Hardening konfigurasi SSH server
hosts: all
become: true
tasks:
- name: Template sshd_config yang aman
ansible.builtin.template:
src: sshd_config.j2
dest: /etc/ssh/sshd_config
owner: root
group: root
mode: "0600"
validate: "/usr/sbin/sshd -t -f %s"
notify: Restart SSH
handlers:
- name: Restart SSH
ansible.builtin.service:
name: sshd
state: restartedThe sshd_config.j2 template:
# Managed by Ansible - perubahan manual akan ditimpa
Port {{ ssh_port | default(22) }}
Protocol 2
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
MaxAuthTries 3
LoginGraceTime 30
ClientAliveInterval 300
ClientAliveCountMax 2
AllowUsers {{ ssh_allowed_users | default([]) | join(' ') }}
AllowTcpForwarding no
X11Forwarding noWarning
Notice the validate: "/usr/sbin/sshd -t -f %s" attribute. This validates the config syntax before replacing the original file — saving you from a fatal lockout if the template is wrong. And always make sure your SSH keys are installed (authorized_key) before disabling PasswordAuthentication, or you'll lock yourself out of your own server.
The firewall is a server's first fence. Ansible provides dedicated modules for each backend, and the best decision is to pick one firewall per host and stay consistent — don't mix UFW and firewalld on the same system.
The following code group example shows equivalent approaches in three different worlds — one for Debian/Ubuntu (UFW), one for RHEL/Rocky (firewalld), and one low-level (iptables) for very specific cases:
- name: Konfigurasi firewall UFW (Debian/Ubuntu)
hosts: ubuntu_servers
become: true
tasks:
- name: Default policy deny incoming
community.general.ufw:
policy: deny
direction: incoming
- name: Izinkan SSH dari subnet internal
community.general.ufw:
rule: allow
proto: tcp
port: "22"
src: 10.0.0.0/8
- name: Izinkan HTTP/HTTPS publik
community.general.ufw:
rule: allow
port: "80,443"
proto: tcp
- name: Aktifkan UFW
community.general.ufw:
state: enabledNote
For managed nodes behind cloud security groups (AWS, GCP, Azure), remember that cloud security groups don't replace the OS firewall — the two must complement each other. Security groups protect from outside the VPC, while the OS firewall protects from other hosts within the same network. Set firewalld rules with immediate: true so changes apply without waiting for a service reload — preventing lockout when applying a deny policy live.
Patch management is one of the cheapest yet most effective security controls — most ransomware attacks and zero-day exploitation target vulnerabilities that were actually patched months ago. The problem is, manual patching across many servers is almost impossible to do consistently.
Ansible offers two approaches: automated security updates (small patches run automatically) and scheduled patch cycles (large patches with a schedule and controlled reboot).
For Ubuntu/Debian, unattended-upgrades installs security patches automatically in the background. Its config can be templated by Ansible:
- name: Aktifkan automated security updates
hosts: ubuntu_servers
become: true
tasks:
- name: Install unattended-upgrades
ansible.builtin.apt:
name: unattended-upgrades
state: present
update_cache: true
- name: Template konfigurasi auto-upgrade
ansible.builtin.template:
src: 50unattended-upgrades.j2
dest: /etc/apt/apt.conf.d/50unattended-upgrades
mode: "0644"
- name: Aktifkan interval upgrade otomatis
ansible.builtin.copy:
dest: /etc/apt/apt.conf.d/20auto-upgrades
content: |
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";For RHEL, dnf-automatic plays the same role:
dnf install -y dnf-automatic
sed -i 's/apply_updates = no/apply_updates = yes/' /etc/dnf/automatic.conf
systemctl enable --now dnf-automatic.timerCaution
Automated security updates are a double-edged sword. On one hand they close security holes quickly; on the other they can install updates that break the application at 3 AM without anyone knowing. Best practice: enable automated updates only for security patches, pin versions for critical services, and monitor the results through logs and monitoring (recall episode 25).
Kernel updates always require a reboot — and reboot is the most tense moment in operations. That's where the ansible.builtin.reboot module comes in. This module doesn't just reboot; it can wait for the SSH connection to drop, wait for services to return, and delay the reboot so it doesn't happen suddenly.
Example safe patch + reboot playbook for a production fleet — using serial so only part of the servers are patched at a time:
- name: Patch dan reboot aman
hosts: app_servers
become: true
serial: 1
order: sorted
tasks:
- name: Update semua paket (termasuk kernel)
ansible.builtin.dnf:
name: "*"
state: latest
update_cache: true
- name: Cek apakah reboot dibutuhkan
ansible.builtin.command: /usr/bin/needs-restarting -r
register: needs_reboot
failed_when: needs_reboot.rc not in [0, 1]
changed_when: false
- name: Reboot dengan grace period
ansible.builtin.reboot:
reboot_timeout: 600
pre_reboot_delay: 30
post_reboot_delay: 15
connect_timeout: 30
when: needs_reboot.rc == 1Tip
Some important reboot module parameters: pre_reboot_delay gives 30 seconds for graceful application drain, post_reboot_delay waits for the system to fully stabilize after boot, and reboot_timeout limits the maximum wait time. For services that must not drop, add a post-reboot application health check task (e.g., check the HTTP endpoint) — Ansible has wait_for that can wait for a port/URL to become healthy again. The combination of serial: 1 + drain + health check is the recipe for downtime-free rolling reboots.
STIG (Security Technical Implementation Guide) is a hardening standard issued by the US DISA (Defense Information Systems Agency), required for all systems in military and government environments, and widely adopted by industry as the "advanced hardening" reference. In this episode we'll understand the basic pattern: STIG is categorized per control (e.g., SV-XXXXX for a specific rule), and roles like ansible-lockdown.stig_rhel9 automate its implementation.
The second pillar of compliance is audit logging — without good logs, you can't prove compliance (or find attack traces). The following example configures rsyslog to forward logs to a central log server, plus enables auditd to monitor changes to important files:
- name: Konfigurasi audit logging terpusat
hosts: all
become: true
vars:
central_log_host: 10.0.0.50
tasks:
- name: Forward log auth ke server terpusat
ansible.builtin.copy:
dest: /etc/rsyslog.d/60-central.conf
content: |
auth.* @@{{ central_log_host }}:514
kern.* @@{{ central_log_host }}:514
mail.* @@{{ central_log_host }}:514
notify: Restart rsyslog
- name: Pantau integritas file penting dengan auditd
ansible.builtin.copy:
dest: /etc/audit/rules.d/audit.rules
content: |
-w /etc/passwd -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/sudoers -p wa -k identity
-w /var/log/auth.log -p wa -k authlog
-a always,exit -F arch=b64 -S execve -k process_execution
notify: Restart auditd
handlers:
- name: Restart rsyslog
ansible.builtin.service:
name: rsyslog
state: restarted
- name: Restart auditd
ansible.builtin.service:
name: auditd
state: restartedFile integrity monitoring (FIM) completes audit logging: it detects changes to important files that should never change. AIDE (Advanced Intrusion Detection Environment) is a popular open-source FIM choice. The flow: initialize a baseline database → compare periodically → report differences.
- name: Setup AIDE file integrity monitoring
hosts: all
become: true
tasks:
- name: Install AIDE
ansible.builtin.apt:
name: aide
state: present
- name: Inisialisasi database baseline AIDE
ansible.builtin.command: aideinit
args:
creates: /var/lib/aide/aide.db
changed_when: false
- name: Jadwalkan scan harian via cron
ansible.builtin.cron:
name: "AIDE daily integrity check"
minute: "0"
hour: "3"
job: "/usr/bin/aide.wrapper --check | mail -s 'AIDE report' security@example.com"Warning
The golden FIM principle: the baseline database must be created on a clean, verified system, not after the system has run for months with a possible compromise. After initialization, update the baseline database manually only for intentional changes (e.g., new deployments), and store the database hash in a location that's hard for attackers to tamper with. Otherwise, you'll drown in hundreds of false-positive alerts every day.
Hardening without verification is just a claim. Security scanners turn claims into evidence: how many controls pass, which fail, and which remain vulnerable. Ansible is the perfect vehicle to run scanners, collect results, and generate reports across the fleet periodically.
OpenSCAP is a standards-based compliance scanner (running profiles like CIS, STIG, PCI-DSS) that generates HTML/XML reports. Lynis is a security audit scanner that provides a hardening index and heuristic-based recommendations. The following code group example shows both:
- name: Scan compliance dengan OpenSCAP
hosts: rhel_servers
become: true
tasks:
- name: Install openscap-scanner
ansible.builtin.dnf:
name: openscap-scanner
state: present
- name: Jalankan scan profil STIG RHEL 9
ansible.builtin.command:
cmd: >-
oscap xccdf eval
--profile stig
--results /root/scap-results.xml
--report /root/scap-report.html
/usr/share/xml/scap/ssg/content/ssg-rhel9-ds.xml
register: scan_result
changed_when: scan_result.rc != 0
- name: Tarik laporan scan ke control node
ansible.builtin.fetch:
src: /root/scap-report.html
dest: "reports/{{ inventory_hostname }}-scap.html"
flat: trueTip
Run scans periodically (e.g., via cron or the CI/CD pipeline from episode 19) and make the reports the source of truth for audits. Integrate vulnerability scores into a ticketing system or dashboard — for example, community.general has the grafana_dashboard module if you want to visualize compliance scores. What's more important: scans must be remediated, not just stored. Pair scan results with hardening playbooks so failing controls are automatically fixed on the next run.
1. Hardening straight into production without staging
CIS and STIG roles are aggressive; one rule can lock users, disable a service the application uses, or block the network. Always --check, test in staging, then promote to production.
2. Forgetting to keep an escape path
Before disabling PasswordAuthentication, make sure SSH keys are installed on all hosts. Before locking down firewall default policy, make sure there's out-of-band access (e.g., IPMI or cloud console). One mistake and you're locked out — and Ansible can't help you anymore.
3. Rebooting without drain
Rebooting all servers at once (ignoring serial: 1) guarantees everything down. Always limit batches, give pre_reboot_delay, and wait for health checks before moving to the next batch.
4. Automated updates without monitoring
unattended-upgrades running silently without alerts is as dangerous as never updating. Always monitor update logs and verify services remain healthy after patches.
5. AIDE baseline created when the system is already "contaminated"
An FIM database is only useful if created from a clean system. And don't forget to update the baseline on intentional changes — otherwise alert noise will make you disable the alerts (which is far more dangerous).
6. Relying on scanners as a replacement for hardening
OpenSCAP/Lynis find problems, they don't fix them. Scanning must be paired with remediation playbooks, or the compliance score is just a number on a dashboard.
In this episode we've covered security hardening and compliance automation thoroughly: implementing CIS Benchmarks with the ansible-lockdown collection, SSH hardening and firewall configuration (UFW, firewalld, iptables), patch management with automated security updates and safe reboots, compliance as code for STIG, audit logging, and file integrity monitoring with AIDE, plus integration of the OpenSCAP and Lynis security scanners with automated reports. We also closed with common mistakes that often trap teams.
The essence: security isn't a product you buy, but a process you maintain continuously — and Ansible turns that process from error-prone manual activity into a declarative system that's verified and auditable. With this capability, you can claim not just that your infrastructure is available, but that it's guaranteed.
In episode 28, we'll cover a topic that challenges the assumption "Ansible is only for Linux": Windows Automation with Ansible — managing WinRM, the ansible.windows and community.windows modules, IIS automation, Windows Update, and Active Directory from the same Linux control node. Keep your enthusiasm up!