Security

Server Hardening with Ansible: CIS-Style Security Baseline Playbook

Teach me Ansible | 2026-08-15 | 22 min read

Manually hardening Linux servers is slow, error-prone, and impossible to keep consistent across a fleet. In this guide you'll build a CIS-style security baseline with Ansible: SSH lockdown, automatic security updates, firewalls, fail2ban, kernel hardening, auditd, and compliance verification — all as repeatable, reviewable code.

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:

  1. SSH brute force and credential stuffing - Bots hammer port 22 within minutes of a server going live
  2. Exploitation of unpatched services - Known CVEs in software you forgot to update
  3. Exposed services you didn't mean to expose - Databases, admin panels, and debug endpoints listening on 0.0.0.0
  4. Privilege escalation after initial compromise - Weak file permissions, permissive sudo, and unhardened kernels
  5. 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:

  1. Check mode first - ansible-playbook harden.yml --check --diff shows exactly what would change, with no risk
  2. Limit to a canary - --limit canary01 applies it to one expendable host
  3. 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.