Ubuntu 22.04/24.04 LTS & Debian 12/13 · systemd

Linux Server Hardening & Sysadmin

An interactive hardening checklist with saved progress and paste-ready commands — SSH keys, ufw, fail2ban, systemd sandboxing, users & permissions, sudo, sysctl, and unattended-upgrades.

Hardening
0%
0 / 0
Golden rule: never close your only SSH session until a second session works. After every SSH or firewall change, open a new terminal and confirm you can still log in before dropping the original. A locked-out cloud box means console/rescue mode or a rebuild. Snapshot the VM first if your provider supports it.
How to use this page. Tick each task as you finish it — progress is saved in your browser (localStorage), so you can close the tab and resume. Hit the button to copy any command block. Commands assume a Debian/Ubuntu host and a non-root user with sudo; notes call out RHEL/Fedora (dnf, firewalld) differences where they matter.

Quick Reference — highest-frequency lookups

TaskCommandNotes
New ed25519 key (client)ssh-keygen -t ed25519 -C "you@host"Default & fastest; ~256-bit security.
Install your key on serverssh-copy-id user@hostAppends to ~/.ssh/authorized_keys (mode 600).
Reload sshd safelysudo systemctl reload sshKeeps live sessions; ssh on Ubuntu, sshd on RHEL.
Open a firewall portsudo ufw allow 443/tcpThen sudo ufw enable once SSH is allowed.
Firewall statussudo ufw status verboseShows default policy + rules.
Ban status (fail2ban)sudo fail2ban-client status sshdLists currently banned IPs.
Unban an IPsudo fail2ban-client set sshd unbanip 1.2.3.4For when you ban yourself.
Who's listeningsudo ss -tulpnOpen TCP/UDP sockets + owning process.
Service status / logssystemctl status nginx · journalctl -u nginx -e-f to follow, -p err for errors only.
Failed loginssudo lastb | head · journalctl -u ssh -g "Failed"Audit brute-force attempts.
Pending security updatesapt list --upgradable 2>/dev/null | grep -i securityOr trust unattended-upgrades.
Make a file owner-onlychmod 600 file · chmod 700 dir600 = rw owner; 700 = rwx owner.
Add sudo useradduser deploy && usermod -aG sudo deploywheel group on RHEL.

Phase 0
First login & baseline

Patch everything before touching config

An unpatched box is the biggest hole; close it first. full-upgrade resolves dependency changes that plain upgrade holds back.

Debian / Ubuntu
sudo apt update && sudo apt full-upgrade -y
sudo apt autoremove --purge -y
RHEL / Fedora / Rocky
sudo dnf upgrade --refresh -y
Gotcha: if /var/run/reboot-required exists after upgrade, a new kernel/libc is staged — reboot during a maintenance window. Check live-patch coverage if you can't: sudo apt install needrestart then sudo needrestart.
Create a non-root sudo user (stop using root)

Day-to-day work as root means every typo runs with full privilege and there's no audit trail of who did what. Create a named human account.

create + grant sudo (Debian/Ubuntu)
sudo adduser deploy                 # interactive: sets password
sudo usermod -aG sudo deploy        # 'sudo' group grants admin
# verify:
groups deploy
RHEL/Fedora: the admin group is wheelsudo usermod -aG wheel deploy. Test sudo whoami as the new user before locking root out. See Phase 5 for sudoers detail.
Set hostname, timezone & time sync

Correct, monotonically-synced time is a security control: TLS cert validation, log correlation, TOTP/2FA, and Kerberos all break with clock drift.

identity + clock
sudo hostnamectl set-hostname web-01
sudo timedatectl set-timezone UTC          # UTC on servers avoids DST bugs
sudo timedatectl set-ntp true              # enable systemd-timesyncd
timedatectl status                         # confirm "System clock synchronized: yes"

Phase 1
SSH keys & daemon hardening

Generate a strong key pair (on your laptop, not the server)

ed25519 is the modern default: small, fast, and resistant to the RNG pitfalls that bit RSA. Use a passphrase + ssh-agent so a stolen laptop ≠ stolen server.

