How to Harden Ubuntu 24.04 With an Ansible Playbook (2026)

Nov 10, 2026 · 13 min · Ajay Kumar

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.

A safe hardening run, left to right 1. Pin and lint ansible-core 2.21.6 ansible-lint 26.11.0 2. Dry run --check --diff read every diff 3. Apply users, keys, sudo before sshd changes 4. Validate sshd -t in handler then restart ssh 5. Re-run expect changed=0 idempotency proof sshd -t fails: play stops fix the drop-in, run again Keep a second SSH session open until step 5 passes. If sshd -t fails, the handler chain stops and ssh is never restarted, so the running daemon keeps its old, working configuration.
The run order used in this guide. Versions are the ones the project was tested with; the stop on a failed sshd -t comes from the handler chain in Step 9.
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