Server Hardening with Ansible: CIS-Style Security Baseline Playbook
Why Automate Server Hardening?
A hardening checklist executed by hand suffers from three fatal problems: it drifts (someone changes a setting and nobody notices), it doesn't scale (100 servers × 50 checks is not a human job), and it isn't auditable (there's no record of what was applied where). Turning your baseline into an Ansible playbook fixes all three:
- Consistency - Every server gets exactly the same configuration, every time
- Idempotency - Re-running the playbook reverts drift back to the baseline
- Auditability - Your security posture lives in Git with full change history
- Speed - Harden a new server in minutes, not hours
- Verification - Run the same playbook in check mode as a compliance scanner
Threat Model: Internet-Facing Linux Servers
Before writing tasks, know what you're defending against. For a typical internet-facing Linux server, the realistic threats are:
- SSH brute force and credential stuffing - Bots hammer port 22 within minutes of a server going live
- Exploitation of unpatched services - Known CVEs in software you forgot to update
- Exposed services you didn't mean to expose - Databases, admin panels, and debug endpoints listening on 0.0.0.0
- Privilege escalation after initial compromise - Weak file permissions, permissive sudo, and unhardened kernels
- Lack of forensic visibility - No audit trail when something does go wrong
The baseline below addresses each of these in turn. It follows the spirit of the CIS Benchmarks — the industry-standard hardening guides — without slavishly implementing every control, some of which will break real workloads if applied blindly.
SSH Hardening
SSH is your front door, so it gets the most attention. The goals: key-only authentication, no direct root login, an explicit allowlist of users, and modern cryptography only.
---
- name: Harden SSH daemon
hosts: all
become: yes
vars:
ssh_allowed_users:
- deploy
- ansible
tasks:
- name: Ensure admin user has an authorized key BEFORE locking down
ansible.posix.authorized_key:
user: deploy
key: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
state: present
- name: Apply hardened sshd settings
ansible.builtin.blockinfile:
path: /etc/ssh/sshd_config.d/99-hardening.conf
create: yes
mode: "0600"
block: |
# Authentication
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
AllowUsers {{ ssh_allowed_users | join(' ') }}
MaxAuthTries 3
LoginGraceTime 30
# Session
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding no
ClientAliveInterval 300
ClientAliveCountMax 2
# Modern crypto only
KexAlgorithms sntrup761x25519-sha512@openssh.com,curve25519-sha256
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com
MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com
notify: Restart sshd
- name: Validate sshd config before it can lock us out
ansible.builtin.command: sshd -t
changed_when: false
handlers:
- name: Restart sshd
ansible.builtin.service:
name: sshd
state: restarted
Don't Lock Yourself Out
Always deploy your SSH key before disabling password authentication, and always run sshd -t before restarting the daemon. Keep an out-of-band console (cloud provider serial console, IPMI) available the first time you apply this.
Automatic Security Updates
Unpatched CVEs are the number-two entry point after weak SSH. Enable unattended security updates on Debian/Ubuntu and dnf-automatic on RHEL-family systems:
- name: Configure automatic security updates
hosts: all
become: yes
tasks:
# Debian / Ubuntu
- name: Install unattended-upgrades
ansible.builtin.apt:
name:
- unattended-upgrades
- apt-listchanges
state: present
when: ansible_facts.os_family == "Debian"
- name: Enable unattended security upgrades
ansible.builtin.copy:
dest: /etc/apt/apt.conf.d/20auto-upgrades
mode: "0644"
content: |
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
when: ansible_facts.os_family == "Debian"
# RHEL / Rocky / Alma
- name: Install dnf-automatic
ansible.builtin.dnf:
name: dnf-automatic
state: present
when: ansible_facts.os_family == "RedHat"
- name: Configure dnf-automatic for security updates only
ansible.builtin.ini_file:
path: /etc/dnf/automatic.conf
section: commands
option: "{{ item.option }}"
value: "{{ item.value }}"
mode: "0644"
loop:
- { option: upgrade_type, value: security }
- { option: apply_updates, value: "yes" }
when: ansible_facts.os_family == "RedHat"
- name: Enable dnf-automatic timer
ansible.builtin.systemd:
name: dnf-automatic.timer
enabled: yes
state: started
when: ansible_facts.os_family == "RedHat"
Firewall Automation
Default-deny inbound traffic, allow only what the server actually serves. Here are both the Ubuntu (ufw) and RHEL (firewalld) approaches:
- name: Configure host firewall
hosts: all
become: yes
vars:
allowed_tcp_ports: [22, 80, 443]
tasks:
# UFW (Debian/Ubuntu)
- name: Allow required ports through ufw
community.general.ufw:
rule: allow
port: "{{ item }}"
proto: tcp
loop: "{{ allowed_tcp_ports }}"
when: ansible_facts.os_family == "Debian"
- name: Rate-limit SSH connections
community.general.ufw:
rule: limit
port: "22"
proto: tcp
when: ansible_facts.os_family == "Debian"
- name: Enable ufw with default deny
community.general.ufw:
state: enabled
policy: deny
direction: incoming
when: ansible_facts.os_family == "Debian"
# firewalld (RHEL family)
- name: Ensure firewalld is running
ansible.builtin.service:
name: firewalld
state: started
enabled: yes
when: ansible_facts.os_family == "RedHat"
- name: Open required ports in firewalld
ansible.posix.firewalld:
port: "{{ item }}/tcp"
permanent: yes
immediate: yes
state: enabled
loop: "{{ allowed_tcp_ports }}"
when: ansible_facts.os_family == "RedHat"
Order matters with ufw: add the allow rule for SSH before enabling the firewall, or your very next task will time out.
Deploying fail2ban
Even with key-only auth, brute-force bots waste resources and fill logs. fail2ban bans repeat offenders at the firewall level:
- name: Deploy fail2ban
hosts: all
become: yes
tasks:
- name: Install fail2ban
ansible.builtin.package:
name: fail2ban
state: present
- name: Configure sshd jail
ansible.builtin.copy:
dest: /etc/fail2ban/jail.local
mode: "0644"
content: |
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
bantime.increment = true
[sshd]
enabled = true
mode = aggressive
notify: Restart fail2ban
- name: Enable fail2ban service
ansible.builtin.service:
name: fail2ban
state: started
enabled: yes
handlers:
- name: Restart fail2ban
ansible.builtin.service:
name: fail2ban
state: restarted
Kernel and sysctl Hardening
The kernel's network stack ships with compatibility-friendly defaults, not security-friendly ones. The ansible.posix.sysctl module makes these settings persistent and applies them immediately:
- name: Harden kernel parameters
hosts: all
become: yes
vars:
sysctl_settings:
# Network hardening
net.ipv4.conf.all.rp_filter: 1
net.ipv4.conf.all.accept_redirects: 0
net.ipv4.conf.all.send_redirects: 0
net.ipv4.conf.all.accept_source_route: 0
net.ipv4.icmp_echo_ignore_broadcasts: 1
net.ipv4.tcp_syncookies: 1
net.ipv6.conf.all.accept_redirects: 0
# Kernel hardening
kernel.randomize_va_space: 2
kernel.kptr_restrict: 2
kernel.dmesg_restrict: 1
kernel.yama.ptrace_scope: 1
fs.protected_hardlinks: 1
fs.protected_symlinks: 1
fs.suid_dumpable: 0
tasks:
- name: Apply sysctl hardening baseline
ansible.posix.sysctl:
name: "{{ item.key }}"
value: "{{ item.value }}"
sysctl_file: /etc/sysctl.d/99-hardening.conf
state: present
reload: yes
loop: "{{ sysctl_settings | dict2items }}"
Audit Logging with auditd
When an incident happens, auditd is the difference between a forensic timeline and a shrug. Deploy it with rules that watch the files attackers touch:
- name: Deploy auditd with baseline rules
hosts: all
become: yes
tasks:
- name: Install auditd
ansible.builtin.package:
name: "{{ 'auditd' if ansible_facts.os_family == 'Debian' else 'audit' }}"
state: present
- name: Install baseline audit rules
ansible.builtin.copy:
dest: /etc/audit/rules.d/hardening.rules
mode: "0640"
content: |
# Identity changes
-w /etc/passwd -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/group -p wa -k identity
-w /etc/sudoers -p wa -k privilege
-w /etc/sudoers.d/ -p wa -k privilege
# SSH configuration
-w /etc/ssh/sshd_config -p wa -k sshd
# Privileged command execution
-a always,exit -F arch=b64 -S execve -C uid!=euid -F euid=0 -k setuid_exec
# Module loading
-w /sbin/insmod -p x -k modules
-w /sbin/modprobe -p x -k modules
notify: Restart auditd
- name: Enable auditd
ansible.builtin.service:
name: auditd
state: started
enabled: yes
handlers:
- name: Restart auditd
ansible.builtin.command: service auditd restart
changed_when: true
Note the handler uses service auditd restart directly — auditd famously refuses restarts via systemctl on many distributions for security reasons.
File Permission Checks
Weak permissions on sensitive files are a classic privilege escalation path. The file module both fixes and enforces:
- name: Enforce sensitive file permissions
hosts: all
become: yes
tasks:
- name: Lock down sensitive files
ansible.builtin.file:
path: "{{ item.path }}"
owner: root
group: "{{ item.group | default('root') }}"
mode: "{{ item.mode }}"
loop:
- { path: /etc/passwd, mode: "0644" }
- { path: /etc/shadow, mode: "0000" }
- { path: /etc/gshadow, mode: "0000" }
- { path: /etc/group, mode: "0644" }
- { path: /etc/ssh/sshd_config, mode: "0600" }
- { path: /etc/crontab, mode: "0600" }
- { path: /boot/grub/grub.cfg, mode: "0600" }
ignore_errors: "{{ ansible_check_mode }}"
- name: Find world-writable files in system paths
ansible.builtin.command: >
find /etc /usr/local/bin -xdev -type f -perm -0002
register: world_writable
changed_when: false
failed_when: world_writable.stdout | length > 0
Going Further: ansible-lockdown CIS Roles
If you need full CIS Benchmark coverage — hundreds of controls with audit tagging — don't write it yourself. The ansible-lockdown project maintains complete, actively updated CIS and STIG roles on Galaxy:
# Install CIS roles for your platforms
ansible-galaxy install ansible-lockdown.ubuntu22_cis
ansible-galaxy install ansible-lockdown.rhel9_cis
---
- name: Apply full CIS benchmark
hosts: all
become: yes
roles:
- role: ansible-lockdown.ubuntu22_cis
vars:
# Every control is a toggle - disable what breaks your workload
ubtu22cis_rule_5_2_4: false # keep AllowTcpForwarding for your bastion
ubtu22cis_sshd_allow_users: "deploy ansible"
ubtu22cis_firewall_package: ufw
when: ansible_facts.distribution == "Ubuntu"
Review the role's defaults/main.yml carefully before running it — a full CIS profile applied blindly will break something (commonly: NFS, containers, or IPv6). Every rule is individually toggleable, which is exactly how the benchmark is meant to be consumed.
Verifying Compliance
A hardening playbook doubles as a compliance scanner. A dedicated check playbook uses assert so it never changes anything — it only reports:
---
- name: Security compliance check
hosts: all
become: yes
gather_facts: yes
tasks:
- name: Read effective sshd configuration
ansible.builtin.command: sshd -T
register: sshd_config
changed_when: false
- name: Assert SSH is hardened
ansible.builtin.assert:
that:
- "'permitrootlogin no' in sshd_config.stdout"
- "'passwordauthentication no' in sshd_config.stdout"
fail_msg: "SSH hardening has drifted on {{ inventory_hostname }}!"
success_msg: "SSH configuration compliant"
- name: Check firewall is active
ansible.builtin.command: >
{{ 'ufw status' if ansible_facts.os_family == 'Debian'
else 'firewall-cmd --state' }}
register: fw_status
changed_when: false
- name: Assert critical services are running
ansible.builtin.service_facts:
- name: Verify fail2ban and auditd are active
ansible.builtin.assert:
that:
- ansible_facts.services['fail2ban.service'].state == 'running'
- ansible_facts.services['auditd.service'].state == 'running'
fail_msg: "Security services not running on {{ inventory_hostname }}"
Schedule this in cron or your CI pipeline. A non-zero exit code means drift — and because your baseline is idempotent, remediation is just re-running the hardening playbook.
Rolling Out Safely Across Many Hosts
Never apply a new hardening baseline to your whole fleet at once. Use this progression:
- Check mode first -
ansible-playbook harden.yml --check --diffshows exactly what would change, with no risk - Limit to a canary -
--limit canary01applies it to one expendable host - Serial batches - Roll out in waves so a bad change can't take down everything
---
- name: Staged hardening rollout
hosts: all
become: yes
serial:
- 1 # one host first
- "10%" # then 10% of the fleet
- "100%" # then everyone else
max_fail_percentage: 0 # any failure aborts the run
pre_tasks:
- name: Verify we can still reach the host post-SSH-change
ansible.builtin.wait_for_connection:
timeout: 30
roles:
- hardening
# The safe rollout sequence
ansible-playbook harden.yml --check --diff # 1. preview everything
ansible-playbook harden.yml --limit canary01 # 2. one host
ansible-playbook harden.yml # 3. staged full rollout
ansible-playbook compliance-check.yml # 4. verify
Full End-to-End Hardening Playbook
Putting it all together, here's a compact single-file baseline you can adapt. In production, split each section into a role (roles/hardening/tasks/ssh.yml, firewall.yml, etc.) and import them:
---
- name: CIS-style security baseline
hosts: all
become: yes
serial: "25%"
vars:
ssh_allowed_users: [deploy, ansible]
allowed_tcp_ports: [22, 80, 443]
pre_tasks:
- name: Ensure deploy key exists before lockdown
ansible.posix.authorized_key:
user: deploy
key: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
tasks:
- name: SSH hardening
ansible.builtin.import_tasks: tasks/ssh.yml
tags: ssh
- name: Automatic security updates
ansible.builtin.import_tasks: tasks/updates.yml
tags: updates
- name: Firewall configuration
ansible.builtin.import_tasks: tasks/firewall.yml
tags: firewall
- name: fail2ban deployment
ansible.builtin.import_tasks: tasks/fail2ban.yml
tags: fail2ban
- name: Kernel/sysctl hardening
ansible.builtin.import_tasks: tasks/sysctl.yml
tags: kernel
- name: Audit logging
ansible.builtin.import_tasks: tasks/auditd.yml
tags: audit
- name: File permission enforcement
ansible.builtin.import_tasks: tasks/permissions.yml
tags: permissions
post_tasks:
- name: Confirm SSH connectivity survived
ansible.builtin.wait_for_connection:
timeout: 30
- name: Report baseline applied
ansible.builtin.debug:
msg: "Hardening baseline applied to {{ inventory_hostname }} ({{ ansible_facts.distribution }} {{ ansible_facts.distribution_version }})"
Conclusion
Security hardening isn't a one-time project — it's a baseline you enforce continuously. With the playbooks above you now have:
- SSH locked to keys, allowlisted users, and modern ciphers
- Security patches applied automatically
- Default-deny firewalls on both Debian and RHEL families
- fail2ban absorbing brute-force noise
- A hardened kernel, audit trails, and enforced file permissions
- A compliance playbook that detects drift, and a serial rollout strategy that won't take down your fleet
Start with the SSH and firewall sections — they close the biggest holes — then layer in the rest. When you're ready for full benchmark coverage, graduate to the ansible-lockdown CIS roles and tune their toggles to your workloads.
Pro Tip
Run your compliance check playbook from CI on a schedule and alert on failures. Drift detection you don't automate is drift detection that stops happening after week two.