generate (run locally)
ssh-keygen -t ed25519 -a 100 -C "deploy@web-01 2026-06"
# -a 100 = 100 KDF rounds, slows brute-force of the passphrase
# For hosts stuck on very old OpenSSH (<6.5), fall back to:
ssh-keygen -t rsa -b 4096 -C "deploy@web-01 2026-06"
load into agent (avoids retyping passphrase)
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519          # macOS: ssh-add --apple-use-keychain
Pitfall: the private key (id_ed25519, no .pub) never leaves your machine. If you ever paste a key blob into a server, it must start with ssh-ed25519/ssh-rsa — that's the public half.
Install the public key & test login
easiest — ssh-copy-id
ssh-copy-id deploy@SERVER_IP        # prompts for password one last time
ssh deploy@SERVER_IP                # should log in with NO password prompt
manual fallback (if ssh-copy-id unavailable)
cat ~/.ssh/id_ed25519.pub | ssh deploy@SERVER_IP \
  "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
Do not proceed to disabling passwords until key login works. Permissions matter: ~/.ssh must be 700 and authorized_keys 600, or sshd silently ignores the key (see StrictModes).
Harden sshd via a drop-in (no root, no passwords)

Edit a drop-in under /etc/ssh/sshd_config.d/ rather than the main file — it survives package upgrades and keeps your intent isolated. Drop-ins are Included at the top, and first match wins in sshd, so your file overrides defaults below it.

/etc/ssh/sshd_config.d/99-hardening.conf
sudo tee /etc/ssh/sshd_config.d/99-hardening.conf >/dev/null <<'EOF'
# --- Authentication ---
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
PermitEmptyPasswords no
MaxAuthTries 3
LoginGraceTime 20
MaxSessions 4

# --- Limit who can log in ---
AllowUsers deploy

# --- Reduce attack surface ---
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding no
ClientAliveInterval 300
ClientAliveCountMax 2
EOF
validate, then reload (NOT restart)
sudo sshd -t && echo "config OK"     # aborts here if syntax is wrong
sudo systemctl reload ssh             # 'reload' keeps your current session alive
Test in a second terminal NOW: ssh deploy@SERVER_IP. If it works, you're safe to close the first session. If it fails, fix it from the still-open original.
Ubuntu 22.10+ uses socket activation (ssh.socket). Changing Port in a drop-in is ignored — set it via sudo systemctl edit ssh.socket (ListenStream=) or run sudo systemctl disable --now ssh.socket; sudo systemctl enable --now ssh.service. Port-changing is obscurity, not security — it cuts log noise but key-only auth is what actually protects you.
(Optional) Add TOTP 2FA for SSH

Defense in depth for high-value hosts: require key and a 6-digit code. Skip for fleet/automation boxes where a key in a vault is the better model.

install + enroll
sudo apt install -y libpam-google-authenticator
google-authenticator      # run as YOUR user; scan QR, save scratch codes
# /etc/pam.d/sshd : add ->  auth required pam_google_authenticator.so
# /etc/ssh/sshd_config.d/99-hardening.conf:
#   AuthenticationMethods publickey,keyboard-interactive
#   KbdInteractiveAuthentication yes
sudo systemctl reload ssh
Lockout risk: keep one session open and store the scratch codes off-box. Get it wrong and you need console access.

Phase 2
Firewall — ufw (default-deny)

Default-deny inbound, allow only what you serve

ufw is a friendly front-end to nftables. The cardinal sin is enabling the firewall before allowing SSH — do these in order.

order matters — allow SSH FIRST
sudo apt install -y ufw
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw limit OpenSSH          # 'limit' = rate-cap brute force (6 hits/30s -> deny)
sudo ufw allow 80/tcp           # http  (omit if no web server)
sudo ufw allow 443/tcp          # https
sudo ufw enable                 # answer 'y' — SSH already allowed, so safe
sudo ufw status verbose
limit vs allow for SSH: limit throttles repeated connections from one IP (cheap brute-force defense); pair it with fail2ban for real bans. If you changed the SSH port, use sudo ufw limit 2222/tcp instead of the OpenSSH app profile.
scope a rule to one source (e.g. admin allowlist)
sudo ufw allow from 203.0.113.10 to any port 22 proto tcp
sudo ufw status numbered         # list with rule numbers
sudo ufw delete 3                # remove rule #3
RHEL/Fedora ship firewalld: sudo firewall-cmd --permanent --add-service=ssh --add-service=https; sudo firewall-cmd --set-default-zone=drop; sudo firewall-cmd --reload. Don't run ufw and firewalld together.
Know the Docker / ufw bypass trap
Docker punches through ufw. A published port (-p 5432:5432) writes its own DOCKER nft chain ahead of ufw's rules, so your "blocked" database is exposed to the internet even though ufw status looks locked down.

Fixes: bind to localhost in compose (127.0.0.1:5432:5432), or install the ufw-docker rules. Verify reality from outside the box, not from ufw's own view.

