DEV Community

DevKit
DevKit

Posted on

Hardening a fresh Ubuntu 24.04 server with Ansible without locking yourself out

Written with AI assistance; the role itself was built and lint-checked by the author's AI assistant, which works for a DevOps engineer.

Hardening a server is easy. Hardening it and still being able to log in afterwards is the part people get wrong. Most lockouts I have seen described come from a few causes: SSH password login turned off before a key was installed, a firewall enabled before the SSH rule existed, or an sshd config with a typo that stops the daemon from restarting.

This article walks through an Ansible approach for a fresh Ubuntu 24.04 VPS that avoids those three. Everything below is free to copy. The snippets are short adaptations of an Ansible role I wrote, so you can apply the ideas in your own playbook.

The order is the safety mechanism

The role's main.yml runs the parts in this order:

users → ufw → ssh → fail2ban → updates → sysctl → auditd → services → banner
Enter fullscreen mode Exit fullscreen mode

The reasoning:

  1. Users first. Create the admin account and install its public key. Until that exists, nothing else should touch SSH.
  2. ufw second. Allow the SSH port, then enable the firewall.
  3. SSH third. Only now apply the strict config: no passwords, no root login. The key and the firewall hole already exist.
  4. Everything else. fail2ban, updates, sysctl and auditd cannot lock you out in the usual way, so they come last.

In Ansible terms it is just the order of your import_tasks:

- name: Users
  ansible.builtin.import_tasks: users.yml
  tags: [users]

- name: Ufw firewall
  ansible.builtin.import_tasks: ufw.yml
  tags: [ufw]

- name: SSH configuration
  ansible.builtin.import_tasks: ssh.yml
  tags: [ssh]
Enter fullscreen mode Exit fullscreen mode

Tags let you re-run one part (--tags ssh) later without replaying the whole thing.

One habit matters more than any task: keep your current SSH session open, and test a login from a second terminal before you close the first.

Refuse to run if there is no key

The cheapest protection is an assertion. If password login is going to be disabled and you forgot to provide a public key, fail before changing anything:

- name: SSH | Administrator key required before disabling passwords
  ansible.builtin.assert:
    that:
      - hardening_admin_ssh_keys | length > 0
    fail_msg: >-
      Password login would be disabled, but hardening_admin_ssh_keys
      is empty. Set a public key or allow password authentication.
    quiet: true
  when: not (hardening_ssh_password_authentication | bool)
Enter fullscreen mode Exit fullscreen mode

The role also asserts that, if you set AllowUsers, the admin user is in the list. Both checks are about the same idea: catch the lockout in the playbook, not on the server.

The admin user itself is the standard pairing of ansible.builtin.user and ansible.posix.authorized_key, with sudo in its groups. Decide on sudo before you run: if the account has neither a password hash nor passwordless sudo, sudo will not work for it. The role defaults to no NOPASSWD, so you either supply a password hash or opt in explicitly.

ufw: rule first, enable last

With community.general.ufw, the sequence matters. Add the SSH allow rule, set policies, and enable at the very end:

- name: UFW | Allow SSH before enabling the firewall
  community.general.ufw:
    rule: allow
    port: "{{ ssh_port | string }}"
    proto: tcp
    from_ip: "{{ ssh_allowed_from | default('any') }}"
    comment: ssh

- name: UFW | Default incoming policy
  community.general.ufw:
    default: deny
    direction: incoming

- name: UFW | Enable the firewall
  community.general.ufw:
    state: enabled
Enter fullscreen mode Exit fullscreen mode

If you restrict from_ip to your own address, be sure that address is stable. A home connection with a changing IP is a good way to lock yourself out slowly.

Key-only SSH, validated before it is written

