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
| Goal | Public: TCP 80 and 443 only. SSH: only over your Tailscale network (tailnet) |
| Admin access | A normal user with sudo and an SSH key. No root login, no passwords over SSH |
| Private network | Tailscale, with servers tagged tag:server and a policy that limits who reaches port 22 |
| Firewall | UFW on Ubuntu and Debian, firewalld on RHEL, Rocky, CentOS Stream and openSUSE |
| Second firewall | Your cloud provider's firewall, with the same rules |
| Automatic updates | unattended-upgrades (Ubuntu, Debian), dnf-automatic (RHEL family), a zypper patch timer (openSUSE) |
| Keep enabled | AppArmor (Ubuntu, Debian) or SELinux (RHEL family, openSUSE Leap 16) |
| Biggest trap | Docker-published ports skip UFW and firewalld. Bind containers to 127.0.0.1 |
| Safety net | Your 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:
- 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.
- Take a snapshot or backup of the server.
- 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
tailscale0interface - 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:
| Protocol | Port | Source | Why |
|---|---|---|---|
| TCP | 80 | 0.0.0.0/0 and ::/0 | HTTP, redirects and Let's Encrypt validation |
| TCP | 443 | 0.0.0.0/0 and ::/0 | HTTPS |
| UDP | 443 | 0.0.0.0/0 and ::/0 | Only if you serve HTTP/3 |
| UDP | 41641 | 0.0.0.0/0 and ::/0 | Optional, 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 see | Fine? |
|---|---|
nginx, Apache or Caddy on 0.0.0.0:80, 0.0.0.0:443, [::]:80, [::]:443 | Yes, that's the point |
sshd on 0.0.0.0:22 | Yes, the firewall and AllowUsers handle it |
tailscaled on UDP 41641 and on your 100.x address | Yes |
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:323 | Yes |
MySQL, PostgreSQL, Redis, MongoDB, Elasticsearch on 0.0.0.0 | No, bind to localhost |
Admin tools (phpMyAdmin, Grafana, Portainer, Webmin, Cockpit) on 0.0.0.0 | No, bind to localhost or the tailnet |
| Anything you don't recognise | Find out what it is, then remove or disable it |
Databases on localhost:
- MySQL and MariaDB:
bind-address = 127.0.0.1in the server config (on Ubuntu,/etc/mysql/mysql.conf.d/mysqld.cnffor MySQL or/etc/mysql/mariadb.conf.d/50-server.cnffor MariaDB) - PostgreSQL:
listen_addresses = 'localhost'inpostgresql.conf - Redis:
bind 127.0.0.1 -::1andprotected-mode yesinredis.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-neededreboots 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 = securityfinds nothing and installs nothing. Useupgrade_type = defaultthere. RHEL and Rocky Linux do publish advisories, sosecurityworks. - Check what ran with
journalctl -u dnf-automatic(ordnf-automatic-install), and what's still pending withsudo 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.
| Distribution | System | Check it | Should say |
|---|---|---|---|
| Ubuntu, Debian | AppArmor | sudo aa-status | Profiles loaded and in enforce mode |
| RHEL, Rocky, CentOS Stream | SELinux | getenforce | Enforcing |
| openSUSE Leap 16, new Tumbleweed installs | SELinux | getenforce | Enforcing |
| Older openSUSE installs | AppArmor | sudo aa-status | Profiles 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:
timedatectlshould showSystem 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 systemlists 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
- Log in through your provider's web console as
deploy. - Check Tailscale:
tailscale status. If the server shows as logged out or its key expired, runsudo tailscale up --advertise-tags=tag:serveragain and sign in. - Check sshd:
sudo sshd -tfor config errors andsudo systemctl status ssh(orsshd) for why it isn't running. - 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 firewalldsudo 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 toAllowUsers. Remove both once you're back in over the tailnet. - As a last resort,
sudo ufw disableorsudo systemctl stop firewalldturns 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
| Ubuntu | Debian | RHEL, Rocky, CentOS Stream | openSUSE Leap | |
|---|---|---|---|---|
| Package manager | apt | apt | dnf | zypper |
| Admin group | sudo | sudo | wheel | wheel |
| SSH service | ssh | ssh | sshd | sshd |
| Firewall | UFW (installed, off) | UFW or nftables (install) | firewalld (on) | firewalld (on) |
| Automatic updates | unattended-upgrades | unattended-upgrades | dnf-automatic | zypper patch timer |
| Access control | AppArmor | AppArmor | SELinux | SELinux (Leap 16) |
| Reboot needed? | /var/run/reboot-required | /var/run/reboot-required | dnf needs-restarting -r | zypper 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,
AllowUserslimited 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.1only - 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
- Tailscale: Install Tailscale on Linux
- Tailscale: Use UFW to lock down an Ubuntu server
- Tailscale: Key expiry
- Tailscale: Tailnet policy file syntax (grants and tag owners)
- Tailscale: Updating Tailscale
- OpenBSD manual: sshd_config(5)
- Debian Wiki: UnattendedUpgrades
- DNF documentation: DNF Automatic
- firewalld documentation
- Docker documentation: Packet filtering and firewalls
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.