verify exposure from another machine
# from your laptop, NOT the server:
nmap -Pn -p 22,80,443,5432,3306,6379 SERVER_IP
# anything OPEN that you didn't intend is a finding

Phase 3
fail2ban — ban brute-forcers

Install & configure jail.local

fail2ban scans logs and temporarily bans IPs that fail auth repeatedly. Always edit jail.local, never jail.conf — the .conf is overwritten on upgrade.

install
sudo apt install -y fail2ban
/etc/fail2ban/jail.local
sudo tee /etc/fail2ban/jail.local >/dev/null <<'EOF'
[DEFAULT]
bantime  = 1h
findtime = 10m
maxretry = 5
# never ban yourself — add your office/home IP:
ignoreip = 127.0.0.1/8 ::1 203.0.113.10
# escalate repeat offenders:
bantime.increment = true
bantime.factor    = 2
bantime.maxtime   = 1w

[sshd]
enabled = true
EOF
Backend note: On modern systemd hosts SSH logs go to the journal, not /var/log/auth.log. fail2ban's shipped sshd jail defaults to backend = systemd on Debian/Ubuntu, so it works out of the box. If bans never fire, set backend = systemd explicitly in the [sshd] block.
enable + verify
sudo systemctl enable --now fail2ban
sudo fail2ban-client status            # lists active jails
sudo fail2ban-client status sshd       # banned IPs, total failures
if you ban yourself
sudo fail2ban-client set sshd unbanip 203.0.113.10

Phase 4
Automatic security updates

unattended-upgrades for security patches

Most breaches exploit known, patched CVEs. Auto-apply the security pocket only — leave feature upgrades manual to avoid surprise breakage.

install + enable
sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades   # answer "Yes"
/etc/apt/apt.conf.d/20auto-upgrades (the schedule)
sudo tee /etc/apt/apt.conf.d/20auto-upgrades >/dev/null <<'EOF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
EOF
tune /etc/apt/apt.conf.d/50unattended-upgrades
# Uncomment to auto-reboot when a patch needs it (e.g. kernel):
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "03:30";
# Email yourself on action (needs a configured MTA):
Unattended-Upgrade::Mail "[email protected]";
Unattended-Upgrade::MailReport "on-change";
dry-run to prove it works
sudo unattended-upgrade --dry-run --debug | tail -n 20
Trade-off: auto-reboot at 03:30 fixes kernel CVEs without you, but interrupts service — only enable on hosts that tolerate a brief restart, or where you run live-patching (Ubuntu Pro / kpatch). RHEL/Fedora: use dnf-automatic with apply_updates = yes in /etc/dnf/automatic.conf.

Phase 5
Users, groups & sudo

Edit sudoers the safe way (visudo)

A syntax error in /etc/sudoers can lock out all admin access. visudo validates before saving; drop-ins under /etc/sudoers.d/ keep changes modular.

grant scoped, password-required sudo
sudo visudo -f /etc/sudoers.d/deploy
# put a single line, then save:
deploy ALL=(ALL:ALL) ALL
# validate the whole tree:
sudo visudo -c
Avoid blanket NOPASSWD: ALL. It turns any RCE in a service the user can reach into instant root. If automation needs it, scope to exact commands: deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart myapp.
audit who has root-equivalent power
getent group sudo          # Debian/Ubuntu admins (wheel on RHEL)
sudo grep -RvE '^\s*#|^\s*$' /etc/sudoers /etc/sudoers.d/   # all live rules
awk -F: '$3==0 {print $1}' /etc/passwd                      # UID 0 = root; should be ONLY 'root'
Lock unused accounts & enforce password policy
lock the password & shell of service/stale accounts
sudo passwd -l olduser                 # lock password
sudo usermod -s /usr/sbin/nologin olduser   # deny interactive shell
# find accounts WITH a login shell:
getent passwd | awk -F: '$7 ~ /(bash|sh|zsh)$/ {print $1, $7}'
password aging & strength (libpam-pwquality)
sudo apt install -y libpam-pwquality
# /etc/login.defs:
#   PASS_MAX_DAYS 365
#   PASS_MIN_DAYS 1
# apply max-age to an existing user:
sudo chage --maxdays 365 --warndays 14 deploy
sudo chage -l deploy                   # review aging
Set a safe default umask so new files aren't world-readable: confirm 027 (or 077 for single-tenant) in /etc/login.defs (UMASK 027). 027 → new files 640, dirs 750.

