Subscribe

Automate Ubuntu 24.04 hardening: sshd, apt, Ansible

A brass toolkit chest with one empty coral socket, house style.

Ubuntu 24.04.4 LTS shipped on 12 February 2026. Canonical’s announce mail is the date I am using. Launchpad bug 2088207, opened November 2024, is the failure: PasswordAuthentication no in /etc/ssh/sshd_config loses to /etc/ssh/sshd_config.d/50-cloud-init.conf when that drop-in says yes. A hardening playbook that only edits the stock file will ship a password door on the next image.

The control is two files you can name, one boot-time, one converge-time, plus a timer that installs Tuesday’s OpenSSH without you being on the box. Pair TLS after 443 answers with the TLS guide. Pair the response headers with the headers guide.

Why a hand edit does not replay

24.04 is still the image a lot of VPS panels hand you in August 2026. Standard security maintenance on Canonical’s cycle runs through May 2029. You do not need 26.04 to automate a web host. You need a written sshd, a security pocket that applies itself, and a tool that will put those files back after someone clicks through the installer again.

OpenSSH on noble reads every *.conf under /etc/ssh/sshd_config.d/ in lexical order, usually from an Include at the top of the stock file, and keeps the first keyword. That is why 00-hardening.conf beats 50-cloud-init.conf, and why 99-hardening.conf loses. The Ubuntu host page walks that file by hand. This page is how it gets onto the next ten boxes without a ticket.

Socket activation is the other 24.04 surprise. ssh.socket owns the listen port. After you write auth keywords, systemctl restart ssh is the unit that reloads them. If a later play changes ListenAddress, restart the socket too. Check with systemctl is-active ssh.socket.

cloud-init writes the first sshd drop-in

cloud-init runs once on first boot from user-data you already pass to the image. The module that owns password SSH is ssh_pwauth. Set it false so the generator does not emit PasswordAuthentication yes. Then write 00-hardening.conf so a host that never gets a playbook still boots closed. Ansible will overwrite that path later with identical keywords.

#cloud-config
# user-data for a noble LTS image you launch
hostname: app-a
ssh_pwauth: false
package_update: true
packages:
  - unattended-upgrades

users:
  - name: deploy
    groups: [sudo]
    sudo: ALL=(ALL) NOPASSWD:ALL
    shell: /bin/bash
    ssh_authorized_keys:
      - ssh-ed25519 AAAAREPLACE_WITH_YOUR_DEPLOY_KEY deploy@laptop

write_files:
  - path: /etc/ssh/sshd_config.d/00-hardening.conf
    owner: root:root
    permissions: "0644"
    content: |
      PermitRootLogin no
      PasswordAuthentication no
      KbdInteractiveAuthentication no
      PubkeyAuthentication yes
      AllowUsers deploy
      AuthenticationMethods publickey
  - path: /etc/apt/apt.conf.d/20auto-upgrades
    owner: root:root
    permissions: "0644"
    content: |
      APT::Periodic::Update-Package-Lists "1";
      APT::Periodic::Unattended-Upgrade "1";
  - path: /etc/apt/apt.conf.d/50unattended-upgrades
    owner: root:root
    permissions: "0644"
    content: |
      Unattended-Upgrade::Allowed-Origins {
          "${distro_id}:${distro_codename}-security";
      };
      Unattended-Upgrade::Automatic-Reboot "false";

runcmd:
  - [sshd, -t]
  - [systemctl, restart, ssh]
  - [systemctl, enable, --now, unattended-upgrades]

Identifiers stay deploy, 00-hardening.conf, 20auto-upgrades, and 50unattended-upgrades. Replace the ed25519 line with a key you minted for this role. Do not paste a password into chpasswd. NOPASSWD on sudo is acceptable only because password SSH is off and the key is the authenticator. If you want a sudo password, set one out of band and drop NOPASSWD. Do not put that password in user-data. User-data often lands in the instance metadata store.

