All guides

How to Harden a Linux Web Server: Firewall, Tailscale-Only SSH and Automatic Security Updates

Lock down a Linux web server step by step: only ports 80 and 443 open to the internet, SSH reachable only over Tailscale, hardened sshd, automatic security updates and kernel settings, with commands for Ubuntu, Debian, RHEL, Rocky Linux, CentOS Stream and openSUSE.

By Muhammad Saif · Updated

Most break-ins on small servers don't need a clever exploit. They come through an SSH port that accepts passwords, a database listening on the public internet, an admin panel nobody remembered, or a package that was patched months ago but never installed. Closing those doors takes an afternoon, and it's the same work on every Linux distribution, just with different commands.

This guide takes a fresh or existing server to a simple end state: the internet can reach ports 80 and 443 and nothing else, SSH works only from your own devices over Tailscale, and security updates install themselves. Every step has commands for Ubuntu, Debian, RHEL, Rocky Linux, CentOS Stream and openSUSE, and the order is designed so you can't lock yourself out along the way.

The short version

GoalPublic: TCP 80 and 443 only. SSH: only over your Tailscale network (tailnet)
Admin accessA normal user with sudo and an SSH key. No root login, no passwords over SSH
Private networkTailscale, with servers tagged tag:server and a policy that limits who reaches port 22
FirewallUFW on Ubuntu and Debian, firewalld on RHEL, Rocky, CentOS Stream and openSUSE
Second firewallYour cloud provider's firewall, with the same rules
Automatic updatesunattended-upgrades (Ubuntu, Debian), dnf-automatic (RHEL family), a zypper patch timer (openSUSE)
Keep enabledAppArmor (Ubuntu, Debian) or SELinux (RHEL family, openSUSE Leap 16)
Biggest trapDocker-published ports skip UFW and firewalld. Bind containers to 127.0.0.1
Safety netYour provider's web console, tested before you start

Versions covered: Ubuntu 24.04 LTS and 26.04 LTS, Debian 12 and 13, RHEL, Rocky Linux and CentOS Stream 9 and 10, and openSUSE Leap 16.0. Notes for Tumbleweed are included where it differs. If you're still on CentOS Linux 7 or 8, it no longer gets security updates at all: hardening helps, but moving to Rocky Linux or RHEL comes first.

What you'll end up with

                 Internet
                    │
          only TCP 80 and 443 (and UDP 443 for HTTP/3, if you use it)
                    │
     ┌──────────────▼──────────────┐
     │ Cloud firewall              │  same rules, outside the server
     ├─────────────────────────────┤
     │ Host firewall               │  UFW or firewalld, default deny inbound
     ├─────────────────────────────┤
     │ nginx / Apache / Caddy      │  80 → redirect, 443 → your site
     │ app, database, admin tools  │  bound to 127.0.0.1 or the tailnet
     │ sshd                        │  keys only, tailnet addresses only
     └──────────────▲──────────────┘
                    │
          WireGuard tunnel (Tailscale), outbound-initiated
                    │
         Your laptop / phone on the same tailnet

Tailscale builds an encrypted WireGuard network between your devices. Each device gets a private address in 100.64.0.0/10 (and fd7a:115c:a1e0::/48 for IPv6), and connections are set up from the inside out, so the server doesn't need any inbound port open for you to reach it. To an attacker scanning the internet, SSH simply isn't there.

Before you start: don't lock yourself out

Every step below changes how you log in. Do these three things first:

  1. Open your provider's web console (DigitalOcean Droplet Console, Hetzner Console, AWS EC2 Serial Console or Session Manager, Linode Lish, Vultr View Console) and check you can log in through it. This works even when SSH and the network are broken. On AWS, the serial console needs a user with a password, so set one for your admin user.
  2. Take a snapshot or backup of the server.
  3. Keep your current SSH session open for the whole process. After every change to SSH or the firewall, test from a new terminal. Restarting sshd doesn't drop existing sessions, so the old one is your way back if the new one fails.

The examples use deploy as the admin user, 203.0.113.10 as the server's public IP and 100.101.102.103 as its Tailscale IP. Replace them with your own.

Step 1: Update the system

Start from a fully patched system, then reboot if the kernel changed.

