Ansible Course
Server Hardening
In this lesson
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.
SSH hardening
Disable root login, enforce key auth, restrict algorithms, set idle timeout
Firewall rules
Default deny all inbound, allow only required ports, restrict by source IP where possible
Kernel hardening
sysctl parameters — disable IP forwarding, enable SYN cookies, restrict core dumps
User access controls
Remove unused accounts, enforce password policies, restrict sudo access
Service minimisation
Disable and mask unnecessary services — avahi, cups, rpcbind, telnet, FTP
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.
# 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
validate: "sshd -t -f %s" on every task that modifies SSH
configuration. A syntax error without validation locks you out of the server.
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.
/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.
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.