This is the Ansible implementation of the Linux server hardening guide.
Same sections, same controls, automated and idempotent.
It targets Debian 11/12 and Ubuntu 22.04/24.04.
Tested on a fresh VPS, on a fresh VM, and on an existing production server (the latter requires a careful read of the vars: block before applying).
You can run the whole playbook or any subset with --tags.
Project layout
linux-hardening/
βββ ansible.cfg
βββ inventory.yml
βββ group_vars/
β βββ hardened_servers.yml
βββ linux-hardening.yml
βββ templates/
βββ sshd-hardening.conf.j2
βββ sysctl-hardening.conf.j2
βββ audit-hardening.rules.j2
βββ fail2ban-jail.local.j2
βββ unattended-upgrades.conf.j2
βββ pam-faillock.j2
ansible.cfg
[defaults]
inventory = inventory.yml
host_key_checking = False
retry_files_enabled = False
stdout_callback = yaml
deprecation_warnings = False
forks = 10
[privilege_escalation]
become = True
become_method = sudo
become_user = root
become_ask_pass = False
[ssh_connection]
pipelining = True
control_path = /tmp/ansible-ssh-p-%%rinventory.yml
---
all:
children:
hardened_servers:
hosts:
vps-01:
ansible_host: 10.0.0.10
ansible_user: admin
vps-02:
ansible_host: 10.0.0.11
ansible_user: admingroup_vars/hardened_servers.yml
All the policy values centralised here.
Adapt to your site policy before running.
---
# ββ SSH ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
ssh_port: 22
# IMPORTANT: leave true until every user has a working public key.
# Switch to false once you've verified key-based login works for everyone.
ssh_password_auth: true
# ββ Firewall (UFW) βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# SSH is added automatically (rate-limited) β add other ports your server serves.
ufw_extra_allow:
# - { port: 80, proto: tcp, rule: allow }
# - { port: 443, proto: tcp, rule: allow }
# Optional: restrict SSH to a management subnet (defense in depth on top of fail2ban).
# Leave empty to allow SSH from anywhere.
ssh_management_cidrs: []
# - 10.0.0.0/24
# ββ Password policy (pam_pwquality) ββββββββββββββββββββββββββββββββββββββββ
password_min_length: 14
password_max_days: 365
password_min_days: 1
password_warn_age: 7
password_history: 5
password_enforce_for_root: true
# ββ Account lockout (pam_faillock) βββββββββββββββββββββββββββββββββββββββββ
faillock_deny: 5
faillock_unlock_time: 900 # 15 min
faillock_fail_interval: 900
# ββ Shell environment ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
shell_tmout_seconds: 900 # 15 min; raise to 3600 if too aggressive
system_umask: "027"
# ββ Sudo βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
sudo_timestamp_timeout: 5 # 0 = always prompt; CIS L1 strict
# ββ Auditd βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Set true ONLY after the ruleset is stable. Once true, the rules become
# immutable until reboot β you cannot change them without a reboot.
auditd_immutable: false
# ββ Fail2ban βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
fail2ban_bantime: 1h
fail2ban_findtime: 10m
fail2ban_maxretry: 5
fail2ban_ignoreip:
- 127.0.0.1/8
- ::1
# - 10.0.0.0/24
# ββ Unattended-upgrades ββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Master switch: set to false to skip auto-updates entirely (e.g. on hosts
# managed by a separate patching pipeline). When false, the playbook also
# disables the existing timer if it was previously enabled.
unattended_upgrades_enabled: true
unattended_auto_reboot: false # NEVER true in production
unattended_mail: "root"
# ββ Time sync ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
ntp_pool: 2.debian.pool.ntp.org
# ββ Banner βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
login_banner: |
WARNING: Authorized uses only. All activity may be monitored and reported.
Unauthorized access is prohibited and will be prosecuted to the full extent of the law.linux-hardening.yml
The main playbook.
Organised section by section, mirroring the hardening guide.
---
- name: Linux server hardening (Debian/Ubuntu) β based on CIS L1 + extras
hosts: hardened_servers
become: true
gather_facts: true
pre_tasks:
- name: Sanity check β only Debian/Ubuntu supported
ansible.builtin.assert:
that:
- ansible_facts.os_family == "Debian"
fail_msg: "This playbook only supports Debian/Ubuntu."
tags: always
- name: Update apt cache
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
tags: always
tasks:
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Β§2 β Packages and services
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- name: Β§2.1 β Purge unnecessary packages
ansible.builtin.apt:
name:
- telnet
- telnetd
- rsh-client
- rsh-redone-client
- talk
- nis
- ldap-utils
- xinetd
state: absent
purge: true
autoremove: true
tags: [packages]
- name: Β§2.2 β Install hardening tooling
ansible.builtin.apt:
name:
- rsyslog
- chrony
- auditd
- audispd-plugins
- unattended-upgrades
- apt-listchanges
- fail2ban
- libpam-pwquality
- libpam-modules
- ufw
- apparmor
- apparmor-utils
- apparmor-profiles
- apparmor-profiles-extra
state: present
tags: [packages]
- name: Β§2.3 β Disable services not needed on a server
ansible.builtin.systemd:
name: "{{ item }}"
enabled: false
state: stopped
loop:
- postfix
- bluetooth
failed_when: false # Skip cleanly if not installed
tags: [packages]
- name: Β§2.4 β Time sync via chrony
ansible.builtin.copy:
dest: /etc/chrony/chrony.conf
owner: root
group: root
mode: "0644"
content: |
pool {{ ntp_pool }} iburst
driftfile /var/lib/chrony/chrony.drift
makestep 1.0 3
rtcsync
notify: restart chrony
tags: [packages]
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Β§3 β Kernel modules and filesystem types
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- name: Β§3.1 β Disable unused filesystem modules
ansible.builtin.copy:
dest: /etc/modprobe.d/cis-filesystem.conf
owner: root
group: root
mode: "0644"
content: |
install cramfs /bin/true
install freevxfs /bin/true
install jffs2 /bin/true
install hfs /bin/true
install hfsplus /bin/true
install udf /bin/true
tags: [kernel-modules]
- name: Β§3.1 β Unload modules already in memory
community.general.modprobe:
name: "{{ item }}"
state: absent
loop:
- cramfs
- freevxfs
- jffs2
- hfs
- hfsplus
- udf
failed_when: false
tags: [kernel-modules]
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Β§4 β Kernel and network parameters (sysctl)
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- name: Β§4 β Deploy sysctl hardening
ansible.builtin.template:
src: sysctl-hardening.conf.j2
dest: /etc/sysctl.d/20-hardening.conf
owner: root
group: root
mode: "0644"
notify: reload sysctl
tags: [sysctl]
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Β§5 β Firewall (UFW)
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# CRITICAL ORDER: allow SSH BEFORE switching to default deny, or you
# will lock yourself out. Ansible handles this in a single play but
# the task order below still matters.
- name: Β§5 β UFW default policies
community.general.ufw:
direction: "{{ item.direction }}"
policy: "{{ item.policy }}"
loop:
- { direction: incoming, policy: deny }
- { direction: outgoing, policy: allow }
tags: [firewall]
- name: Β§5 β UFW allow SSH (rate-limited)
community.general.ufw:
rule: limit
port: "{{ ssh_port }}"
proto: tcp
tags: [firewall]
- name: Β§5 β UFW restrict SSH to management CIDRs (if any)
community.general.ufw:
rule: allow
from_ip: "{{ item }}"
to_port: "{{ ssh_port }}"
proto: tcp
loop: "{{ ssh_management_cidrs }}"
when: ssh_management_cidrs | length > 0
tags: [firewall]
- name: Β§5 β UFW extra application ports
community.general.ufw:
rule: "{{ item.rule }}"
port: "{{ item.port }}"
proto: "{{ item.proto }}"
loop: "{{ ufw_extra_allow }}"
tags: [firewall]
- name: Β§5 β Enable UFW + logging
community.general.ufw:
state: enabled
logging: "on"
tags: [firewall]
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Β§6 β Filesystem mount options
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- name: Β§6.1 β /dev/shm with noexec,nodev,nosuid
ansible.posix.mount:
path: /dev/shm
src: tmpfs
fstype: tmpfs
opts: defaults,noexec,nodev,nosuid
state: mounted
tags: [mount]
# NOTE: /tmp and /var/tmp mount options are intentionally NOT applied
# automatically β `noexec /tmp` breaks too many edge cases (snap, some
# apt postinst scripts, Java tmpdirs). Apply manually after testing.
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Β§7 β SSH server hardening
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- name: Β§7 β Deploy sshd hardening drop-in
ansible.builtin.template:
src: sshd-hardening.conf.j2
dest: /etc/ssh/sshd_config.d/99-hardening.conf
owner: root
group: root
mode: "0600"
validate: "/usr/sbin/sshd -t -f %s" # blocks lockout from typos
notify: restart ssh
tags: [ssh]
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Β§8 β PAM, password, and account policy
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- name: Β§8.1 β pam_pwquality config
ansible.builtin.copy:
dest: /etc/security/pwquality.conf
owner: root
group: root
mode: "0644"
content: |
minlen = {{ password_min_length }}
minclass = 4
dcredit = -1
ucredit = -1
ocredit = -1
lcredit = -1
retry = 3
{% if password_enforce_for_root %}
enforce_for_root
{% endif %}
tags: [pam]
- name: Β§8.1 β Enable pwquality via pam-auth-update
ansible.builtin.command: pam-auth-update --enable pwquality --force
changed_when: false
tags: [pam]
- name: Β§8.2 β pam_faillock config
ansible.builtin.copy:
dest: /etc/security/faillock.conf
owner: root
group: root
mode: "0644"
content: |
deny = {{ faillock_deny }}
unlock_time = {{ faillock_unlock_time }}
fail_interval = {{ faillock_fail_interval }}
tags: [pam]
- name: Β§8.2 β Deploy faillock pam-configs profile
ansible.builtin.template:
src: pam-faillock.j2
dest: /usr/share/pam-configs/faillock
owner: root
group: root
mode: "0644"
tags: [pam]
- name: Β§8.2 β Enable faillock via pam-auth-update
ansible.builtin.command: pam-auth-update --enable faillock --force
changed_when: false
tags: [pam]
- name: Β§8.3 / Β§8.5 β Password hashing + aging in login.defs
ansible.builtin.lineinfile:
path: /etc/login.defs
regexp: "^#?\\s*{{ item.key }}\\b"
line: "{{ item.key }} {{ item.value }}"
state: present
loop:
- { key: ENCRYPT_METHOD, value: SHA512 }
- { key: SHA_CRYPT_MIN_ROUNDS, value: "65536" }
- { key: SHA_CRYPT_MAX_ROUNDS, value: "65536" }
- { key: PASS_MAX_DAYS, value: "{{ password_max_days }}" }
- { key: PASS_MIN_DAYS, value: "{{ password_min_days }}" }
- { key: PASS_WARN_AGE, value: "{{ password_warn_age }}" }
- { key: UMASK, value: "{{ system_umask }}" }
tags: [pam]
- name: Β§8.6 β Create empty wheel group for su restriction
ansible.builtin.group:
name: wheel
state: present
tags: [pam]
- name: Β§8.6 β Restrict su to wheel via pam_wheel
ansible.builtin.lineinfile:
path: /etc/pam.d/su
regexp: "^#?\\s*auth\\s+required\\s+pam_wheel\\.so"
line: "auth required pam_wheel.so use_uid group=wheel"
state: present
tags: [pam]
- name: Β§8.7 β Empty /etc/securetty (forbid root tty login)
ansible.builtin.copy:
dest: /etc/securetty
owner: root
group: root
mode: "0600"
content: ""
tags: [pam]
- name: Β§8.8 β Belt-and-braces interactive umask
ansible.builtin.copy:
dest: /etc/profile.d/set_umask.sh
owner: root
group: root
mode: "0644"
content: |
umask {{ system_umask }}
tags: [pam, banners]
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Β§9 β Sudo hardening
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- name: Β§9 β Deploy sudo hardening (validated with visudo)
ansible.builtin.copy:
dest: /etc/sudoers.d/00-hardening
owner: root
group: root
mode: "0440"
validate: "visudo -cf %s"
content: |
Defaults use_pty
Defaults logfile="/var/log/sudo.log"
Defaults timestamp_timeout={{ sudo_timestamp_timeout }}
Defaults !visiblepw
Defaults env_reset
Defaults requiretty
tags: [sudo]
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Β§10 β Auditing (auditd)
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- name: Β§10 β Deploy audit rules
ansible.builtin.template:
src: audit-hardening.rules.j2
dest: /etc/audit/rules.d/hardening.rules
owner: root
group: root
mode: "0640"
notify: reload audit rules
tags: [auditd]
- name: Β§10 β Enable + start auditd
ansible.builtin.systemd:
name: auditd
enabled: true
state: started
tags: [auditd]
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Β§11 β Logging
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- name: Β§11.1 β rsyslog file mode
ansible.builtin.lineinfile:
path: /etc/rsyslog.conf
regexp: "^\\$FileCreateMode"
line: "$FileCreateMode 0640"
state: present
notify: restart rsyslog
tags: [logging]
- name: Β§11.2 β Tighten permissions on existing logs
ansible.builtin.shell: |
find /var/log -type f -exec chmod g-wx,o-rwx {} +
find /var/log -type d -exec chmod g-wx,o-rwx {} +
changed_when: false
tags: [logging]
- name: Β§11.5 β Cron config files mode
ansible.builtin.file:
path: "{{ item }}"
owner: root
group: root
mode: "0700"
loop:
- /etc/cron.hourly
- /etc/cron.daily
- /etc/cron.weekly
- /etc/cron.monthly
- /etc/cron.d
tags: [logging]
- name: Β§11.5 β /etc/crontab mode
ansible.builtin.file:
path: /etc/crontab
owner: root
group: root
mode: "0600"
tags: [logging]
- name: Β§11.5 β Create cron.allow / at.allow, remove cron.deny / at.deny
block:
- ansible.builtin.file:
path: "{{ item }}"
owner: root
group: root
mode: "0600"
state: touch
loop:
- /etc/cron.allow
- /etc/at.allow
changed_when: false
- ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- /etc/cron.deny
- /etc/at.deny
tags: [logging]
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Β§12 β Fail2ban
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- name: Β§12 β Deploy fail2ban jail.local
ansible.builtin.template:
src: fail2ban-jail.local.j2
dest: /etc/fail2ban/jail.local
owner: root
group: root
mode: "0644"
notify: restart fail2ban
tags: [fail2ban]
- name: Β§12 β Enable + start fail2ban
ansible.builtin.systemd:
name: fail2ban
enabled: true
state: started
tags: [fail2ban]
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Β§13 β Automatic security updates (master switch: unattended_upgrades_enabled)
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- name: Β§13 β Configure and enable unattended-upgrades
when: unattended_upgrades_enabled | bool
tags: [updates]
block:
- name: Β§13 β Deploy unattended-upgrades config
ansible.builtin.template:
src: unattended-upgrades.conf.j2
dest: /etc/apt/apt.conf.d/50unattended-upgrades
owner: root
group: root
mode: "0644"
- name: Β§13 β Enable apt periodic config
ansible.builtin.copy:
dest: /etc/apt/apt.conf.d/20auto-upgrades
owner: root
group: root
mode: "0644"
content: |
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
- name: Β§13 β Ensure unattended-upgrades timer is active
ansible.builtin.systemd:
name: unattended-upgrades
enabled: true
state: started
- name: Β§13 β Disable unattended-upgrades if explicitly opted out
when: not (unattended_upgrades_enabled | bool)
tags: [updates]
ansible.builtin.systemd:
name: unattended-upgrades
enabled: false
state: stopped
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Β§14 β Banners, MOTD, shell environment
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- name: Β§14.1 β Login banner /etc/issue + /etc/issue.net
ansible.builtin.copy:
dest: "{{ item }}"
owner: root
group: root
mode: "0644"
content: "{{ login_banner }}"
loop:
- /etc/issue
- /etc/issue.net
tags: [banners]
- name: Β§14.2 β Disable Ubuntu MOTD news
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- /etc/default/motd-news
tags: [banners]
- name: Β§14.2 β Strip exec bit from motd-news fetcher
ansible.builtin.file:
path: /etc/update-motd.d/50-motd-news
mode: "0644"
failed_when: false
tags: [banners]
- name: Β§14.3 β Deploy TMOUT (auto-logout, readonly)
ansible.builtin.copy:
dest: /etc/profile.d/tmout.sh
owner: root
group: root
mode: "0644"
content: |
# Auto-logout after {{ shell_tmout_seconds }} seconds of inactivity
declare -xr TMOUT={{ shell_tmout_seconds }}
tags: [banners]
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Β§15 β AppArmor
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- name: Β§15 β Ensure AppArmor is enabled + started
ansible.builtin.systemd:
name: apparmor
enabled: true
state: started
tags: [apparmor]
- name: Β§15 β Enforce critical profiles
ansible.builtin.command: aa-enforce {{ item }}
loop:
- /etc/apparmor.d/usr.sbin.sshd
- /etc/apparmor.d/usr.sbin.rsyslogd
changed_when: false
failed_when: false
tags: [apparmor]
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Β§10 β Make auditd ruleset immutable (ONLY if explicitly requested)
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- name: Β§10 β Append -e 2 to make audit rules immutable (LAST!)
ansible.builtin.lineinfile:
path: /etc/audit/rules.d/hardening.rules
line: "-e 2"
state: present
when: auditd_immutable | bool
notify: reload audit rules
tags: [auditd, immutable]
handlers:
- name: restart ssh
ansible.builtin.systemd:
name: ssh
state: restarted
- name: restart chrony
ansible.builtin.systemd:
name: chrony
state: restarted
- name: restart rsyslog
ansible.builtin.systemd:
name: rsyslog
state: restarted
- name: restart fail2ban
ansible.builtin.systemd:
name: fail2ban
state: restarted
- name: reload sysctl
ansible.builtin.command: sysctl --system
changed_when: false
- name: reload audit rules
ansible.builtin.command: augenrules --load
changed_when: falsetemplates/sshd-hardening.conf.j2
# Managed by Ansible β do not edit by hand
# Drop-in loaded by /etc/ssh/sshd_config
# Logging
LogLevel VERBOSE
# Authentication
PermitRootLogin no
PermitUserEnvironment no
PermitEmptyPasswords no
HostbasedAuthentication no
IgnoreRhosts yes
MaxAuthTries 4
LoginGraceTime 60
# PAM (account/session)
UsePAM yes
PubkeyAuthentication yes
PasswordAuthentication {{ "yes" if ssh_password_auth else "no" }}
KbdInteractiveAuthentication no
# Sessions
ClientAliveInterval 300
ClientAliveCountMax 3
MaxSessions 10
MaxStartups 10:30:60
# Banner
Banner /etc/issue.net
# Strong algorithms only
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr
MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,hmac-sha2-512,hmac-sha2-256
KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512,ecdh-sha2-nistp521,ecdh-sha2-nistp384,ecdh-sha2-nistp256,diffie-hellman-group-exchange-sha256templates/sysctl-hardening.conf.j2
# Managed by Ansible β see linux-server-hardening guide Β§4
# βββ Network: routing & redirects βββββββββββββββββββββββββ
net.ipv4.ip_forward = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv4.conf.all.secure_redirects = 0
net.ipv4.conf.default.secure_redirects = 0
# βββ Network: source routing ββββββββββββββββββββββββββββββ
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.default.accept_source_route = 0
# βββ Network: spoofing & anti-DoS βββββββββββββββββββββββββ
net.ipv4.icmp_echo_ignore_broadcasts = 1
net.ipv4.icmp_ignore_bogus_error_responses = 1
net.ipv4.tcp_syncookies = 1
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
net.ipv4.conf.all.log_martians = 1
net.ipv4.conf.default.log_martians = 1
# βββ IPv6 hardening βββββββββββββββββββββββββββββββββββββββ
net.ipv6.conf.all.accept_redirects = 0
net.ipv6.conf.default.accept_redirects = 0
net.ipv6.conf.all.accept_source_route = 0
net.ipv6.conf.default.accept_source_route = 0
net.ipv6.conf.all.forwarding = 0
# βββ Kernel: exploit mitigations & info leaks βββββββββββββ
kernel.randomize_va_space = 2
fs.suid_dumpable = 0
kernel.dmesg_restrict = 1
kernel.kptr_restrict = 2
kernel.perf_event_paranoid = 3
kernel.yama.ptrace_scope = 1
# βββ Filesystem: link & FIFO protections ββββββββββββββββββ
fs.protected_hardlinks = 1
fs.protected_symlinks = 1
fs.protected_fifos = 2
fs.protected_regular = 2templates/audit-hardening.rules.j2
# Managed by Ansible β see linux-server-hardening guide Β§10
# Order matters: -e 2 (set by playbook when auditd_immutable=true) MUST be last.
# βββ Authentication & account changes βββββββββββββββββββ
-w /etc/passwd -p wa -k identity
-w /etc/group -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/gshadow -p wa -k identity
-w /etc/sudoers -p wa -k scope
-w /etc/sudoers.d/ -p wa -k scope
# βββ Login records ββββββββββββββββββββββββββββββββββββββ
-w /var/log/lastlog -p wa -k logins
-w /var/log/faillog -p wa -k logins
-w /var/run/utmp -p wa -k session
-w /var/log/wtmp -p wa -k session
-w /var/log/btmp -p wa -k session
# βββ Privilege escalation βββββββββββββββββββββββββββββββ
-w /usr/bin/sudo -p x -k privileged
-w /bin/su -p x -k privileged
# βββ Kernel module loading ββββββββββββββββββββββββββββββ
-w /sbin/insmod -p x -k modules
-w /sbin/rmmod -p x -k modules
-w /sbin/modprobe -p x -k modules
-a always,exit -F arch=b64 -S init_module,delete_module -k modules
# βββ Time changes βββββββββββββββββββββββββββββββββββββββ
-a always,exit -F arch=b64 -S adjtimex,settimeofday -k time-change
-a always,exit -F arch=b64 -S clock_settime -k time-change
-w /etc/localtime -p wa -k time-change
# βββ DAC permission changes βββββββββββββββββββββββββββββ
-a always,exit -F arch=b64 -S chmod,fchmod,fchmodat -F auid>=1000 -F auid!=4294967295 -k perm_mod
-a always,exit -F arch=b64 -S chown,fchown,fchownat,lchown -F auid>=1000 -F auid!=4294967295 -k perm_mod
-a always,exit -F arch=b64 -S setxattr,lsetxattr,fsetxattr,removexattr -F auid>=1000 -F auid!=4294967295 -k perm_mod
# βββ Unauthorized access attempts βββββββββββββββββββββββ
-a always,exit -F arch=b64 -S open,openat,creat,truncate,ftruncate -F exit=-EACCES -F auid>=1000 -F auid!=4294967295 -k access
-a always,exit -F arch=b64 -S open,openat,creat,truncate,ftruncate -F exit=-EPERM -F auid>=1000 -F auid!=4294967295 -k access
# βββ Mount events βββββββββββββββββββββββββββββββββββββββ
-a always,exit -F arch=b64 -S mount -F auid>=1000 -F auid!=4294967295 -k mounts
# βββ Network env ββββββββββββββββββββββββββββββββββββββββ
-w /etc/hosts -p wa -k system-locale
-w /etc/network/ -p wa -k system-locale
-w /etc/netplan/ -p wa -k system-localetemplates/fail2ban-jail.local.j2
# Managed by Ansible β see linux-server-hardening guide Β§12
[DEFAULT]
bantime = {{ fail2ban_bantime }}
findtime = {{ fail2ban_findtime }}
maxretry = {{ fail2ban_maxretry }}
backend = systemd
ignoreip = {{ fail2ban_ignoreip | join(' ') }}
[sshd]
enabled = truetemplates/unattended-upgrades.conf.j2
// Managed by Ansible β see linux-server-hardening guide Β§13
Unattended-Upgrade::Allowed-Origins {
"${distro_id}:${distro_codename}-security";
"${distro_id}ESMApps:${distro_codename}-apps-security";
"${distro_id}ESM:${distro_codename}-infra-security";
};
Unattended-Upgrade::Automatic-Reboot "{{ 'true' if unattended_auto_reboot else 'false' }}";
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Unattended-Upgrade::MinimalSteps "true";
Unattended-Upgrade::Mail "{{ unattended_mail }}";
Unattended-Upgrade::MailReport "on-change";templates/pam-faillock.j2
Name: Account lockout via pam_faillock
Default: yes
Priority: 0
Auth-Type: Primary
Auth:
[default=die] pam_faillock.so authfail
Auth-Initial:
required pam_faillock.so preauth
Account-Type: Primary
Account:
required pam_faillock.soHow to use it
One-time prerequisites on your control node
# Ansible itself + the collections used in the playbook
sudo apt install -y ansible
ansible-galaxy collection install community.general
ansible-galaxy collection install ansible.posixDry-run first (always)
ansible-playbook linux-hardening.yml --check --diffReads what would change without applying.
Always do this first on a new host, especially with PAM and SSH: verify the diff for /etc/pam.d/su, /etc/securetty, and the sshd drop-in before committing.
Apply the full playbook
ansible-playbook linux-hardening.ymlApply just one section (using tags)
# Only the firewall
ansible-playbook linux-hardening.yml --tags firewall
# Just sysctl + SSH + sudo
ansible-playbook linux-hardening.yml --tags "sysctl,ssh,sudo"
# Everything except auditd (rules can be noisy on first apply)
ansible-playbook linux-hardening.yml --skip-tags auditdMake auditd immutable (only after the ruleset is stable)
# In group_vars/hardened_servers.yml: set auditd_immutable: true
ansible-playbook linux-hardening.yml --tags auditdAfter this, auditctl can no longer modify rules until the next reboot.
Donβt apply this on a new host youβre still tuning.
Opt a host out of automatic security updates
# In group_vars/hardened_servers.yml (or host_vars/<host>.yml for one host):
# unattended_upgrades_enabled: false
ansible-playbook linux-hardening.yml --tags updatesThe playbook will stop and disable the timer on the next run.
Set the flag back to true to re-enable.
Safety considerations
CAUTION
First run on a production host: open a second SSH session before applying, just in case. The sshd drop-in is validated with
sshd -tbefore being installed (so a typo canβt lock you out), but always have an out-of-band path (console, BMC, second open shell) just in case.
IMPORTANT
ssh_password_authdefaults totruefor safety. Switch it tofalseingroup_vars/only after every user on every host has a working public key: otherwise the next run locks out everyone without keys.
INFO
The
wheelgroup is intentionally empty after the playbook runs:subecomes unusable until you add a user. This is deliberate (forcing everyone throughsudo). If you actually want one user to retainsuaccess, add them manually withusermod -aG wheel <user>after the run.
What to do next
- Apply the playbook to a fresh VM, then run
lynis audit systemandoscap evalto measure your CIS L1 coverage. - Add an Ansible CI job (e.g. GitHub Actions or Jenkins) that re-runs the playbook in
--checkmode weekly: anychangedreport = configuration drift on a host, investigate and re-apply.