Ubuntu and Debian

sudo apt update && sudo apt full-upgrade -y
sudo apt autoremove -y
[ -f /var/run/reboot-required ] && sudo reboot

RHEL, Rocky Linux and CentOS Stream

sudo dnf upgrade --refresh -y
sudo dnf needs-restarting -r || sudo reboot

On RHEL, the server must be registered (sudo subscription-manager register) or it gets no updates at all.

openSUSE Leap

sudo zypper refresh
sudo zypper --non-interactive patch    # run it twice if zypper updated itself first
sudo zypper --non-interactive patch
sudo zypper needs-rebooting || sudo reboot

On Tumbleweed, the update command is sudo zypper dup instead of patch.

Step 2: Create an admin user with an SSH key

You'll turn off root login over SSH in Step 4, so you need a normal user who can use sudo. Give it a strong password: sudo asks for it, and your provider's console may need it.

Ubuntu and Debian

sudo adduser deploy
sudo usermod -aG sudo deploy

On a minimal Debian install, sudo may be missing. Install it as root first with apt install sudo.

RHEL, Rocky Linux and CentOS Stream

sudo useradd -m -G wheel deploy
sudo passwd deploy

openSUSE

sudo useradd -m -G wheel deploy
sudo passwd deploy

Log in as deploy and run sudo -v. If sudo asks for the root password instead of yours, or says deploy isn't allowed, add a rule with sudo visudo -f /etc/sudoers.d/wheel:

Defaults:%wheel !targetpw
%wheel ALL=(ALL:ALL) ALL

On your laptop, create a key if you don't have one, and copy it to the server:

ssh-keygen -t ed25519 -C "laptop"
ssh-copy-id deploy@203.0.113.10
ssh deploy@203.0.113.10        # should log in without asking for the account password

Use a passphrase on the key. If several people manage the server, each gets their own user and key, so access can be removed per person.

Step 3: Put the server on Tailscale

Install Tailscale

The official install script detects your distribution, adds Tailscale's package repository and installs it. It supports every distribution in this guide. Read it before running it:

curl -fsSLo install-tailscale.sh https://tailscale.com/install.sh
less install-tailscale.sh
sh install-tailscale.sh
sudo systemctl enable --now tailscaled

Because it installs from a package repository, Tailscale is updated by the same package manager as everything else. If you'd rather add the repository by hand, the per-distribution commands are on Tailscale's Linux install page.

Tag the server and turn off key expiry

Devices that sign in as a person have their keys expire after 180 days by default. When a server's key expires, it drops off your tailnet, and with SSH closed to the internet, so do you. There are two ways to prevent this:

  • Tag the server (recommended). Tagged devices belong to the tag rather than a person, and key expiry is turned off for them when they're first authenticated with a tag.
  • Or, in the admin console under Machines, open the server's menu and choose Disable key expiry.

To tag it, first let admins own the tag in your tailnet policy (Access controls in the admin console). A minimal policy that also limits SSH looks like this:

{
  "tagOwners": {
    "tag:server": ["autogroup:admin"]
  },
  "grants": [
    // Tailnet admins can SSH into servers, and nothing else on them
    { "src": ["autogroup:admin"], "dst": ["tag:server"], "ip": ["tcp:22"] },
    // Each person's own devices can still reach each other
    { "src": ["autogroup:member"], "dst": ["autogroup:self"], "ip": ["*"] }
  ]
}

New tailnets start with a policy that lets every device reach every other device on every port. Replacing it with the one above means a stolen laptop belonging to a non-admin can't reach your servers, and the servers themselves can't open connections to anything else on your tailnet. Add more grants as you need them, such as tcp:5432 from a group:dba if someone needs the database over the tailnet.

Then bring the server up with the tag:

sudo tailscale up --advertise-tags=tag:server

It prints a login URL. Open it, sign in as an admin, and the server joins the tailnet as a tagged device. For servers built by scripts, create a tagged, pre-approved auth key in the admin console and use sudo tailscale up --auth-key=tskey-auth-... --advertise-tags=tag:server instead.

Turn on automatic Tailscale updates

sudo tailscale set --auto-update

