Ansible Lesson 31 – Server Hardening | Dataplexa
Section III · Lesson 31

Server Hardening

In this lesson

Hardening categories SSH hardening Firewall management Kernel & sysctl CIS benchmarks

Server hardening is the process of reducing a server's attack surface by disabling unnecessary services, enforcing strict authentication, restricting network access, and applying security baselines. Done manually, hardening is error-prone and inconsistent — one engineer's definition of "hardened" differs from another's, and servers provisioned months apart drift apart. Done with Ansible, hardening is declarative, repeatable, and auditable: the same playbook applies the same security configuration to every server in the fleet, and re-running it corrects any drift that has occurred since the last run. This lesson builds a production hardening role that covers SSH, firewall, kernel parameters, user access controls, and CIS benchmark alignment.

Hardening Categories

Server hardening is not a single task — it is a layered set of controls. Each layer addresses a different attack vector. A comprehensive hardening playbook covers all six.

Layer 1

SSH hardening

Disable root login, enforce key auth, restrict algorithms, set idle timeout

Layer 2

Firewall rules

Default deny all inbound, allow only required ports, restrict by source IP where possible

Layer 3

Kernel hardening

sysctl parameters — disable IP forwarding, enable SYN cookies, restrict core dumps

Layer 4

User access controls

Remove unused accounts, enforce password policies, restrict sudo access

Layer 5

Service minimisation

Disable and mask unnecessary services — avahi, cups, rpcbind, telnet, FTP

Layer 6

Audit and logging

auditd rules, syslog configuration, log rotation, MOTD banners

The Building Security Analogy

Server hardening is like building security for an office. You start with the perimeter — a fence and locked front door (firewall). Then you control who has a key (SSH key authentication). Then you limit which rooms each key holder can access (user permissions and sudo rules). Then you disable unused side entrances (unnecessary services). Finally, you install cameras and an alarm (audit logging). Each layer independently reduces risk; together they form defence in depth. Disabling the alarm because you have a good lock is as foolish in IT security as it is in physical security.

SSH Hardening

SSH is the primary attack vector for external attackers attempting to gain shell access. Every SSH hardening measure reduces the exploitable surface — even if the server is never exposed to the public internet.

Critical sshd_config directives

PermitRootLogin no Prevents direct root login over SSH. Attackers cannot target the root account directly — they must compromise a normal user first, then escalate.
PasswordAuthentication no Disables password login completely. Only SSH key pairs work. Eliminates brute force password attacks entirely — the most impactful single SSH hardening measure.
MaxAuthTries 3 Disconnects after 3 failed authentication attempts. Slows automated brute force attacks that rely on rapid repeated attempts.
ClientAliveInterval 300 Disconnects idle SSH sessions after 5 minutes (300 seconds) of inactivity. Prevents abandoned sessions from remaining open indefinitely as unmonitored access points.
AllowUsers deploy ansible Allowlist specifying which users may connect via SSH. Any user not listed is denied even if they have valid credentials — limits the blast radius of a compromised account.
Protocol 2 Enforces SSH protocol version 2 only. SSH protocol 1 has known cryptographic weaknesses and should never be used.
# roles/hardening/tasks/ssh.yml
---
- name: Harden SSH daemon configuration
  ansible.builtin.lineinfile:
    path: /etc/ssh/sshd_config
    regexp: "{{ item.regexp }}"
    line: "{{ item.line }}"
    state: present
    validate: "sshd -t -f %s"    # validate before writing — prevents SSH lockout
    backup: true
  loop:
    - { regexp: "^#?PermitRootLogin",         line: "PermitRootLogin no" }
    - { regexp: "^#?PasswordAuthentication",  line: "PasswordAuthentication no" }
    - { regexp: "^#?PermitEmptyPasswords",    line: "PermitEmptyPasswords no" }
    - { regexp: "^#?MaxAuthTries",            line: "MaxAuthTries 3" }
    - { regexp: "^#?ClientAliveInterval",     line: "ClientAliveInterval 300" }
    - { regexp: "^#?ClientAliveCountMax",     line: "ClientAliveCountMax 2" }
    - { regexp: "^#?X11Forwarding",           line: "X11Forwarding no" }
    - { regexp: "^#?AllowAgentForwarding",    line: "AllowAgentForwarding no" }
    - { regexp: "^#?Protocol",               line: "Protocol 2" }
    - { regexp: "^#?LoginGraceTime",          line: "LoginGraceTime 60" }
  notify: hardening | Restart SSH
  loop_control:
    label: "{{ item.line }}"

