Linux Server Hardening Checklist for Self-Hosters

You spun up a server, installed Docker, and deployed a dozen services. Everything works. But your SSH port is open to the world with password auth, you’re running everything as root, and your firewall is… what firewall?

Most self-hosting guides skip security entirely. This one doesn’t. Here’s a practical, ordered checklist to harden your Linux server without breaking the services running on it.

Who This Is For

Anyone running a self-hosted server — whether it’s a Raspberry Pi on your desk, a mini PC in the closet, or a VPS at Hetzner. You don’t need to be a sysadmin. You just need a terminal and 30 minutes.

1. Create a Non-Root User

If you’re logging in as root, stop. Create a regular user and give it sudo access.

adduser deploy
usermod -aG sudo deploy

Log out, log back in as deploy, and confirm sudo works:

sudo whoami
# root

From here on, everything runs as this user with sudo when needed. The root account stays locked for direct login.

2. Lock Down SSH

SSH is the front door to your server. Every bot on the internet is trying default passwords against it right now. Fix that.

Disable Root Login and Password Auth

Edit /etc/ssh/sshd_config:

sudo nano /etc/ssh/sshd_config

Set these values (uncomment if needed):

PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
MaxAuthTries 3
LoginGraceTime 30

Before restarting SSH, make sure your SSH key is already on the server:

# From your local machine
ssh-copy-id deploy@your-server-ip

Then restart SSH:

sudo systemctl restart sshd

Don’t close your current session until you’ve confirmed you can log in with your key in a new terminal. Locking yourself out is not fun.

Change the SSH Port (Optional but Effective)

Moving SSH off port 22 eliminates 99% of automated attacks. They don’t scan every port — they hammer 22.

Port 2222

Pick any unused port between 1024–65535. Update your SSH config on your local machine so you don’t forget:

# ~/.ssh/config
Host myserver
    HostName your-server-ip
    Port 2222
    User deploy

3. Set Up a Firewall with UFW

UFW (Uncomplicated Firewall) is the simplest way to manage iptables on Ubuntu/Debian.

sudo apt install ufw

# Default: deny all incoming, allow all outgoing
sudo ufw default deny incoming
sudo ufw default allow outgoing

# Allow SSH (use your custom port if you changed it)
sudo ufw allow 2222/tcp comment 'SSH'

# Allow HTTP/HTTPS for your reverse proxy
sudo ufw allow 80/tcp comment 'HTTP'
sudo ufw allow 443/tcp comment 'HTTPS'

# Enable
sudo ufw enable

Check the status:

sudo ufw status verbose

Important for Docker users: Docker manipulates iptables directly and bypasses UFW by default. A container with -p 8080:80 is exposed to the internet even if UFW blocks port 8080. Fix this by editing /etc/docker/daemon.json:

{
  "iptables": false
}

Then restart Docker:

sudo systemctl restart docker

With iptables: false, Docker won’t add its own firewall rules. You’ll need to handle port forwarding through your reverse proxy (which you should be doing anyway). If you bind containers to 127.0.0.1 (e.g., 127.0.0.1:8080:80), they’re only accessible locally — your reverse proxy forwards traffic to them.

4. Install Fail2Ban

Fail2Ban watches log files and bans IPs that show malicious behavior (brute-force attempts, repeated 404s, etc.).

sudo apt install fail2ban

Create a local config (don’t edit the defaults — they get overwritten on updates):

sudo nano /etc/fail2ban/jail.local
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 3
banaction = ufw

[sshd]
enabled = true
port = 2222
logpath = /var/log/auth.log

Start it:

sudo systemctl enable fail2ban
sudo systemctl start fail2ban

Check who’s been banned:

sudo fail2ban-client status sshd

You’ll be surprised how quickly the ban list fills up — even on a “nobody knows about” server.

5. Enable Automatic Security Updates

Unpatched vulnerabilities are the #1 way servers get compromised. Automatic security updates fix this with zero effort.

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

Verify it’s configured:

cat /etc/apt/apt.conf.d/20auto-upgrades

Should show:

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

For more control, edit /etc/apt/apt.conf.d/50unattended-upgrades. You can enable email notifications, automatic reboots, and blacklist specific packages:

Unattended-Upgrade::Mail "[email protected]";
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "04:00";

6. Secure Docker Containers

Docker is powerful but runs containers as root by default. A container escape means full server compromise.

Don’t Run Containers as Root

Most images support running as a non-root user:

services:
  myapp:
    image: myapp:latest
    user: "1000:1000"

Use Read-Only Filesystems Where Possible

services:
  myapp:
    image: myapp:latest
    read_only: true
    tmpfs:
      - /tmp
      - /run

