Subscribe

Harden Ubuntu 24.04: sshd, default deny, TLS you own

A teal padlock on a closet latch with a coral spare key, house style.

Ubuntu 24.04.4 LTS shipped on 12 February 2026. Canonical’s list of releases still puts standard security maintenance for 24.04 through May 2029. The OpenSSH man page default for PermitRootLogin is prohibit-password. Root can still arrive with a key on a fresh image.

Changing the SSH port is not a control either. The control is who can authenticate, which packets are accepted, who owns the worker, and whether Tuesday’s OpenSSH fix lands without you being at the keyboard.

Pair this host page with the TLS guide for the certificate and ciphers, and with HTTP security headers once 443 answers. If the app sets a session cookie, read CSRF before you call the box done.

Ubuntu 24.04 in August 2026

Ubuntu 26.04 LTS exists. A lot of VPS images, marketplace AMIs, and “click Ubuntu” panels still hand you 24.04. That is fine. 24.04 is an LTS with five years of standard security maintenance from the April 2024 release, through 31 May 2029 on Canonical’s cycle page. You do not need to jump LTS to harden a web host. You need a written sshd, a deny-first filter, and a worker that is not root.

Ubuntu 24.04’s openssh-server package is the 9.6p1 series. I am citing the noble package name. Qualys published CVE-2024-6387, the signal-handler race in sshd, on 1 July 2024. Ubuntu shipped a fix in the security pocket. That incident is why the next section after sshd is unattended upgrades, not a new theme for Apache.

Socket activation is the other 24.04 surprise. ssh.socket owns the listen port. Auth keywords take effect after systemctl restart ssh. If you later change ListenAddress, reload the socket too. Check with systemctl is-active ssh.socket.

sshd: keys only, root never

Write a drop-in. Do not edit the 200-line stock file and hope a later package prompt keeps your comments. On 24.04, sshd reads every *.conf under the sshd drop-in directory in lexical order and keeps the first value for each keyword.

# /etc/ssh/sshd_config.d/00-hardening.conf
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AllowUsers deploy
AuthenticationMethods publickey

Install your ed25519 public key in /home/deploy/.ssh/authorized_keys first. Mode 600 on that file, 700 on .ssh, owner deploy. Keep a second root console or a second session open. Then:

sudo sshd -t
sudo systemctl restart ssh
sudo sshd -T | grep -E 'permitrootlogin|passwordauthentication|kbdinteractiveauthentication'

You want permitrootlogin no, passwordauthentication no, and kbdinteractiveauthentication no. If any line still says yes, another drop-in won. Cloud images often ship 50-cloud-init.conf in that same directory with PasswordAuthentication yes. Your file must sort first. 00-hardening.conf does. 99-hardening.conf loses.

I could not confirm that every August 2026 marketplace image still writes that cloud-init file. The proof does not need that date. sshd -T is the effective config on the host you have.

prohibit-password is not no. The compiled default lets root in with a key. Set no. deploy uses sudo for admin work. Do not keep a password on deploy either if you can avoid it. A password is a second authenticator you will paste into a panel one day.

Do not add and forget keys. _Chief’s February 2026 comment said the same about leftover authorized_keys from machines you are not using. Remove a key the day the laptop leaves.

unattended-upgrades is the patch pipe

sshd hardening is a snapshot. CVE-2024-6387 was a reminder that the daemon you locked last quarter still needs Tuesday’s fix. Install the package if it is missing, then turn on the security origin.

sudo apt-get install -y unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades

In /etc/apt/apt.conf.d/50unattended-upgrades keep the security origin on:

Unattended-Upgrade::Allowed-Origins {
    "${distro_id}:${distro_codename}-security";
};
Unattended-Upgrade::Automatic-Reboot "false";

Leave automatic reboot off unless you have a second host and a planned drain. A kernel update that reboots a single VPS at 04:00 without you is an outage, not a control. Schedule the reboot. Confirm the timer is live:

systemctl is-enabled unattended-upgrades
journalctl -u unattended-upgrades -n 20 --no-pager

Do not hold openssh-server or libc6 on pin unless you are mid-incident. A pin is how a 2024 sshd race lives into 2026.

ufw and nftables: deny first

Ubuntu 24.04’s ufw talks to nftables. You do not have to write nft by hand. You do have to set the default to deny incoming before you allow anything. 80 is optional and only for an ACME HTTP-01 redirect you own. 22 is not a public service.

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 443/tcp
sudo ufw allow from 203.0.113.10 to any port 22 proto tcp
sudo ufw --force enable
sudo ufw status verbose

Replace 203.0.113.10 with the admin address or the tailnet prefix you actually use. If the admin IP is not stable, put sshd behind a WireGuard or Tailscale interface and allow 22 only on that interface. Public 22 with key-only auth is survivable. Public 22 plus a password is the scan you already know.

Confirm nft sees the same policy:

sudo nft list ruleset | head

If ufw is inactive and you inherited raw nft, write a table that drops input, then adds tcp dport 443 accept and your admin ssh accept. Do not run ufw and a hand nft table that both think they own input. One filter. Default deny.

The worker binds loopback port 8080. Nothing in the filter needs to publish that port. If ss -lnt shows it on 0.0.0.0, the proxy hop is a costume.

TLS on a process you admin

Terminate TLS on Caddy or nginx on this host, or inside the app if the app is the only listener. Do not send cleartext to a box you do not admin and call that HTTPS. The TLS guide is the cipher and certificate half. This page is the process half.