Phase 6
File permissions & ownership

Lock down secrets & audit risky permissions
secrets are owner-only
chmod 600 ~/.ssh/authorized_keys ~/.ssh/id_*    # rw owner only
chmod 700 ~/.ssh                                # rwx owner only
chmod 600 .env config/secrets.yml               # app secrets
sudo chown -R deploy:deploy /srv/myapp
hunt world-writable files & stray SUID binaries
# world-writable files outside /tmp (privilege-escalation risk):
sudo find / -xdev -type f -perm -0002 ! -path '/proc/*' 2>/dev/null
# SUID/SGID binaries — baseline this list; investigate anything unexpected:
sudo find / -xdev -type f \( -perm -4000 -o -perm -2000 \) -exec ls -l {} \; 2>/dev/null

Octal permission reference

OctalSymbolicMeaningTypical use
600rw-------owner read/writeprivate keys, .env
640rw-r-----owner rw, group rshared config, logs
644rw-r--r--owner rw, all readweb assets, public files
700rwx------owner only~/.ssh, private dirs
750rwxr-x---owner rwx, group rxapp dirs, scripts (group)
755rwxr-xr-xowner rwx, all rxbinaries, public dirs
1777rwxrwxrwtworld-write + sticky/tmp (sticky = only owner deletes)
4755rwsr-xr-xSUID — runs as owner/usr/bin/passwd (audit these!)

Digit math: r=4, w=2, x=1 — add them. Leading 4th digit: 4=SUID, 2=SGID, 1=sticky. chmod -R on a tree is dangerous — it strips x from files or adds it everywhere; prefer find … -type d -exec chmod 750 and -type f -exec chmod 640 separately.

Phase 7
systemd units & service sandboxing

Write a hardened service unit

Run apps as a dedicated unprivileged user with kernel-enforced sandboxing — these directives are free defense-in-depth that contain a compromised process.

/etc/systemd/system/myapp.service
[Unit]
Description=My App
After=network-online.target
Wants=network-online.target

[Service]
# Auto-create a transient unprivileged user; no shell, no home:
DynamicUser=yes
WorkingDirectory=/srv/myapp
ExecStart=/srv/myapp/bin/server
Restart=on-failure
RestartSec=5

# --- Sandboxing (kernel-enforced) ---
NoNewPrivileges=yes          # block setuid escalation
ProtectSystem=strict         # whole FS read-only...
ProtectHome=yes              # ...and /home, /root hidden
ReadWritePaths=/var/lib/myapp   # ...except what it truly needs
PrivateTmp=yes               # private /tmp, /var/tmp
PrivateDevices=yes           # no raw device access
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM

[Install]
WantedBy=multi-user.target
apply + score the hardening
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
systemd-analyze security myapp        # lower "exposure" score = better; aim < 5.0
Gotcha: tighten incrementally. ProtectSystem=strict + SystemCallFilter can break apps that write outside ReadWritePaths or use exotic syscalls. Watch journalctl -u myapp -e after each tightening and loosen the one directive that breaks.
Disable services you don't need

Every listening service is attack surface. Audit what's enabled and what's actually on a port.

enumerate & prune
systemctl list-units --type=service --state=running   # what's running
sudo ss -tulpn                                        # what's LISTENING + pid
systemctl list-unit-files --state=enabled             # what starts at boot
# turn one off completely:
sudo systemctl disable --now cups avahi-daemon 2>/dev/null
Common removable extras on a headless server: cups (printing), avahi-daemon (mDNS), ModemManager, bluetooth. Confirm nothing depends on them first with systemctl list-dependencies.

Phase 8
Kernel / network sysctl hardening

Apply network & kernel sysctl baseline

Drop in a sysctl file (loaded in lexical order, last wins). These curb spoofing, redirects, and info leaks. Test on a staging box first if you run unusual networking.

/etc/sysctl.d/99-hardening.conf
sudo tee /etc/sysctl.d/99-hardening.conf >/dev/null <<'EOF'
# --- Spoofing / routing ---
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.all.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.all.log_martians = 1

# --- ICMP / SYN ---
net.ipv4.icmp_echo_ignore_broadcasts = 1
net.ipv4.icmp_ignore_bogus_error_responses = 1
net.ipv4.tcp_syncookies = 1