Limit Container Capabilities

By default, Docker grants containers a wide set of Linux capabilities. Drop them all and add back only what’s needed:

services:
  myapp:
    image: myapp:latest
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE

Keep Images Updated

Outdated images have known CVEs. Update regularly:

docker compose pull
docker compose up -d

Consider tools like Watchtower for automatic updates, or Diun for notifications without auto-updating.

7. Set Up Log Monitoring

Logs are useless if nobody reads them. At minimum, know where to look:

  • Auth attempts: /var/log/auth.log
  • System events: journalctl -xe
  • Docker containers: docker logs <container>
  • Firewall blocks: sudo ufw show added or journalctl | grep UFW

For a more structured approach, self-host a log aggregator:

  • Dozzle — Lightweight Docker log viewer (read-only, no database)
  • Loki + Grafana — Full log pipeline with search, dashboards, and alerts
  • Graylog — Enterprise-grade centralized logging

At minimum, install Dozzle — it takes 30 seconds and gives you a web UI for all container logs.

8. Disable Unused Services

Every running service is an attack surface. Check what’s listening:

sudo ss -tlnp

Look for anything you don’t recognize. Common culprits:

  • rpcbind — NFS-related, rarely needed for self-hosting
  • cups — Printer service, not useful on headless servers
  • avahi-daemon — mDNS/Bonjour, usually unnecessary on VPS

Disable what you don’t need:

sudo systemctl disable --now rpcbind
sudo systemctl disable --now cups
sudo systemctl disable --now avahi-daemon

9. File Permissions and Ownership

Sensitive files should have strict permissions:

# SSH config
sudo chmod 600 /etc/ssh/sshd_config
sudo chmod 700 ~/.ssh
sudo chmod 600 ~/.ssh/authorized_keys

# Docker socket (already restricted, but verify)
ls -la /var/run/docker.sock
# Should be: srw-rw---- root docker

# .env files with secrets
chmod 600 .env
chmod 600 docker-compose.yml

Never store secrets in Docker images or Dockerfiles. Use .env files or Docker secrets.

10. Bonus: Additional Hardening

These aren’t critical for every setup but add defense in depth:

CrowdSec (Community Threat Intelligence)

Like Fail2Ban but with shared blocklists. When one server detects an attacker, all CrowdSec users benefit. See our CrowdSec guide for setup instructions.

Authelia or Authentik (SSO/2FA)

Add authentication in front of services that don’t have their own. Pair with your reverse proxy to protect admin panels, dashboards, and internal tools.

WireGuard or Tailscale (VPN Access)

Instead of exposing services publicly, access them through a VPN. Tailscale is the easiest option — zero firewall config, works behind NAT. See our Tailscale guide.

Kernel Hardening (sysctl)

For advanced users:

sudo nano /etc/sysctl.d/99-hardening.conf
# Prevent IP spoofing
net.ipv4.conf.all.rp_filter = 1

# Ignore ICMP redirects
net.ipv4.conf.all.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0

# Don't send ICMP redirects
net.ipv4.conf.all.send_redirects = 0

# Enable SYN flood protection
net.ipv4.tcp_syncookies = 1

# Disable source routing
net.ipv4.conf.all.accept_source_route = 0
net.ipv6.conf.all.accept_source_route = 0

Apply:

sudo sysctl --system

The Quick Checklist

For the skimmers — here’s the condensed version:

StepActionPriority
1Non-root user with sudoCritical
2SSH keys only, no root loginCritical
3UFW firewall + Docker iptables fixCritical
4Fail2Ban on SSHHigh
5Automatic security updatesHigh
6Docker: non-root, read-only, cap_dropMedium
7Log monitoring (at least Dozzle)Medium
8Disable unused servicesMedium
9Strict file permissionsMedium
10CrowdSec, SSO, VPN, sysctlNice to have

What Not to Do

  • Don’t disable the firewall “temporarily” — temporary becomes permanent.
  • Don’t share your SSH key across every server. One key per machine or use an SSH agent.
  • Don’t expose Docker socket to containers unless absolutely necessary (Portainer, Traefik). The Docker socket is root access to your host.
  • Don’t skip backups thinking security alone is enough. Hardening prevents attacks; backups survive them.

Wrapping Up

You don’t need to do everything at once. Start with the critical items — non-root user, SSH lockdown, and firewall. That alone puts you ahead of most self-hosters.

Then work through the rest over a weekend. Each step compounds. A server with all ten items checked off is significantly harder to compromise than the default Ubuntu install most people run.

Security isn’t paranoia — it’s maintenance. Like changing oil in a car. Do it regularly, and your server keeps running. Skip it, and you’ll find out the hard way.