- name: Set allowed SSH users
  ansible.builtin.lineinfile:
    path: /etc/ssh/sshd_config
    regexp: "^#?AllowUsers"
    line: "AllowUsers {{ ssh_allowed_users | join(' ') }}"
    validate: "sshd -t -f %s"
  notify: hardening | Restart SSH
  when: ssh_allowed_users | length > 0

Firewall Management

Ansible manages firewalls through the ansible.builtin.ufw module (Ubuntu/Debian) or ansible.posix.firewalld (RHEL/CentOS). The principle is the same for both: deny all inbound by default, then explicitly allow only the ports the server legitimately serves.

# roles/hardening/tasks/firewall.yml — UFW (Debian/Ubuntu)
---
- name: Install UFW
  ansible.builtin.package:
    name: ufw
    state: present

- name: Reset UFW to clean defaults
  community.general.ufw:
    state: reset

- name: Set default inbound policy to deny
  community.general.ufw:
    default: deny
    direction: incoming

- name: Set default outbound policy to allow
  community.general.ufw:
    default: allow
    direction: outgoing

- name: Allow configured inbound ports
  community.general.ufw:
    rule: allow
    port: "{{ item.port }}"
    proto: "{{ item.proto | default('tcp') }}"
    src: "{{ item.src | default('any') }}"
    comment: "{{ item.comment | default('') }}"
  loop: "{{ firewall_allowed_ports }}"
  loop_control:
    label: "{{ item.port }}/{{ item.proto | default('tcp') }}"

- name: Enable UFW
  community.general.ufw:
    state: enabled
    logging: "on"
# roles/hardening/defaults/main.yml — firewall defaults
---
ssh_allowed_users: []   # empty = no AllowUsers restriction

# Override in group_vars per server role:
firewall_allowed_ports:
  - { port: "22",  proto: tcp, comment: "SSH" }
  # Web servers add:
  # - { port: "80",  proto: tcp, comment: "HTTP" }
  # - { port: "443", proto: tcp, comment: "HTTPS" }
  # DB servers add:
  # - { port: "5432", proto: tcp, src: "10.0.0.0/8", comment: "PostgreSQL — internal only" }

Kernel Hardening with sysctl

Linux kernel parameters set via sysctl control network behaviour, memory protection, and core dump policies. The ansible.posix.sysctl module applies these persistently by writing to /etc/sysctl.d/ and reloading — they survive reboots.

# roles/hardening/tasks/kernel.yml
---
- name: Apply kernel security parameters
  ansible.posix.sysctl:
    name: "{{ item.name }}"
    value: "{{ item.value }}"
    state: present
    sysctl_file: /etc/sysctl.d/99-hardening.conf
    reload: true
  loop:
    # Network hardening
    - { name: net.ipv4.ip_forward,                value: "0" }   # disable IP forwarding
    - { name: net.ipv4.conf.all.send_redirects,   value: "0" }   # disable ICMP redirects
    - { name: net.ipv4.conf.all.accept_redirects, value: "0" }
    - { name: net.ipv4.conf.all.accept_source_route, value: "0" }
    - { name: net.ipv4.tcp_syncookies,            value: "1" }   # enable SYN flood protection
    - { name: net.ipv4.icmp_echo_ignore_broadcasts, value: "1" } # ignore broadcast pings
    - { name: net.ipv4.conf.all.log_martians,     value: "1" }   # log spoofed packets

    # Memory protection
    - { name: kernel.randomize_va_space,          value: "2" }   # full ASLR
    - { name: kernel.dmesg_restrict,              value: "1" }   # restrict dmesg to root
    - { name: kernel.core_uses_pid,               value: "1" }   # include PID in core dumps
    - { name: fs.suid_dumpable,                   value: "0" }   # no core dumps for setuid

    # IPv6 (disable if not in use)
    - { name: net.ipv6.conf.all.disable_ipv6,     value: "{{ '1' if disable_ipv6 else '0' }}" }
  loop_control:
    label: "{{ item.name }} = {{ item.value }}"

