Before you expose a service to the web — the non-negotiable Linux server hardening checklist
Self-hosters' worst incidents almost never come from a sophisticated adversary. They come from the moment a service became reachable from the internet without anyone re-checking the boring stuff first. This is the pre-exposure checklist: not general hardening, not a monthly review — the eight things you do once you've decided to open a port.
For background on the wider hygiene loop, the 30-minute homelab security baseline covers the steady-state. This post is narrower: the decision point. You're about to add a public-facing DNS record, port-forward, Cloudflare Tunnel, or take a service off Tailscale-only. What changes?
Step 1 — Decide whether the service has to be public at all
Before any hardening, ask the question that obviates most of it: does this service really need to be on the public internet? If the answer is "I want to reach it from outside my LAN," the right answer is almost always one of these:
- Tailscale / Headscale / Netbird / Twingate. A WireGuard mesh removes the public attack surface entirely. The service stays LAN-only; your devices join the mesh.
- Cloudflare Tunnel / WARP private apps. No inbound port at all on your end. Cloudflare originates the connection and applies its own auth at the edge.
- WireGuard on the firewall. Older-school but bulletproof. Open one UDP port, get LAN access from anywhere.
If any of these solve your access problem, stop reading this post and use one. Pre-exposure hardening for a service that could have been zero-exposure is wasted entropy. The rest of this checklist is for the genuine cases — public APIs, web apps that need anonymous reach, services with users you don't control.
Step 2 — Confirm what's actually listening, from outside
Most self-hosters have a mental model of what their box exposes that disagrees with reality. The reality is the only one that counts. Run two scans and reconcile them:
From outside your network (a $5 VPS, a friend's home, your phone hotspot — anything off-LAN):
nmap -Pn -p- -T4 your.public.ip.here
# or against a hostname:
nmap -Pn -p- -T4 your-public-hostname.example.com
From the host itself:
ss -tulpn # every TCP/UDP listener with the owning process
sudo ss -tulpn # add process names if not running as root
Compare. Anything in the external scan that's not the
service you intend to publish is a bug. Anything in
ss -tulpn bound to 0.0.0.0 that you
didn't expect is a future bug — even if your firewall is currently
catching it. A reboot, a rule mistake, or a forgotten Docker
-p flag will eventually expose it.
The classic surprises:
- Redis on
0.0.0.0:6379with no password (protected-modeoff because someone put it in a Compose file once). - MongoDB / Elasticsearch on a public IP with no auth.
- An old phpMyAdmin or Adminer on port 80 that survived a reverse-proxy migration.
- A Kubernetes API server or etcd reachable on the LAN with no TLS client cert.
Step 3 — Lock down SSH before anything else
SSH is the one port almost every self-hoster has open. It's also
the easiest place to drift. Open /etc/ssh/sshd_config
and confirm:
PermitRootLogin no
PasswordAuthentication no
ChallengeResponseAuthentication no
KbdInteractiveAuthentication no
UsePAM yes
X11Forwarding no
MaxAuthTries 3
LoginGraceTime 30s
Reload sshd: sudo systemctl reload sshd. Don't
restart and disconnect mid-session — keep one shell open until
you've verified a fresh login works.
Then audit ~/.ssh/authorized_keys on every host. The
keys you forgot are the ones that bite. (See
SSH key hygiene for
homelabs for the full audit.)
For the cipher/MAC/KEX side specifically, weak choices accumulate
after distro upgrades. The
Ubuntu
22.04 hardening checklist has the exact Ciphers
/ MACs / KexAlgorithms lines worth
pinning before any service goes public.
Step 4 — Patch the host and confirm zero known-exploited CVEs
Run the package manager update on every host before exposing anything:
# Debian / Ubuntu
sudo apt update && sudo apt full-upgrade -y
# Rocky / Alma / RHEL
sudo dnf upgrade -y
# Alpine
sudo apk update && sudo apk upgrade
# Arch
sudo pacman -Syu --noconfirm
Before exposing the service, also cross-check the live per-distro
CVE tracker for any open critical findings — for
Ubuntu 24.04,
22.04,
Debian 12,
AlmaLinux 9 or
Rocky 9. The trackers rebuild daily
and surface the exact source-package fix version per release,
so you can confirm apt full-upgrade actually pulled
the patched build rather than something pinned in a stale repo.
Updates without a reboot don't apply to a running kernel or
libraries that are still mapped into long-lived processes. If a
kernel landed: reboot. If libssl updated: restart
every service that links against it (or just reboot — homelab,
not high-availability).
Then check the CISA Known Exploited Vulnerabilities catalog against your installed package versions. KEV is the smallest list of CVEs you can't afford to ignore — every entry has confirmed exploitation in the wild. (See how to triage CVE findings for the rest of the prioritisation hierarchy.)
Two CVEs worth knowing about by name before exposing any OpenSSH-bearing host: regreSSHion (CVE-2024-6387) and the xz/liblzma backdoor (CVE-2024-3094). Both are exactly the kind of thing that ships on a long-running LTS box and never gets noticed without an active scan.
Step 5 — Harden TLS and certificate hygiene
Anything serving a login form or API token over plaintext HTTP is a liability the moment it's public. Cheap fixes:
- HTTPS-only. Caddy, Traefik, and Nginx all
offer one-line Let's Encrypt automation. Set
auto_https(or equivalent) and stop hand-rolling cert renewals. - Disable TLS 1.0 and 1.1. They've been deprecated since 2020. Modern reverse proxies default to 1.2+ but old configs sometimes drag the floor lower.
- Enable HSTS.
Strict-Transport-Security: max-age=31536000; includeSubDomainstells browsers never to fall back to HTTP. Set it once and forget it. - Watch certificate expiry. Let's Encrypt is 90 days. If your reverse proxy ever fails to renew (DNS challenge bug, rate limit, expired API token), you'll find out at 3am from a user. A 30-day-out alert prevents that. (More in TLS certificate expiry on self-hosted services.)
Test the result with SSL Labs or testssl.sh. An A grade isn't a vanity metric; B and below means there's a known weakness someone can target.
Step 6 — Authenticate every admin surface
The single highest-leverage rule when something goes public: no admin UI, anywhere, ever, reachable without auth. Not "behind a hard-to-guess port." Not "only people who know the URL." Not "I'll fix it after launch." Authenticated, or not reachable.
The dangerous services to double-check before exposure:
- Grafana, Kibana, Prometheus, Alertmanager, Uptime Kuma
- Portainer, Cockpit, Webmin, Yacht, Dockge
- phpMyAdmin, Adminer, pgAdmin, Mongo Express
- Pi-hole, AdGuard Home, Nginx Proxy Manager, Traefik dashboard
- Jenkins, Gitea, GitLab, Drone, Woodpecker
- Home Assistant, Frigate, Scrypted, Plex, Jellyfin admin consoles
- Redis, Memcached, Elasticsearch, MongoDB, etcd, MinIO API
- Kubernetes Dashboard, Rancher, k0s, k3s API
Even with auth, prefer a layered approach: an authenticating reverse proxy (Authelia, Authentik, OAuth2 Proxy) or zero-trust mesh in front of the app's own auth. The app's auth might have a CVE next month; the proxy still catches anonymous traffic.
For more on why this is the dominant compromise vector, see exposed admin surfaces: the #1 homelab compromise vector.
Step 7 — Set firewall rules to default-deny
A firewall isn't a substitute for hardening, but it's the cheap backstop when hardening drifts. Set the default to deny, then explicitly allow only the public service:
# Ubuntu / Debian (ufw)
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp comment 'ssh'
sudo ufw allow 443/tcp comment 'https — public service'
sudo ufw enable
# Verify
sudo ufw status verbose
nftables / iptables / firewalld
/ pf — pick whichever your distro speaks natively. The pattern is
always the same: deny inbound by default, list the exceptions
explicitly.
Recheck the rules after every container change. docker run
-p 80:80 and compose ports: entries punch
holes through ufw on most distros without warning. The "I added a
service and it's accidentally public" bug starts here.
Step 8 — Plan for drift detection, not a one-time pass
You've now done the pre-exposure work. The question that decides whether the service stays safe is what happens in the months after.
The realistic threat model for a self-hosted Linux box isn't a zero-day from a nation-state. It's drift:
- A package update lands a CVE on a service you exposed in Step 1.
- An sshd_config edit during a debugging session leaves
PasswordAuthentication yesin place. - A Compose file gets
ports:added because someone wanted to test something locally and forgot to remove the line. - A TLS setting regresses after a reverse-proxy version bump re-enables a deprecated cipher for "compatibility."
- A new admin tool gets added behind the public reverse proxy with default credentials, "to fix later."
None of these are detectable by a one-time hardening pass. They're detectable by running the same audit nightly and looking at what changed since yesterday. That's the difference between hardening as a checkbox and hardening as a process.
Three options at the homelab scale:
- Lynis on a cron — run weekly, diff the report against last week's by hand.
- OSSEC / Wazuh — heavier, agent-based, designed for SIEM-style correlation. Worth it if you already have time-series infrastructure; usually overkill at homelab scale.
- An agentless scanner from your Mac. Noxen sits here — runs the same SSH-driven audit nightly, shows only what changed since the last scan, $79 one-time. Built for exactly this gap. (See agent vs agentless for the architectural trade-off.)
Whichever you pick, the principle is identical: the goal isn't to pass a one-time audit; it's to notice when the audit's answer changes.
Common mistakes
- Assuming a reverse proxy fixes auth. A proxy terminates TLS and routes; it doesn't authenticate users unless you explicitly configured it to.
- Leaving SSH password auth enabled because "I might need to log in from somewhere I don't have my key." Generate a new key on the new device and add it. That's the flow.
- Trusting "containers are isolated." A
container with
--network host, or a Compose file withports: 0.0.0.0:, is exactly as exposed as a bare process. - Skipping the external port scan. The view from inside the LAN is wrong. Always check from outside.
- Forgetting about IPv6. Many home routers
don't apply firewall rules to IPv6 by default. A service bound
to
[::]on a dual-stack host can be reachable even if the IPv4 firewall blocks it. - Treating hardening as one-time. The whole of Step 8.
FAQ
Do I need enterprise tooling for any of this?
No. Every check above runs with what's already on the box —
nmap, ss, the package manager, sshd,
ufw. Tools like Lynis, Wazuh, and Noxen automate the recurring
part; they don't replace the one-time pass.
Is Tailscale really enough that I can skip the rest?
Tailscale removes the public-internet attack surface, which is the highest-impact change you can make. SSH hardening, package patching, and admin-surface auth still matter — your ACLs aren't perfect, your devices can be lost or compromised, and lateral movement inside a flat tailnet is real. Tailscale lowers the ceiling; it doesn't raise the floor.
How often should I re-run this checklist?
The full checklist: every time you newly expose something. The audit-style version (Steps 2–7 as recurring checks): nightly via tooling, weekly by hand if not. See how often should you scan your homelab.
What's the single most overlooked item?
Step 6. Self-hosters tend to spend hours on TLS configuration and then leave a Pi-hole admin or a Portainer instance reachable on the same domain with default credentials. Exposed admin surfaces are the dominant compromise vector at the homelab scale.
Try Noxen — $79 one-time, agentless, diff-from-yesterday reports for your homelab and small VPS fleet.
Scan your Linux fleet from your Mac
Noxen runs nightly agentless audits over SSH and shows only what changed since the last scan — new CVEs, config drift, newly exposed admin services. Mac-native control plane, no SaaS round-trip.