# --- Kernel info leaks / exploit mitigation ---
kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1
kernel.randomize_va_space = 2
fs.protected_hardlinks = 1
fs.protected_symlinks = 1
fs.suid_dumpable = 0
EOF
sudo sysctl --system        # load all sysctl.d files now
Don't blindly disable IP forwarding (net.ipv4.ip_forward) on a Docker/Kubernetes/router host — it breaks container and pod networking. Leave it as-is on those roles.

Phase 9
Audit, logging & intrusion checks

Enable auditd & review the journal
auditd — tamper-evident kernel audit log
sudo apt install -y auditd audispd-plugins
sudo systemctl enable --now auditd
# watch a sensitive file for any change:
sudo auditctl -w /etc/passwd -p wa -k passwd_changes
sudo ausearch -k passwd_changes -i        # human-readable matches
persist the journal & cap its size
sudo mkdir -p /var/log/journal
sudo sed -i 's/^#\?Storage=.*/Storage=persistent/;s/^#\?SystemMaxUse=.*/SystemMaxUse=500M/' /etc/systemd/journald.conf
sudo systemctl restart systemd-journald
journalctl --disk-usage
daily triage one-liners
journalctl -p err -b               # all errors this boot
journalctl -u ssh -g "Accepted|Failed" --since "24 hours ago"
sudo lastb | head                  # recent failed logins (btmp)
last -a | head                     # recent successful logins
who -a                             # who is on right now
Verify backups & restore path (the real security control)

Hardening reduces the odds; backups decide whether an incident is an inconvenience or a catastrophe. A backup you've never restored is a hypothesis.

3-2-1 rule: 3 copies, on 2 media types, 1 off-site/immutable. Ransomware deletes online backups — keep one copy the server can't reach (pull-based or object-lock).
test a restore (don't just trust the cron job)
# example with restic — verify integrity, then do a trial restore:
restic snapshots
restic check --read-data-subset=10%
restic restore latest --target /tmp/restore-test --include /etc

Post-hardening verification

Run these to confirm the work actually took effect — from the server and, for the firewall, from outside it.

on the server
sshd -T | grep -Ei 'permitrootlogin|passwordauthentication|pubkeyauthentication|maxauthtries'
sudo ufw status verbose
sudo fail2ban-client status sshd
systemctl is-enabled unattended-upgrades fail2ban
sudo ss -tulpn                       # only intended ports listening?
awk -F: '$3==0{print $1}' /etc/passwd # only 'root' has UID 0?
from your laptop (external truth)
nmap -Pn -sV --top-ports 100 SERVER_IP    # what's actually reachable
ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no deploy@SERVER_IP
# ^ should be REJECTED ("Permission denied (publickey)") = passwords are off
Optional auditors: sudo lynis audit system (CIS-style scoring & suggestions) and debsecan / ares for CVE exposure. Treat their output as a to-do list, not a grade.

Common mistakes & anti-patterns

Enabling ufw before allowing SSH. Instant lockout. Always ufw limit OpenSSH first, then ufw enable.
Closing your only session after an sshd change. Keep it open; verify a fresh login in a second terminal first.
Disabling passwords before testing keys. Confirm key login works and ~/.ssh=700 / authorized_keys=600, or sshd ignores the key.
Editing jail.conf / sshd_config / sudoers directly. Use jail.local, a sshd_config.d/ drop-in, and visudo — upgrade-safe and validated.
Trusting ufw status with Docker. Published ports bypass ufw. Bind to 127.0.0.1 and verify with external nmap.
NOPASSWD: ALL for convenience. Any app-level RCE becomes instant root. Scope sudo to exact commands.
chmod -R 777 to "fix permissions". It makes everything world-writable forever. Find the real owner/mode instead.
Port-knocking / non-standard port = "secure". It only cuts log noise. Key-only auth + fail2ban is the real control.
restart instead of reload for sshd. Both keep existing sessions, but reload is gentler; either way, never assume — test.
No backups / untested restore. Hardening can't undo deletion or ransomware. 3-2-1, and actually restore.

Maintenance cadence

WhenDo
Daily (automated)unattended-upgrades applies security patches; fail2ban bans abusers; journald rotates logs.
WeeklySkim journalctl -p err -b and fail2ban-client status sshd. Check /var/run/reboot-required.
MonthlyReview users with shells & sudo; re-run ss -tulpn and external nmap; run lynis audit system; verify a backup restore.
QuarterlyRotate SSH keys / secrets; review firewall rules for drift; audit SUID list against baseline; confirm LTS still in support window.
On eventAfter any breach indicator: rotate all credentials, review last/lastb/ausearch, and assume the box is untrusted until proven clean (often: rebuild from known-good image).