Service Minimisation

Every running service that is not required is an attack surface. Services that are merely stopped can be started by an attacker who gains local access. Masking a service with systemd prevents it from being started by any means — the strongest form of disabling.

# roles/hardening/tasks/services.yml
---
- name: Remove unnecessary packages
  ansible.builtin.package:
    name: "{{ item }}"
    state: absent
  loop: "{{ packages_to_remove }}"
  loop_control:
    label: "remove {{ item }}"

- name: Stop and mask unnecessary services
  ansible.builtin.systemd:
    name: "{{ item }}"
    state: stopped
    enabled: false
    masked: true           # prevents starting even with systemctl start
  loop: "{{ services_to_mask }}"
  loop_control:
    label: "mask {{ item }}"
  # Ignore errors — some services may not exist on all OS versions
  failed_when: false
# roles/hardening/defaults/main.yml (continued)
packages_to_remove:
  - telnet
  - rsh-client
  - nis
  - talk

services_to_mask:
  - avahi-daemon    # mDNS — rarely needed on servers
  - cups            # printing — never needed on servers
  - rpcbind         # NFS portmapper — only needed for NFS
  - nfs-server      # NFS server

disable_ipv6: false   # set true if IPv6 is not used in your environment

The Complete Hardening Role

Bring all the sub-task files together via import_tasks in the role's main.yml. Each section is independently tagged so engineers can re-apply only the SSH rules, or only the firewall rules, after a specific change.

# roles/hardening/tasks/main.yml
---
- name: Apply SSH hardening
  ansible.builtin.import_tasks: ssh.yml
  tags: [hardening, ssh]

- name: Apply firewall rules
  ansible.builtin.import_tasks: firewall.yml
  tags: [hardening, firewall]

- name: Apply kernel hardening
  ansible.builtin.import_tasks: kernel.yml
  tags: [hardening, kernel, sysctl]

- name: Minimise running services
  ansible.builtin.import_tasks: services.yml
  tags: [hardening, services]

- name: Configure audit logging
  ansible.builtin.import_tasks: audit.yml
  tags: [hardening, audit]

Applying the hardening role in a playbook

---
# hardening.yml — run after provisioning, before production traffic
- name: Harden all servers
  hosts: all
  become: true
  roles:
    - role: hardening
      vars:
        # Web servers also allow HTTP/HTTPS
        firewall_allowed_ports:
          - { port: "22",  proto: tcp, comment: "SSH" }
          - { port: "80",  proto: tcp, comment: "HTTP" }
          - { port: "443", proto: tcp, comment: "HTTPS" }
        ssh_allowed_users:
          - ansible
          - deploy
PLAY [Harden all servers] *****************************************************

TASK [hardening : Harden SSH daemon configuration] ****************************
changed: [web01] => (item=PermitRootLogin no)
changed: [web01] => (item=PasswordAuthentication no)
ok:      [web01] => (item=MaxAuthTries 3)          <-- already set
changed: [web01] => (item=ClientAliveInterval 300)
...

TASK [hardening : Set allowed SSH users] **************************************
changed: [web01]

TASK [hardening : Set default inbound policy to deny] *************************
changed: [web01]

TASK [hardening : Allow configured inbound ports] *****************************
changed: [web01] => (item=22/tcp)
changed: [web01] => (item=80/tcp)
changed: [web01] => (item=443/tcp)

TASK [hardening : Enable UFW] *************************************************
changed: [web01]

TASK [hardening : Apply kernel security parameters] ***************************
changed: [web01] => (item=net.ipv4.ip_forward = 0)
changed: [web01] => (item=net.ipv4.tcp_syncookies = 1)
ok:      [web01] => (item=kernel.randomize_va_space = 2)  <-- already set
...

RUNNING HANDLERS [Harden all servers] *****************************************
changed: [web01]   <-- SSH restarted with new config

PLAY RECAP ********************************************************************
web01   : ok=18  changed=14  unreachable=0  failed=0  skipped=0

