
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.
- From a second console,
sshd -Tprintspermitrootlogin noandpasswordauthentication no. ls /etc/ssh/sshd_config.d/shows00-hardening.conf. If50-cloud-init.confexists, it must not win those two keywords.systemctl is-enabled unattended-upgradesis enabled. The journal shows a recent run, or a next timer.- A login as
rootwith a key you did not authorize fails. A login asdeploywithkeys/deploy.pubworks. 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.
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.



