How to Harden Ubuntu 24.04 With an Ansible Playbook (2026)
This guide is for anyone who looks after a handful to a few hundred Ubuntu 24.04 servers and wants their baseline hardening written down as code instead of in someone's shell history. By the end you will have a single, idempotent Ansible playbook that creates admin users with keys and validated sudo rules, locks down SSH, turns on ufw, enables unattended security upgrades, and configures fail2ban, auditd and a few kernel parameters, plus a habit of running it with --check --diff before it touches anything. I tested the project on ansible-core 2.21.6 and ansible-lint 26.11.0 with community.general 14.0.1 and ansible.posix 2.2.2: syntax check, ansible-config validate and the ansible-lint production profile all pass, and every package version below is the one currently published for Ubuntu 24.04 (Noble). You need Python 3.12 or newer on your workstation, SSH key access to the servers as a sudo-capable user, and a console or out-of-band way in, in case you lock yourself out. If you are wondering whether Ansible is still worth learning for this, I made that case separately in Is Ansible Still Relevant in 2026?.
Step 1: Install a pinned ansible-core and the collections
Do not use the distro package. Ubuntu 24.04's ansible-core is 2.16.3 (Launchpad), which is long out of upstream support. ansible-core 2.22.0 shipped on 2 November 2026 but dropped Python 3.12 on the controller, per its changelog, so I pin 2.21.6, the version the community Ansible 14.6.0 package is built on and which supports controller Python 3.12 to 3.14 according to the support matrix. If your workstation already has Python 3.13, 2.22.0 works with the same playbook.
mkdir ubuntu-hardening && cd ubuntu-hardening
mkdir -p inventory/group_vars templates files
# Pinned tooling in a project-local venv (uv; plain python -m venv works too)
uv venv --python 3.12 .venv
uv pip install --python .venv/bin/python "ansible-core==2.21.6" "ansible-lint==26.11.0"
source .venv/bin/activate
ansible --version | head -1
# ansible [core 2.21.6]
Pin the collections too. community.general 14 requires ansible-core 2.19 or newer (its meta/runtime.yml says so), and ansible.posix provides authorized_key.
# requirements.yml
---
collections:
- name: community.general
version: "14.0.1"
- name: ansible.posix
version: "2.2.2"
# ansible.cfg
[defaults]
inventory = inventory/hosts.yml
collections_path = ./collections
host_key_checking = True
stdout_callback = default
callback_result_format = yaml
retry_files_enabled = False
[ssh_connection]
pipelining = True
ansible-galaxy collection install -r requirements.yml -p ./collections
ansible-config validate -t all
# All configurations seem valid!
The -t all matters on 2.21: without it, ansible-config validate reports [ssh_connection] as an unknown section, which I hit while testing. ansible-core 2.22 makes -t all the default.
Step 2: Write the inventory and group variables
Keep host addresses in the inventory and policy in group_vars. The addresses below are documentation ranges; replace them with yours.
# inventory/hosts.yml
---
all:
children:
ubuntu_servers:
hosts:
web1:
ansible_host: 203.0.113.10
web2:
ansible_host: 203.0.113.11
vars:
ansible_user: ubuntu
ansible_python_interpreter: /usr/bin/python3
# inventory/group_vars/ubuntu_servers.yml
---
admin_users:
- name: ajay
pubkey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleKeyReplaceMe ajay@laptop"
# Must include ansible_user, or the playbook locks itself out
ssh_allow_users: "ubuntu ajay"
ufw_allowed_tcp_ports: [80, 443]
auto_reboot: false
auto_reboot_time: "04:00"
Check the inventory parses, then check you can reach the hosts:
ansible-inventory --graph
# @all:
# |--@ungrouped:
# |--@ubuntu_servers:
# | |--web1
# | |--web2
ansible ubuntu_servers -m ansible.builtin.ping
# Expected for each host: web1 | SUCCESS => { "changed": false, "ping": "pong" }
Step 3: Install packages, then create users, keys and sudo first
Order is the whole safety strategy. The play creates the admin users, installs their keys and grants sudo before it changes a single SSH setting, so if the SSH step goes wrong you still have a working account. The playbook starts with an assertion so it never runs on the wrong release.
# harden.yml (part 1 of 3)
---
- name: Harden Ubuntu 24.04 servers
hosts: ubuntu_servers
become: true
pre_tasks:
- name: Refuse to run on anything but Ubuntu 24.04
ansible.builtin.assert:
that:
- ansible_facts['distribution'] == 'Ubuntu'
- ansible_facts['distribution_version'] == '24.04'
fail_msg: "This playbook targets Ubuntu 24.04 only"
tasks:
- name: Install hardening packages
ansible.builtin.apt:
name:
- ufw
- fail2ban
- auditd
- unattended-upgrades
state: present
update_cache: true
cache_valid_time: 3600
- name: Create admin users
ansible.builtin.user:
name: "{{ item.name }}"
groups: sudo
append: true
shell: /bin/bash
password_lock: true # key-only accounts
loop: "{{ admin_users }}"
loop_control:
label: "{{ item.name }}"
- name: Install admin SSH keys
ansible.posix.authorized_key:
user: "{{ item.name }}"
key: "{{ item.pubkey }}"
exclusive: true # removes any key not listed here
loop: "{{ admin_users }}"
loop_control:
label: "{{ item.name }}"
- name: Passwordless sudo for admin users (validated by visudo)
ansible.builtin.copy:
dest: "/etc/sudoers.d/90-{{ item.name }}"
content: "{{ item.name }} ALL=(ALL:ALL) NOPASSWD: ALL\n"
owner: root
group: root
mode: "0440"
validate: /usr/sbin/visudo -cf %s
loop: "{{ admin_users }}"
loop_control:
label: "{{ item.name }}"
The validate line is the one that saves you: Ansible writes a temp file, runs visudo -cf on it, and only moves it into place if it parses. A broken sudoers file is otherwise a fast way to lose root on every host at once. Passwordless sudo is a deliberate trade-off for key-only accounts; drop NOPASSWD: and set a password if your policy needs a second factor at sudo time. Ubuntu keeps UsePAM yes, so a locked password does not block public-key logins.
Step 4: Harden SSH with a drop-in that cloud-init cannot override
Ubuntu 24.04 ships OpenSSH 9.6p1 (Launchpad), and its sshd_config includes /etc/ssh/sshd_config.d/*.conf before anything else. The rule that trips people up is in sshd_config(5): for each keyword, the first obtained value is used. cloud-init writes its password setting to 50-cloud-init.conf in that directory (cloud-init source), so a file named 99-hardening.conf loses to it. Name yours 00-.
{# templates/00-hardening.conf.j2 #}
# Managed by Ansible. Files in sshd_config.d load in lexical order and the
# FIRST value wins, so 00- beats 50-cloud-init.conf.
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
MaxAuthTries 3
LoginGraceTime 30
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding no
ClientAliveInterval 300
ClientAliveCountMax 2
AllowUsers {{ ssh_allow_users }}
# harden.yml (part 2 of 3, continues the tasks list)
- name: Write SSH hardening drop-in
ansible.builtin.template:
src: 00-hardening.conf.j2
dest: /etc/ssh/sshd_config.d/00-hardening.conf
owner: root
group: root
mode: "0644"
notify: Validate and restart ssh
The handler (Step 9) runs sshd -t against the full configuration before restarting, so a typo fails the play instead of killing the daemon. I deliberately leave the port at 22. On 24.04, sshd is socket-activated and the listening port is generated from sshd_config by a systemd generator, per Ubuntu's announcement, so a port change also needs systemctl daemon-reload and a restart of ssh.socket. That is a separate, riskier change; do it on its own. The SSH-only settings above are explained in more depth in SSH Security Best Practices 2026.
Step 5: Turn on ufw without cutting your own session
- name: Firewall default deny incoming
community.general.ufw:
default: deny
direction: incoming
- name: Firewall default allow outgoing
community.general.ufw:
default: allow
direction: outgoing
- name: Rate-limit SSH before enabling ufw
community.general.ufw:
rule: limit
port: "22"
proto: tcp
comment: ssh
- name: Allow application ports
community.general.ufw:
rule: allow
port: "{{ item | string }}"
proto: tcp
loop: "{{ ufw_allowed_tcp_ports }}"
- name: Enable ufw
community.general.ufw:
state: enabled
logging: "low"
The SSH rule comes before state: enabled, always. limit rejects a source address that opens six or more connections within 30 seconds, which takes the edge off brute-force noise before fail2ban sees it. port is an alias of the module's to_port option in community.general 14.
Step 6: Enable unattended security upgrades
Ubuntu 24.04 installs unattended-upgrades (2.9.1 on Noble), and the security pocket is in its allowed origins by default. You only need to switch the periodic job on and set your local policy in a file that package upgrades will not overwrite.
# files/20auto-upgrades
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
{# templates/52unattended-upgrades-local.j2 #}
// Managed by Ansible
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Unattended-Upgrade::Automatic-Reboot "{{ auto_reboot | lower }}";
Unattended-Upgrade::Automatic-Reboot-Time "{{ auto_reboot_time }}";
- name: Enable unattended upgrades
ansible.builtin.copy:
src: 20auto-upgrades
dest: /etc/apt/apt.conf.d/20auto-upgrades
owner: root
group: root
mode: "0644"
- name: Configure unattended upgrades
ansible.builtin.template:
src: 52unattended-upgrades-local.j2
dest: /etc/apt/apt.conf.d/52unattended-upgrades-local
owner: root
group: root
mode: "0644"
I leave automatic reboots off by default and let a maintenance job or a human reboot hosts that have /var/run/reboot-required. Flip auto_reboot per group for stateless hosts behind a load balancer.
Step 7: Configure fail2ban for the systemd journal
Make sure your servers have the current Noble package, 1.0.2-3ubuntu0.1. The original 1.0.2-3 in the 24.04 release pocket failed under Python 3.12 because asynchat and asyncore were removed; the update fixes it (changelog, LP: #2055114). The apt task with update_cache installs the fixed version on a fresh host.
# templates/jail.local.j2
# Managed by Ansible
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
backend = systemd
[sshd]
enabled = true
- name: Configure fail2ban
ansible.builtin.template:
src: jail.local.j2
dest: /etc/fail2ban/jail.local
owner: root
group: root
mode: "0644"
notify: Restart fail2ban
Ubuntu's own jail.d/defaults-debian.conf already sets backend = systemd and the nftables ban action; jail.local makes the timings explicit and survives package upgrades.
Step 8: Add auditd rules and kernel parameters
# files/hardening.rules -> /etc/audit/rules.d/hardening.rules
-w /etc/passwd -p wa -k identity
-w /etc/group -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/sudoers -p wa -k scope
-w /etc/sudoers.d/ -p wa -k scope
-w /etc/ssh/sshd_config -p wa -k sshd
-w /etc/ssh/sshd_config.d/ -p wa -k sshd
-a always,exit -F arch=b64 -S execve -F euid=0 -F auid>=1000 -F auid!=unset -k root_cmds
# files/99-hardening.conf -> /etc/sysctl.d/99-hardening.conf
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.all.rp_filter = 1
net.ipv4.tcp_syncookies = 1
net.ipv6.conf.all.accept_redirects = 0
kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1
- name: Install audit rules
ansible.builtin.copy:
src: hardening.rules
dest: /etc/audit/rules.d/hardening.rules
owner: root
group: root
mode: "0640"
notify: Load audit rules
- name: Kernel network hardening
ansible.builtin.copy:
src: 99-hardening.conf
dest: /etc/sysctl.d/99-hardening.conf
owner: root
group: root
mode: "0644"
notify: Reload sysctl
- name: Make sure services are enabled and running
ansible.builtin.systemd_service:
name: "{{ item }}"
enabled: true
state: started
loop:
- fail2ban
- auditd
- unattended-upgrades
The execve rule logs every command a logged-in human runs as root, keyed root_cmds, which is what an auditor or an incident responder asks for first. Search it later with ausearch -k root_cmds.
Step 9: Add handlers, dry-run with --check --diff, then apply
# harden.yml (part 3 of 3, at the same indent as tasks:)
handlers:
- name: Validate and restart ssh
ansible.builtin.command: /usr/sbin/sshd -t
changed_when: false
notify: Restart ssh
- name: Restart ssh
ansible.builtin.systemd_service:
name: ssh # the unit is ssh, not sshd, on Ubuntu
state: restarted
- name: Restart fail2ban
ansible.builtin.systemd_service:
name: fail2ban
state: restarted
- name: Load audit rules
ansible.builtin.command: /usr/sbin/augenrules --load
changed_when: true
- name: Reload sysctl
ansible.builtin.command: /usr/sbin/sysctl --system
changed_when: true
The flow I follow on every run is in the figure below.
ansible-playbook harden.yml --syntax-check
# playbook: harden.yml
# Dry run against one host first, showing file diffs
ansible-playbook harden.yml --check --diff --limit web1
# Apply to one host, then the rest
ansible-playbook harden.yml --limit web1
ansible-playbook harden.yml
# Run it again: a clean baseline reports changed=0 for every host
ansible-playbook harden.yml
Before you close your existing session, open a new one as an admin user (ssh ajay@203.0.113.10) and confirm sudo -v works. Then test what should now fail: ssh -o PubkeyAuthentication=no ubuntu@203.0.113.10 should be refused with Permission denied (publickey).
Step 10: Lint it and keep it in CI
ansible-lint harden.yml
# Passed: 0 failure(s), 0 warning(s) in 1 files processed of 1 encountered.
# Last profile that met the validation criteria was 'production'.
That is the real output from the project in this guide. In CI I run ansible-lint on every pull request and a scheduled --check --diff against production, so manual edits on a server show up as a diff in a report, not as a surprise during an incident.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
PasswordAuthentication still yes in sshd -T output |
Your drop-in sorts after 50-cloud-init.conf, and the first value wins |
Name it 00-hardening.conf; confirm with sudo sshd -T | grep -i passwordauth |
| Locked out after the SSH change | AllowUsers does not include ansible_user, or the key was never installed |
Use the console, fix ssh_allow_users, keep the user and key tasks before the SSH task |
Destination directory /etc/fail2ban does not exist in --check on a new host |
Check mode did not actually install the package | Expected on the first dry run; apply once, or install packages in a separate run first |
Failed to find required executable "ufw" in --check |
Same cause: ufw not installed yet | Same fix; check mode is only reliable on hosts already at baseline |
Found unknown section 'ssh_connection' from ansible-config validate |
ansible-core 2.21 validates base settings only by default | Run ansible-config validate -t all (default from 2.22) |
fail2ban fails with No module named 'asynchat' |
Host still on the original 1.0.2-3 package | Upgrade to 1.0.2-3ubuntu0.1 with apt; update_cache: true handles it |
Play reports changed on every run |
A task without changed_when, or content that differs per run |
Add changed_when, template stable content, and compare with --diff |
visudo validation fails on the sudoers task |
A syntax error or missing newline in the rule | Keep the trailing \n; test with visudo -cf locally |
couldn't resolve module/action 'community.general.ufw' |
Collections installed elsewhere or not at all | Run ansible-galaxy collection install -r requirements.yml -p ./collections and check collections_path |
FAQ
Is this playbook CIS compliant?
No. It covers the controls that matter most on an internet-facing server, but the CIS Ubuntu 24.04 benchmark has hundreds of checks. If you need formal compliance, use the benchmark (or Ubuntu Pro's Ubuntu Security Guide) as the source of truth and treat this playbook as a starting baseline.
Should I use ansible-core 2.22 instead of 2.21?
If your controller has Python 3.13 or newer, yes, and the playbook runs unchanged. 2.22 adds automatic secret masking in output, which is welcome in CI logs. On Python 3.12, stay on 2.21.6, which is supported until November 2027.
Why not just put all this in cloud-init?
cloud-init runs once at first boot. It is a good place to create the first user and key, but it will not notice if someone re-enables password logins in six months. An Ansible run on a schedule with --check --diff will.
Can I change the SSH port with this playbook?
You can, but treat it as a separate change. On Ubuntu 24.04 the port is handled by ssh.socket, so you need a daemon reload, a socket restart, a ufw rule for the new port, and a second session open while you test.
What I check first on any new server, before running anything, is whether I have a second way in: a cloud console, serial console or IPMI. Then I run the dry run against one host, read every diff, apply to that host, re-run until it reports changed=0, and only then roll it to the group. If you want the deeper reasoning behind the SSH settings or a fix for key errors along the way, see how to fix SSH permission denied (publickey). Official docs worth keeping open: the ansible-core 2.22 changelog, the Ansible release and support matrix, sshd_config(5) and ansible-lint.
Related: Is Ansible Still Relevant in 2026? ansible-core 2.22 vs Terraform, SSH Security Best Practices 2026: OpenSSH 10.6, ML-KEM and FIDO2 Keys and How to Fix SSH Permission Denied (publickey): Causes and Fixes (2026).
Spot an error or something out of date? Email me and I will fix it and log it on the corrections page.
I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.
Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.
Related
How to Expose a Local Service With Cloudflare Tunnel (2026)
Step-by-step Cloudflare Tunnel guide for cloudflared 2026.10: install, create a remotely managed tunnel, add a public hostname, run it as a systemd service, in Docker Compose or on Kubernetes, put Cloudflare Access in front, and fix error 1033, 502 and 1016.
12 minNov 10, 2026How to Set Up a WireGuard VPN Server on Ubuntu 24.04 (2026)
Step-by-step WireGuard server setup on Ubuntu 24.04: generate keys, write wg0.conf, enable IP forwarding, NAT with nftables or ufw, client configs and QR codes, split vs full tunnel, wg-quick under systemd, and fixes for handshake did not complete.
12 minOct 31, 2026How to Deploy to Cloud Run with GitHub Actions: Step-by-Step (2026)
Deploy a container to Google Cloud Run from GitHub Actions in 2026 with no service account keys: Artifact Registry, Workload Identity Federation with auth@v3, deploy-cloudrun@v3, Secret Manager, tagged canary revisions, rollbacks, custom domains, and fixes for the PORT 8080 startup error.
15 min