What just happened?

The hardening role applied all six layers in sequence. Some tasks reported ok — those settings were already correctly configured. The SSH handler fired once at the end after multiple SSH directives changed. The key idempotency property: running this playbook again produces all ok results — no unnecessary restarts or changes. Re-run it monthly as a compliance check and it will surface any drift that has occurred.

CIS Benchmarks

The Center for Internet Security (CIS) publishes detailed security benchmarks for every major OS — specific, testable hardening requirements organised by criticality. Rather than building a hardening role from scratch, most teams use a community CIS role from Galaxy as a starting point and customise from there.

Build your own role
Full control over exactly what is applied
No unexpected changes from upstream updates
More work — must track CIS updates manually
Use a community CIS role
Hundreds of controls out of the box — faster to achieve compliance
Community-maintained, tracks CIS updates automatically
Must audit the role carefully before use in production
# Popular CIS hardening roles on Galaxy
ansible-galaxy role install dev-sec.os-hardening    # widely used, well maintained
ansible-galaxy role install dev-sec.ssh-hardening   # SSH-specific hardening
---
- name: Apply CIS-aligned hardening
  hosts: all
  become: true
  roles:
    - role: dev-sec.os-hardening
      vars:
        # Disable controls that break your application
        os_remove_packages:
          - telnet
          - nis
        ufw_manage_defaults: true

    - role: dev-sec.ssh-hardening
      vars:
        ssh_permit_root_login: "no"
        ssh_password_authentication: "no"
        ssh_allowed_users: "ansible deploy"

Always Test Hardening in Staging Before Production — SSH Lockout Is Real

An SSH hardening playbook that has a mistake — an incorrect AllowUsers list, a typo in the key type restriction, or a firewall rule that blocks port 22 — will lock you out of every server it runs against. Always test on a single staging server first. Always keep a separate out-of-band access method available (cloud console, serial console, bastion host) when running SSH hardening for the first time. The validate: "sshd -t -f %s" parameter on all sshd_config tasks is your last line of defence — never omit it.

Key Takeaways

Always validate sshd_config before writing — use validate: "sshd -t -f %s" on every task that modifies SSH configuration. A syntax error without validation locks you out of the server.
Disable password authentication over SSH — this single setting eliminates the entire brute-force password attack vector and is the highest-impact individual SSH hardening measure.
Use masked: true on systemd to prevent unused services from starting — stopping a service is not enough; masking prevents it from being started even if an attacker gains local access.
sysctl parameters go in /etc/sysctl.d/ not /etc/sysctl.conf — using a numbered file like 99-hardening.conf keeps your changes separate from OS defaults and makes them easy to audit and remove.
Run the hardening playbook regularly as a drift-correction job — not just once at provisioning. Idempotency means re-runs are safe and inexpensive; re-running monthly surfaces and corrects any manual changes made outside Ansible.

Teacher's Note

Apply the SSH hardening tasks from this lesson to your lab VM — with validate: "sshd -t -f %s" on every task. Then deliberately introduce a typo in one directive and run the playbook again. Watch the validate step catch it before the file is written. That experience will make the validate parameter non-negotiable in every SSH config task you write from now on.

Practice Questions

1. Which parameter on lineinfile and template tasks should always be used when modifying /etc/ssh/sshd_config to prevent a lockout from a syntax error?



2. Which systemd module parameter prevents a service from being started by any means — even systemctl start — making it the strongest form of disabling a service?



3. Which single sshd_config directive has the highest security impact by eliminating brute-force password attacks entirely?



Quiz

1. A lineinfile task targeting sshd_config has validate: "sshd -t -f %s". The new line contains a typo. What happens?


2. Three months after hardening a fleet of 50 servers, an engineer discovers that some servers have had sysctl parameters manually changed. What is the correct Ansible-native response?


3. A team needs CIS Level 1 compliance on all servers but does not have time to build a full hardening role from scratch. What is the fastest path to compliance?


Up Next · Lesson 32

Ansible with Docker

Learn to manage Docker containers, images, networks, and volumes with Ansible — including multi-container application deployment, Docker Compose integration, and container lifecycle management across a fleet.