Ubuntu 24.04 reads /etc/ssh/sshd_config.d/*.conf. A drop-in is much safer than editing sshd_config, because you only own one small file. The role names it 10-hardening.conf. The 10- prefix matters: in sshd the first value found wins, and cloud images often ship 50-cloud-init.conf with PasswordAuthentication yes. Your file must sort before it.

The key trick is the validate: parameter of the template module. Ansible renders to a temporary file, runs the validator on it, and only moves it into place if it passes:

- name: SSH | Privilege separation directory (needed by sshd -t)
  ansible.builtin.file:
    path: /run/sshd
    state: directory
    mode: "0755"

- name: SSH | Write hardening drop-in
  ansible.builtin.template:
    src: sshd-hardening.conf.j2
    dest: /etc/ssh/sshd_config.d/10-hardening.conf
    owner: root
    group: root
    mode: "0644"
    validate: /usr/sbin/sshd -t -f %s
  notify: Reload ssh configuration
Enter fullscreen mode Exit fullscreen mode

A broken file never reaches disk, so a typo cannot take down the daemon. The /run/sshd task is there because sshd -t can complain if that directory is missing on a fresh box.

The template itself is plain directives: PermitRootLogin no, PasswordAuthentication no, KbdInteractiveAuthentication no, PubkeyAuthentication yes, MaxAuthTries, X11Forwarding no, LogLevel VERBOSE. The handler reloads sshd rather than restarting it, and existing sessions stay alive.

Two gotchas worth knowing:

  • TCP forwarding. The role leaves AllowTcpForwarding at yes by default so existing tunnels keep working. CIS-style guidance recommends no; flip it if you do not use tunnels.
  • Changing the port on 24.04. sshd is socket-activated through ssh.socket, and the Port directive is ignored in that mode. The role writes a ssh.socket.d override for non-default ports. Changing the port is where most of the remaining lockout risk lives, since the old port is not closed in ufw automatically. If you do not need a custom port, leave it at 22 and let fail2ban handle the noise.

fail2ban

On a public IP, SSH brute-force attempts start quickly. fail2ban is a light answer. The role writes one jail, using the systemd journal as the backend (Ubuntu 24.04 does not need a log file for this):

- name: fail2ban | sshd jail
  ansible.builtin.copy:
    dest: /etc/fail2ban/jail.d/sshd-hardening.local
    owner: root
    group: root
    mode: "0644"
    content: |
      [DEFAULT]
      ignoreip = 127.0.0.1/8 ::1 {{ my_office_ip }}

      [sshd]
      enabled = true
      port = {{ ssh_port }}
      backend = systemd
      maxretry = 5
      findtime = 10m
      bantime = 1h
Enter fullscreen mode Exit fullscreen mode

Put your own static IP in ignoreip if you have one. A typo'd key on a few retries should not ban the one person who is allowed in.

Unattended security upgrades, security only

Automatic patching is probably the highest value-per-line item here. The role drops a file that loads after 50unattended-upgrades, clears the origin list with #clear, and re-adds only the security pockets:

- name: Updates | Security-only unattended upgrades
  ansible.builtin.copy:
    dest: /etc/apt/apt.conf.d/52hardening-unattended-upgrades
    owner: root
    group: root
    mode: "0644"
    content: |
      APT::Periodic::Update-Package-Lists "1";
      APT::Periodic::Unattended-Upgrade "1";
      #clear Unattended-Upgrade::Allowed-Origins;
      Unattended-Upgrade::Allowed-Origins {
              "${distro_id}:${distro_codename}-security";
      };
      Unattended-Upgrade::Automatic-Reboot "false";
Enter fullscreen mode Exit fullscreen mode

The role also allows the ESM security origins; I left them out here for brevity. Automatic reboot is off by default. If a kernel update needs a reboot, the server waits for you. Turn it on (with a reboot time) only when you are comfortable with the host restarting unattended.

sysctl and auditd

Neither can lock you out of SSH, but both can break workloads, so read what you apply.

For sysctl, the role writes a file to /etc/sysctl.d/60-hardening.conf with the usual network and kernel switches: reverse-path filtering, no ICMP redirects, no source routing, martian logging, SYN cookies, kernel.kptr_restrict, kernel.dmesg_restrict, fs.protected_symlinks, and so on. You can express that with the ansible.posix.sysctl module:

- name: Sysctl | Network and kernel hardening
  ansible.posix.sysctl:
    name: "{{ item.key }}"
    value: "{{ item.value }}"
    sysctl_file: /etc/sysctl.d/60-hardening.conf
    reload: true
  loop: "{{ sysctl_params | dict2items }}"
  vars:
    sysctl_params:
      net.ipv4.conf.all.rp_filter: 1
      net.ipv4.conf.all.accept_redirects: 0
      net.ipv4.tcp_syncookies: 1
      kernel.dmesg_restrict: 1
      fs.protected_symlinks: 1
Enter fullscreen mode Exit fullscreen mode

The one that bites: the role's base set includes net.ipv4.ip_forward = 0. That breaks Docker, Kubernetes, VPNs and anything that routes. The role has an override variable for that case. If you run containers, remove forwarding from your list before applying.

For auditd, the role installs baseline rules in /etc/audit/rules.d/: watches on /etc/passwd, /etc/shadow, /etc/sudoers, /etc/ssh/sshd_config.d, login files, time changes, and hostname/network files, each with a key such as identity or sshd:

- name: auditd | Baseline watches
  ansible.builtin.copy:
    dest: /etc/audit/rules.d/60-hardening.rules
    owner: root
    group: root
    mode: "0640"
    content: |
      -w /etc/passwd -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.d -p wa -k sshd
Enter fullscreen mode Exit fullscreen mode

An immutable-rules option (-e 2) exists but is off by default, because after that, any rule change needs a reboot. Enable it deliberately.

Run it safely

ansible-playbook -i inventory.ini site.yml --check --diff
ansible-playbook -i inventory.ini site.yml
Enter fullscreen mode Exit fullscreen mode

Dry-run first. Be aware that --check is less informative on a brand-new host: a package that is not installed yet makes later tasks (like ufw rules) unable to run in check mode, and the role has to tolerate that. Set serial: 1 so a failure on the first server stops the rollout. Then log in from a second terminal.

What this is not

Be honest about the limits:

  • It is not a CIS compliance audit. It is a pragmatic subset of CIS-style recommendations, a "CIS-lite" baseline. If you need a benchmark score or auditor evidence, use a proper tool and a proper process.
  • Not every environment was tested. The role targets Ubuntu 24.04 only; running on another OS is a flag you set at your own risk. I cannot claim that ufw or the rest behave the same on every cloud provider and image. Test on a throwaway VM from your provider first.
  • Cloud security groups are still needed. Many providers have their own network firewall in front of the VM. ufw runs on the host in addition, so a port must be open in both places. Opening a port in ufw alone will not make it reachable.
  • Hardening is not monitoring or backups. None of this tells you when the disk is full or the service is down.

The full role, if you want it

Everything above is enough to build your own. If you would rather not assemble it, the complete role includes per-part tags and hardening_<part>_enabled switches, a rollback.yml, a Makefile (check, apply, rollback, lint) and passes yamllint and ansible-lint. It is sold for 11 USDT via the Telegram shop. There is also a free monitoring kit for a single VPS.

Top comments (0)