Tailscale waits several days after a release before rolling it out to auto-updating devices, so you don't get a broken version on day one.

Test SSH over the tailnet

Find the server's tailnet address and connect to it from your laptop (which must be on the same tailnet):

tailscale ip -4                       # on the server, e.g. 100.101.102.103
ssh deploy@100.101.102.103            # from your laptop
ssh deploy@web-1                      # or by name, with MagicDNS turned on

Don't continue until this works. From here on, do everything in an SSH session over the tailnet address, not the public IP.

Optional: Tailscale SSH instead of OpenSSH keys

Tailscale can also handle SSH itself: sudo tailscale set --ssh lets tailnet identity replace SSH keys, and an ssh section in the policy decides who may log in as which user (with "action": "check", it asks for a fresh sign-in every so often). It's convenient for teams, but it makes Tailscale the only way in. This guide keeps normal OpenSSH with keys, reached over the tailnet, so your access doesn't depend on a single service.

Step 4: Harden the SSH server

Put your settings in a drop-in file rather than editing the main config. OpenSSH uses the first value it finds for most settings, and drop-in files are read in alphabetical order near the top of the main config. Name yours 00- so it wins over files that distributions and cloud images add, such as Ubuntu's 50-cloud-init.conf (which can turn password login back on) and RHEL's 01-permitrootlogin.conf.

sudo tee /etc/ssh/sshd_config.d/00-hardening.conf > /dev/null <<'EOF'
# Keys only, no root, no passwords
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
PermitEmptyPasswords no

# Fewer guesses, shorter windows
MaxAuthTries 3
LoginGraceTime 20
ClientAliveInterval 300
ClientAliveCountMax 2

# Features a web server rarely needs
X11Forwarding no
AllowAgentForwarding no
PermitUserEnvironment no

# Only these users, and only from Tailscale addresses
AllowUsers deploy@100.64.0.0/10 deploy@fd7a:115c:a1e0::/48
EOF

AllowUsers makes sshd itself refuse anyone who isn't deploy connecting from a tailnet address, even if the firewall were turned off by mistake. List every admin user here. If a CI system deploys over SSH from outside your tailnet, put it on the tailnet too (Tailscale has a GitHub Action for this) rather than opening SSH up again.

Check that the drop-in folder is actually read. Ubuntu, Debian, RHEL and its rebuilds, and openSUSE Leap 16 all include it by default, but older or customised installs may not:

grep -i '^Include' /etc/ssh/sshd_config /usr/etc/ssh/sshd_config 2>/dev/null