Caddy 2 as the proxy you own, sitting on 443, forwarding to the local worker:

# /etc/caddy/Caddyfile
example.com {
    reverse_proxy 127.0.0.1:8080
}

nginx is the same shape: listen 443 ssl; with a certificate you control, proxy_pass to the same loopback worker Caddy uses. Reload only after nginx -t. HTTP/80, if you open it, exists to 301 to HTTPS. It is not a second app.

Once 443 answers, set the headers from the headers guide on that same proxy: HSTS, a real CSP, X-Content-Type-Options. If the app issues a session cookie, that cookie needs Secure, HttpOnly, SameSite=Lax, and a Host prefix. The CSRF page is the check for cookie-authenticated POST. This host page does not replace it.

Let’s Encrypt from Caddy or certbot is fine. A vendor dashboard that terminates TLS in their anycast and talks HTTP to you is fine only if you treat that vendor as part of the trusted computing base. If you cannot read their config, you do not own the hop.

fail2ban is a tripwire

fail2ban reads logs and inserts a drop. After key-only sshd, the useful signal is “someone is still trying passwords” or “a jail just banned a net that should never have been talking to 22.” That is visibility. It is not the control that stopped the password.

If you install it, keep the jail small and the ban short:

# /etc/fail2ban/jail.d/sshd.local
[sshd]
enabled = true
backend = systemd
maxretry = 5
bantime = 1h

Then:

sudo systemctl enable --now fail2ban
sudo fail2ban-client status sshd

Do not point fail2ban at the app log and call that a WAF. Do not raise maxretry to 50 so the dashboard looks quiet. A quiet jail with password auth still on is a green light on a door that opens.

Moving sshd off 22 cuts bot noise. It does not hide the service from a targeted scan. If you change the port, allow that port from the admin net only, and keep the drop-in auth lines. Port games without those lines are the old article.

The webapp user owns the site, nothing else

Create a system user that cannot log in and cannot sudo. The binary, the working directory, and the writable state live under paths that user can read. Root owns the unit file and the proxy.

sudo useradd --system --home /srv/webapp --shell /usr/sbin/nologin webapp
sudo mkdir -p /srv/webapp /var/lib/webapp
sudo chown -R webapp:webapp /srv/webapp /var/lib/webapp

systemd is how you keep that user in a box after a bug:

# /etc/systemd/system/webapp.service
[Unit]
Description=webapp
After=network.target

[Service]
User=webapp
Group=webapp
WorkingDirectory=/srv/webapp
ExecStart=/srv/webapp/bin/webapp
Restart=on-failure
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadWritePaths=/var/lib/webapp
AmbientCapabilities=
CapabilityBoundingSet=

[Install]
WantedBy=multi-user.target

ProtectSystem=strict makes the tree read-only except ReadWritePaths. NoNewPrivileges=yes stops a later setuid. The process listens on loopback port 8080. Caddy or nginx, running as their own user, is the only thing that talks to the world on 443.

Never chmod 777 a log or upload dir so the worker can write. Add the user to the right group, or point the app at /var/lib/webapp. World-writable logs are how a low user becomes a high user the next time a cron job reads that directory.

Three listeners. Only 443 is public. 8080 never leaves the host.
INTERNET
   |
   +-- tcp/22   nft allow from admin only
   |              sshd  -->  deploy (sudo, keys)
   |
   +-- tcp/443  nft allow
   |              caddy/nginx (TLS you own)
   |                 |
   |                 +-- 127.0.0.1:8080
   |                        webapp (nologin, no sudo)
   |
   DROP  everything else

Prove the host before you ship

You are not scanning other people. You are reading the effective policy on a box you admin.

  1. From a second session, ssh deploy@your-host with the key. Password prompts are a fail.
  2. Rerun the sshd -T grep from the sshd section. Both auth lines must print no.
  3. Rerun the ufw verbose status from the filter section. Incoming default is deny, 443 is open, 22 is limited.
  4. ss -lntup must show 443 and 22 on the addresses you expect, and the worker only on loopback port 8080.
  5. ps -u webapp -o user,pid,cmd must show the worker. sudo -U webapp -l must show nothing useful.
  6. The unattended-upgrades unit from the patch section must still be enabled.
ss -lntup | grep -E ':22|:443|:8080'
ps -u webapp -o user,pid,cmd
systemctl is-enabled unattended-upgrades.service

If sshd -T and the drop-in disagree, the drop-in lost. Fix the sort order. If port 8080 is on *, bind the app to loopback and restart webapp.service. If webapp can sudo, you do not have least privilege. You have a second root.

Questions we keep getting

Is changing the SSH port worth it?

As a noise filter, yes. As a control, no. Keep the drop-in, keep 22 or the new port off the public internet if you can, and do not trade those lines for a new number.

Do I still need fail2ban with keys only?

Optional. It is a tripwire on leftover password attempts and on a jail you forgot to lock. It does not replace PasswordAuthentication no.

Can the app listen on 443 directly?

Yes, if that process is the one you admin and it loads a certificate you control. Then ufw still publishes 443 and still hides every other port. You skip the proxy hop. You do not skip the webapp user.

Mauro Chojrin

Mauro Chojrin / About Author

Mauro is a PHP Trainer and Consultant. He’s been involved in the IT Industry since the year 1997 in a wide array of positions, going from which include technical support, development, team leadership, IT Management and, off course, teaching. Currently Mauro’s focus is on in-company training and consulting but also maintaining his blog and YouTube channel where he shares his knowledge with the world. LinkedIn | Twitter