I opened the cloud-init module docs for ssh_pwauth. The value is a boolean, not a string you hope sshd parses. false is the one that matches this page.

Ansible recopies 00-hardening.conf on every run

cloud-init is first boot. Drift is the next six months. Ansible is the converge. Use a supported ansible-core on the controller. The project’s 2.18 line hit End of Life in May 2026. If ansible --version still prints 2.18 in August 2026, plan the jump. The modules below are ansible.builtin plus ansible.posix.authorized_key. They do not depend on an EOL controller feature.

The playbook writes the identical keywords. It does not edit the 200-line stock file. It does not delete 50-cloud-init.conf unless you have decided that file must go. Winning on sort order is enough, and it survives a cloud-init rerun that puts the 50- file back.

# harden.yml
- name: Ubuntu 24.04 host hardening
  hosts: web
  become: true
  become_user: root
  vars:
    harden_user: deploy
  tasks:
    - name: Install unattended-upgrades
      ansible.builtin.apt:
        name: unattended-upgrades
        state: present
        update_cache: true

    - name: Install deploy authorized key
      ansible.posix.authorized_key:
        user: "{{ harden_user }}"
        key: "{{ lookup('file', 'keys/deploy.pub') }}"
        exclusive: true
        state: present

    - name: Write sshd drop-in
      ansible.builtin.copy:
        dest: /etc/ssh/sshd_config.d/00-hardening.conf
        owner: root
        group: root
        mode: "0644"
        content: |
          PermitRootLogin no
          PasswordAuthentication no
          KbdInteractiveAuthentication no
          PubkeyAuthentication yes
          AllowUsers deploy
          AuthenticationMethods publickey
        validate: sshd -t -f /etc/ssh/sshd_config
      notify: Restart ssh

    - name: Enable unattended-upgrades timer
      ansible.builtin.service:
        name: unattended-upgrades
        state: started
        enabled: true

  handlers:
    - name: Restart ssh
      ansible.builtin.service:
        name: ssh
        state: restarted

Identifiers stay harden.yml, harden_user, keys/deploy.pub, and 00-hardening.conf. exclusive: true on authorized_key drops leftover laptop keys. That is what you want on prod. It is fatal if the play runs with the wrong public file. Keep keys/deploy.pub in the same repo as the play, and keep the private key off the controller disk that developers share. The Ansible vault page is how a secret file gets encrypted. This play has no vaulted password, on purpose.

validate: sshd -t -f /etc/ssh/sshd_config is the lock that stops a bad drop-in from taking the listener down. sshd still merges the directory. Testing the main file is enough to parse the include. Keep a second session open the first time you roll a new image.

unattended-upgrades as a file you own

sshd hardening is a snapshot. CVE-2024-6387, the signal-handler race Qualys published on 1 July 2024, is why the next object in git is the apt config, not a new theme for nginx. The security origin has to be on. Automatic reboot stays off unless you have a second host and a drain.

# templates/50unattended-upgrades.j2
Unattended-Upgrade::Allowed-Origins {
    "${distro_id}:${distro_codename}-security";
};
Unattended-Upgrade::Automatic-Reboot "false";
Unattended-Upgrade::Automatic-Reboot-Time "04:00";
    - name: Write 20auto-upgrades
      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";

    - name: Write 50unattended-upgrades
      ansible.builtin.template:
        src: 50unattended-upgrades.j2
        dest: /etc/apt/apt.conf.d/50unattended-upgrades
        owner: root
        group: root
        mode: "0644"

Identifiers stay 20auto-upgrades, 50unattended-upgrades.j2, and the -security origin. Do not add ${distro_id}:${distro_codename}-updates unless you have decided that proposed pocket is in scope. Security is the pocket that closed the sshd race. A pin on openssh-server is how that race lives into 2026. Do not hold that pin in Ansible apt unless you are mid-incident.

Leave Automatic-Reboot false on a single VPS. A kernel that reboots at 04:00 without a drain is an outage. The time key above is inert until you flip the boolean. When you have two hosts, flip it on the play for the idle one.