If nothing comes back, add Include /etc/ssh/sshd_config.d/*.conf as the first line of /etc/ssh/sshd_config.

Test the config, then restart. A syntax error stops sshd from starting, so always test first:

sudo sshd -t && echo "config OK"

# Ubuntu and Debian
sudo systemctl restart ssh

# RHEL, Rocky, CentOS Stream and openSUSE
sudo systemctl restart sshd

On Ubuntu and Debian, the service is called ssh. The sshd alias isn't always there on Ubuntu 24.04 and later, where SSH starts through ssh.socket.

Confirm what sshd is really using, since another file could still override yours:

sudo sshd -T | grep -Ei '^(permitrootlogin|passwordauthentication|kbdinteractiveauthentication|authenticationmethods|allowusers)'

You should see permitrootlogin no, passwordauthentication no and your allowusers lines. Now open a new terminal and SSH in over the tailnet. Then try the public IP: it should be refused, because deploy isn't allowed from there.

Why not just bind sshd to the Tailscale IP?

It's tempting to set ListenAddress 100.101.102.103. The problem is boot order: sshd can start before Tailscale has brought up its interface, fail to bind to an address that doesn't exist yet, and leave you with no SSH at all. Letting sshd listen everywhere and blocking it with AllowUsers and the firewall gives the same result without that risk. You also don't need to move SSH to another port: nobody on the internet can reach port 22 once the firewall is on.

Step 5: Firewall: 80 and 443 for everyone, SSH only on the tailnet

The rule set is the same on every distribution:

  • Drop all incoming traffic by default, allow all outgoing
  • Allow TCP 80 and 443 from anywhere, over IPv4 and IPv6
  • Allow UDP 443 only if your web server serves HTTP/3
  • Allow TCP 22 only on the tailscale0 interface
  • Optionally allow UDP 41641 so Tailscale can make direct connections instead of going through a relay. It works without it, sometimes a little slower

Make sure you're connected over the tailnet before you turn the firewall on.

Ubuntu and Debian: UFW

UFW comes with Ubuntu but is turned off. On Debian, install it first with sudo apt install ufw.

sudo ufw default deny incoming
sudo ufw default allow outgoing

sudo ufw allow in on tailscale0 to any port 22 proto tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
# sudo ufw allow 443/udp       # only for HTTP/3
# sudo ufw allow 41641/udp     # optional, direct Tailscale connections

sudo ufw enable
sudo ufw status verbose

If an earlier rule opened SSH to everyone (OpenSSH or 22/tcp without on tailscale0), remove it:

sudo ufw status numbered
sudo ufw delete 3              # the number of the public SSH rule

UFW applies each rule to IPv6 too, as long as IPV6=yes is set in /etc/default/ufw (the default).

RHEL, Rocky Linux, CentOS Stream and openSUSE: firewalld

firewalld is installed and running by default on these systems. Its public zone, which your network interface is in, allows SSH, and on RHEL and openSUSE Leap 16 also Cockpit (the web console on port 9090). Put tailscale0 in its own zone that only allows SSH, then reduce public to web traffic:

sudo systemctl enable --now firewalld

# A zone for the tailnet that allows SSH only
sudo firewall-cmd --permanent --new-zone=tailscale
sudo firewall-cmd --reload
sudo firewall-cmd --permanent --zone=tailscale --add-interface=tailscale0
sudo firewall-cmd --permanent --zone=tailscale --add-service=ssh

# The public zone: web only
sudo firewall-cmd --permanent --zone=public --add-service=http
sudo firewall-cmd --permanent --zone=public --add-service=https
# sudo firewall-cmd --permanent --zone=public --add-port=443/udp      # only for HTTP/3
# sudo firewall-cmd --permanent --zone=public --add-port=41641/udp    # optional, Tailscale
sudo firewall-cmd --permanent --zone=public --remove-service=ssh
sudo firewall-cmd --permanent --zone=public --remove-service=cockpit

sudo firewall-cmd --reload
sudo firewall-cmd --get-active-zones
sudo firewall-cmd --zone=public --list-all
sudo firewall-cmd --zone=tailscale --list-all

The public zone should list dhcpv6-client http https (leave dhcpv6-client, some clouds need it for IPv6) and the tailscale zone should list ssh with tailscale0 as its interface. If you don't use Cockpit at all, also run sudo systemctl disable --now cockpit.socket.

Optional: plain nftables on a minimal Debian server

If you'd rather not install UFW, Debian ships nftables. Save this as /etc/nftables.conf:

#!/usr/sbin/nft -f
flush ruleset

table inet filter {
  chain input {
    type filter hook input priority filter; policy drop;

    ct state established,related accept
    ct state invalid drop
    iif "lo" accept

    # ICMP and ICMPv6 (IPv6 breaks without it)
    meta l4proto { icmp, ipv6-icmp } accept

    # SSH from the tailnet only
    iifname "tailscale0" tcp dport 22 accept

    # Public web traffic
    tcp dport { 80, 443 } accept
    # udp dport 443 accept       # HTTP/3
    # udp dport 41641 accept     # direct Tailscale connections
  }

  chain forward {
    type filter hook forward priority filter; policy drop;
  }

  chain output {
    type filter hook output priority filter; policy accept;
  }
}

Then load it with sudo nft -c -f /etc/nftables.conf && sudo systemctl enable --now nftables. flush ruleset also removes rules that Tailscale and Docker added, so restart those services after reloading the firewall. The forward policy above drops everything, which suits a plain web server but breaks Docker networking and Tailscale subnet routing; don't use this file on a Docker host.

How Tailscale and your firewall interact

Tailscale adds its own firewall rules when it starts. With UFW, those rules accept traffic arriving on tailscale0 before UFW's rules see it, so UFW doesn't really limit what tailnet devices can reach. Your tailnet policy from Step 3 does. With firewalld and nftables, the host firewall usually filters tailnet traffic as well. Either way, treat the tailnet policy as the control for tailnet access and check it: from a tailnet device that shouldn't have access, nc -vz 100.101.102.103 22 should fail.

Docker: the hole most people miss

When you publish a container port with -p 8080:80 or ports: in Compose, Docker writes its own firewall rules, and that port is reachable from the internet even if UFW or firewalld blocks it. Publish container ports on localhost only, and let your reverse proxy on 80 and 443 be the only public entry:

services:
  app:
    image: myapp:latest
    ports:
      - "127.0.0.1:8080:80"     # reachable only from the host itself

A database, Redis or admin container usually needs no published port at all: put it on the same Compose network as the app and they reach each other by service name.

Step 6: Repeat the rules in your cloud firewall

Most providers have a firewall outside the server: AWS security groups, DigitalOcean Cloud Firewalls, Hetzner Cloud Firewalls, Google Cloud VPC firewall rules, Azure network security groups. Set the same inbound rules there:

ProtocolPortSourceWhy
TCP800.0.0.0/0 and ::/0HTTP, redirects and Let's Encrypt validation
TCP4430.0.0.0/0 and ::/0HTTPS
UDP4430.0.0.0/0 and ::/0Only if you serve HTTP/3
UDP416410.0.0.0/0 and ::/0Optional, direct Tailscale connections

Delete the inbound SSH rule. Tailscale keeps working because its connections start from inside the server. Two layers mean a mistake in one, such as a package that resets the host firewall, doesn't expose the server. The cloud firewall also drops unwanted traffic before it uses any of your server's resources.

Step 7: Make sure only the web server listens publicly

The firewall is the second line. The first is not having services listen on public addresses at all. List everything that's listening:

sudo ss -tulpn

Go through each line:

You seeFine?
nginx, Apache or Caddy on 0.0.0.0:80, 0.0.0.0:443, [::]:80, [::]:443Yes, that's the point
sshd on 0.0.0.0:22Yes, the firewall and AllowUsers handle it
tailscaled on UDP 41641 and on your 100.x addressYes
Anything on 127.0.0.1 or [::1]Yes, only local programs can reach it
systemd-resolved on 127.0.0.53:53, chronyd on 127.0.0.1:323Yes
MySQL, PostgreSQL, Redis, MongoDB, Elasticsearch on 0.0.0.0No, bind to localhost
Admin tools (phpMyAdmin, Grafana, Portainer, Webmin, Cockpit) on 0.0.0.0No, bind to localhost or the tailnet
Anything you don't recogniseFind out what it is, then remove or disable it

Databases on localhost:

  • MySQL and MariaDB: bind-address = 127.0.0.1 in the server config (on Ubuntu, /etc/mysql/mysql.conf.d/mysqld.cnf for MySQL or /etc/mysql/mariadb.conf.d/50-server.cnf for MariaDB)
  • PostgreSQL: listen_addresses = 'localhost' in postgresql.conf
  • Redis: bind 127.0.0.1 -::1 and protected-mode yes in redis.conf

Restart each service after changing its config and run ss -tulpn again.

Admin tools on the tailnet only. Bind them to 127.0.0.1 and share them with your tailnet using Tailscale Serve, which gives them an HTTPS address that only tailnet devices can open:

sudo tailscale serve --bg 3000    # e.g. Grafana on localhost:3000
tailscale serve status

Add a grant for that port (for example tcp:443 to tag:server) for the people who should use it.

Stop services you don't use. List what starts at boot and disable what a web server doesn't need, such as cups, avahi-daemon, rpcbind or postfix if nothing on the server sends mail:

systemctl list-unit-files --type=service --state=enabled
sudo systemctl disable --now avahi-daemon

A safer default for the web server

Two small nginx changes stop bots that scan by IP address from reaching your sites, and keep port 80 doing nothing except redirecting and answering Let's Encrypt:

# Requests for an unknown host name or the bare IP get no answer at all
server {
    listen 80 default_server;
    listen [::]:80 default_server;
    listen 443 ssl default_server;
    listen [::]:443 ssl default_server;
    ssl_reject_handshake on;
    server_name _;
    return 444;
}

# Port 80 for your site: Let's Encrypt challenges, then redirect to HTTPS
server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;

    location /.well-known/acme-challenge/ { root /var/www/letsencrypt; }
    location / { return 301 https://$host$request_uri; }
}

Also set server_tokens off; in the http block so nginx doesn't advertise its version. On Apache, use ServerTokens Prod and ServerSignature Off.

SELinux on the RHEL family: if nginx or Apache proxies to an app on a local port and you get 502 errors with permission denied in the log, allow it rather than turning SELinux off:

sudo setsebool -P httpd_can_network_connect 1

Step 8: Automatic security updates

A firewall can't protect the one service it must expose, so the web server, PHP, OpenSSL and the kernel have to stay patched. Set every server to install security updates on its own.

Ubuntu and Debian: unattended-upgrades

Ubuntu Server installs it already. On Debian, install it:

sudo apt install unattended-upgrades apt-listchanges needrestart
sudo dpkg-reconfigure -plow unattended-upgrades    # answer Yes

That creates /etc/apt/apt.conf.d/20auto-upgrades, which should contain:

APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";

The default settings install security updates only, from your distribution's security repository. Put your own settings in a separate file so package upgrades don't overwrite them:

sudo tee /etc/apt/apt.conf.d/52unattended-upgrades-local > /dev/null <<'EOF'
// Reboot by itself when an update needs it (kernel, libc), at a quiet hour
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "04:00";

// Keep /boot from filling up with old kernels
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
Unattended-Upgrade::Remove-Unused-Dependencies "true";

// Email a report when something fails (needs a working mail setup)
// Unattended-Upgrade::Mail "you@example.com";
// Unattended-Upgrade::MailReport "only-on-error";
EOF

If PHP or nginx comes from a third-party repository, such as the ondrej/php PPA, its updates are skipped by default. Add its origin to the same file. Run apt-cache policy and look for the o= value on that repository's line:

Unattended-Upgrade::Origins-Pattern {
    "origin=LP-PPA-ondrej-php";
};

Test it without changing anything, then check the logs after the first real run:

sudo unattended-upgrade --dry-run --debug
systemctl list-timers 'apt-daily*'
less /var/log/unattended-upgrades/unattended-upgrades.log

needrestart restarts services that are still using old versions of updated libraries, so an OpenSSL fix takes effect without waiting for the next reboot. On Ubuntu it does this automatically. On Debian, set $nrconf{restart} = 'a'; in /etc/needrestart/needrestart.conf.

With an Ubuntu Pro subscription (free for up to five machines for personal use), sudo pro enable livepatch adds kernel security fixes without rebooting.

RHEL, Rocky Linux and CentOS Stream: dnf-automatic

sudo dnf install -y dnf-automatic

Edit /etc/dnf/automatic.conf (create it if it doesn't exist) and set:

[commands]
upgrade_type = security
download_updates = yes
apply_updates = yes
reboot = when-needed
reboot_command = "shutdown -r +5 'Rebooting to finish security updates'"

[emitters]
emit_via = stdio

Then turn on the timer and check it:

sudo systemctl enable --now dnf-automatic.timer
systemctl list-timers 'dnf*'

If that timer name doesn't exist on your release, systemctl list-unit-files '*automatic*' shows what it's called.

  • reboot = when-needed reboots only when an update needs it, such as a new kernel or glibc, five minutes after the run.
  • CentOS Stream doesn't publish security advisories, so upgrade_type = security finds nothing and installs nothing. Use upgrade_type = default there. RHEL and Rocky Linux do publish advisories, so security works.
  • Check what ran with journalctl -u dnf-automatic (or dnf-automatic-install), and what's still pending with sudo dnf updateinfo list --security.

openSUSE Leap: a zypper patch timer

openSUSE ships updates as patches, and each one has a category such as security. A small script and a systemd timer install security patches every night and reboot when one needs it. Save this as /usr/local/sbin/auto-security-patch:

#!/bin/sh
# Installs openSUSE security patches. Exit code 103 means zypper updated itself
# and must run again; 102 means a reboot is needed.
zypper --non-interactive refresh

zypper --non-interactive patch --category security --auto-agree-with-licenses
rc=$?
if [ "$rc" -eq 103 ]; then
  zypper --non-interactive patch --category security --auto-agree-with-licenses
  rc=$?
fi

if [ "$rc" -eq 102 ]; then
  systemctl reboot
fi

Then add the service and timer:

sudo chmod 700 /usr/local/sbin/auto-security-patch

sudo tee /etc/systemd/system/auto-security-patch.service > /dev/null <<'EOF'
[Unit]
Description=Install openSUSE security patches
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/auto-security-patch
EOF

sudo tee /etc/systemd/system/auto-security-patch.timer > /dev/null <<'EOF'
[Unit]
Description=Nightly openSUSE security patches

[Timer]
OnCalendar=*-*-* 03:30
RandomizedDelaySec=30m
Persistent=true

[Install]
WantedBy=timers.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now auto-security-patch.timer
systemctl list-timers auto-security-patch.timer

Run it once by hand with sudo systemctl start auto-security-patch.service and read the result with journalctl -u auto-security-patch. sudo zypper list-patches --category security shows anything still pending. To restart services still running old libraries after an update, sudo zypper ps -s lists them.

Tumbleweed is a rolling release with no separate security patches, and a full zypper dup can need a human. On a Tumbleweed server, schedule zypper dup only if root is on Btrfs with snapper snapshots so you can roll back, or use openSUSE's transactional-update variant, which applies updates to a new snapshot and switches on reboot.

Whatever the distribution

Automatic updates are only useful if you notice when they stop. Check the logs above once a month, or send them somewhere you'll see failures. And automatic updates only cover packages: anything installed by hand, such as a downloaded binary, a WordPress plugin or a Composer dependency, still needs updating separately.

Step 9: Kernel and system settings

These kernel network settings turn off features a web server doesn't need and that attackers have used for spoofing and redirect tricks. Save them as /etc/sysctl.d/90-hardening.conf:

# Drop spoofed packets (2 = loose mode, which works with Tailscale)
net.ipv4.conf.all.rp_filter = 2
net.ipv4.conf.default.rp_filter = 2

# Ignore and don't send ICMP redirects
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv6.conf.default.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0

# Ignore source-routed packets
net.ipv4.conf.all.accept_source_route = 0
net.ipv6.conf.all.accept_source_route = 0

# SYN flood protection and no reply to broadcast pings
net.ipv4.tcp_syncookies = 1
net.ipv4.icmp_echo_ignore_broadcasts = 1

# Hide kernel addresses and logs from normal users
kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1

# A process can only debug its own children
kernel.yama.ptrace_scope = 1

# Protections against common /tmp link attacks (usually on already)
fs.protected_hardlinks = 1
fs.protected_symlinks = 1

Apply them with sudo sysctl --system. Use loose rp_filter (2) rather than strict (1): strict mode can drop legitimate Tailscale traffic, especially if the server is ever used as a subnet router or exit node. Don't change ip_forward on a server that runs Docker or routes Tailscale traffic.

Keep mandatory access control on. It limits what a hacked web server process can touch, even after an attacker gets in.

DistributionSystemCheck itShould say
Ubuntu, DebianAppArmorsudo aa-statusProfiles loaded and in enforce mode
RHEL, Rocky, CentOS StreamSELinuxgetenforceEnforcing
openSUSE Leap 16, new Tumbleweed installsSELinuxgetenforceEnforcing
Older openSUSE installsAppArmorsudo aa-statusProfiles loaded

When an app breaks under SELinux, sudo ausearch -m avc -ts recent shows what was blocked. Fix it with the matching boolean or file label rather than setenforce 0.

A few more items that take minutes:

  • Time sync: timedatectl should show System clock synchronized: yes. Logs, TLS and two-factor codes depend on it.
  • Logs that survive a reboot: create /var/log/journal (sudo mkdir -p /var/log/journal && sudo systemctl restart systemd-journald) if it doesn't exist, so you can investigate after a crash.
  • fail2ban is less important once SSH isn't public. It's still useful for login pages your web app exposes, such as wp-login.php.
  • An audit: Lynis is in most distribution repositories (EPEL on the RHEL family). sudo lynis audit system lists further suggestions with a short explanation of each. Don't chase a perfect score; read the warnings first.

Step 10: Check it from the outside

Test the server the way an attacker sees it. From a machine that isn't the server itself, scan your own server's public address:

# TCP, all ports, IPv4
nmap -Pn -p- 203.0.113.10

# IPv6, if the server has an address
nmap -6 -Pn -p- 2001:db8::10

# UDP ports you opened on purpose
sudo nmap -sU -Pn -p 443,41641 203.0.113.10

You want to see 80/tcp open and 443/tcp open, and every other port filtered. If SSH or anything else shows as open or closed, a firewall rule is missing. Then confirm the rest:

# SSH over the public IP should time out
ssh -o ConnectTimeout=5 deploy@203.0.113.10

# SSH over the tailnet should work
ssh deploy@100.101.102.103

# The site should load over HTTPS and redirect from HTTP
curl -I http://example.com
curl -I https://example.com

Finally, reboot the server and test again. This catches the boot-order problems: a firewall that doesn't start, Tailscale that doesn't come back, or an sshd that won't start. It's much better to find them now than during an incident.

If you do get locked out

  1. Log in through your provider's web console as deploy.
  2. Check Tailscale: tailscale status. If the server shows as logged out or its key expired, run sudo tailscale up --advertise-tags=tag:server again and sign in.
  3. Check sshd: sudo sshd -t for config errors and sudo systemctl status ssh (or sshd) for why it isn't running.
  4. If you need SSH over the public IP temporarily, open it for your current IP only: sudo ufw allow from 198.51.100.7 to any port 22 proto tcp, or with firewalld sudo firewall-cmd --zone=public --add-rich-rule='rule family="ipv4" source address="198.51.100.7" port port="22" protocol="tcp" accept' (without --permanent, so it disappears on reload). You'd also need to add that address to AllowUsers. Remove both once you're back in over the tailnet.
  5. As a last resort, sudo ufw disable or sudo systemctl stop firewalld turns the host firewall off. The cloud firewall still protects the server while you fix it.

If Tailscale's coordination service has an outage, devices that are already connected usually keep working, because traffic flows directly between them. New sign-ins and policy changes wait until it's back. The web console covers the rare case where that's not enough.

Commands at a glance

UbuntuDebianRHEL, Rocky, CentOS StreamopenSUSE Leap
Package manageraptaptdnfzypper
Admin groupsudosudowheelwheel
SSH servicesshsshsshdsshd
FirewallUFW (installed, off)UFW or nftables (install)firewalld (on)firewalld (on)
Automatic updatesunattended-upgradesunattended-upgradesdnf-automaticzypper patch timer
Access controlAppArmorAppArmorSELinuxSELinux (Leap 16)
Reboot needed?/var/run/reboot-required/var/run/reboot-requireddnf needs-restarting -rzypper needs-rebooting

Checklist

  • Tested the provider's web console and took a snapshot
  • Fully updated the system and rebooted
  • Created an admin user with sudo and an SSH key, and logged in with it
  • Installed Tailscale, tagged the server and confirmed key expiry is off
  • Replaced the allow-all tailnet policy with grants that limit port 22
  • Turned on Tailscale auto-updates
  • SSH works over the tailnet address
  • Added the sshd drop-in: no root, no passwords, AllowUsers limited to tailnet addresses
  • Checked the effective settings with sshd -T
  • Host firewall: deny inbound, allow 80 and 443, SSH only on tailscale0
  • Removed every public SSH rule from the host firewall and the cloud firewall
  • Docker ports published on 127.0.0.1 only
  • Databases and admin tools listen on localhost or the tailnet only
  • Automatic security updates on, with automatic reboots at a quiet hour
  • Third-party repositories (PHP, nginx) included in automatic updates
  • Kernel settings applied with sysctl --system
  • AppArmor or SELinux enforcing
  • Scanned from outside: only 80 and 443 open
  • Rebooted and tested everything again

Sources

Need it done for you? I harden and patch Linux servers on any of these distributions: Tailscale-only access, firewall and cloud firewall rules, automatic updates, and fixes for the services that shouldn't be public, with a written report of every change. For urgent PHP vulnerabilities there's a fixed-scope patching service, and for anything else, get in touch.