Prove sshd and apt after both tools

You are not scanning the internet. You are proving the host you just launched matches the files in git.

  1. From a second console, sshd -T prints permitrootlogin no and passwordauthentication no.
  2. ls /etc/ssh/sshd_config.d/ shows 00-hardening.conf. If 50-cloud-init.conf exists, it must not win those two keywords.
  3. systemctl is-enabled unattended-upgrades is enabled. The journal shows a recent run, or a next timer.
  4. A login as root with a key you did not authorize fails. A login as deploy with keys/deploy.pub works. Password auth is refused.
# After cloud-init or after: ansible-playbook -i inventories/prod/hosts.ini harden.yml
sudo sshd -T | grep -E 'permitrootlogin|passwordauthentication|kbdinteractiveauthentication|allowusers'
# Expect: permitrootlogin no
#         passwordauthentication no
#         kbdinteractiveauthentication no
#         allowusers deploy

ls -l /etc/ssh/sshd_config.d/00-hardening.conf
systemctl is-enabled unattended-upgrades
systemctl list-timers | grep apt-daily

# From your laptop, against the new host only.
ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no deploy@app-a.example
# Expect: Permission denied

ssh -i ~/.ssh/deploy deploy@app-a.example
# Expect: a shell

Inventory isolation belongs on the Ansible page: prod hosts are a dedicated -i, not a laptop hosts.ini with [dev] and [prod] in one file. This play should never be the one that also copies /etc/webapp/app.env from extra-vars.

First boot writes the files. The play writes them again. sshd -T is the proof, not the installer checkbox.
LAUNCH
  user-data
    ssh_pwauth: false
    write 00-hardening.conf
    write 20auto-upgrades + 50unattended-upgrades
    user deploy + ed25519

CONVERGE
  ansible-playbook harden.yml
    copy the same 00-hardening.conf
    apt unattended-upgrades
    authorized_key exclusive

PROOF
  sshd -T   passwordauthentication no
  timer     unattended-upgrades enabled
  ssh       deploy key yes, password no, root no

What you should not encode

Do not encode a port change as the only sshd task. Moving off 22 cuts bot noise. It does not replace the drop-in. If you change the port, allow it from the admin net only, and keep the same four auth lines.

Do not encode ufw allow 22 from 0.0.0.0/0 and call that a firewall play. The Ubuntu host page is deny incoming, 443 from the world, 22 from an address you can name. Put that in a second role once the key login works. A play that opens 22 to the world before the drop-in lands is a race on first boot. cloud-init order above writes sshd before you advertise the address.

Do not encode GrapheneX, a vendor “hardening suite,” or a 200-line CIS dump you have not read. Three files and a timer are the 2026 bar on this URL. A CIS role is fine after you can explain every line it flips. It is not a substitute for sshd -T.

Do not put the deploy private key in the role. lookup('file', 'keys/deploy.pub') is the public half. The private half stays on the laptop or the CI secret store that already runs the play.

Questions we keep getting

Should I delete 50-cloud-init.conf in the playbook?

Only if you have seen it win a keyword you care about after 00-hardening.conf is in place. First-match means your 00- file already wins. Deleting the cloud-init file is a fight you redo every time the instance regenerates it. Prefer ssh_pwauth: false so the yes is never written.

Can I use ansible.posix.sshd instead of a copy task?

Yes, if the collection on the controller is a version you have pinned and the module writes a drop-in, not a merge into the stock file. ansible.builtin.copy plus validate: sshd -t is the shape I can name without that pin. If you switch modules, keep the path /etc/ssh/sshd_config.d/00-hardening.conf and keep the sshd -T proof.

Does unattended-upgrades replace a patch window?

It replaces the “I forgot OpenSSH for a quarter” window. It does not replace a planned reboot after a kernel, and it does not install pocket -updates unless you listed that origin. Read the journal after the first week. A host that never logs a run is a timer you did not enable.