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
| Task | Command | Notes |
|---|---|---|
| New ed25519 key (client) | ssh-keygen -t ed25519 -C "you@host" | Default & fastest; ~256-bit security. |
| Install your key on server | ssh-copy-id user@host | Appends to ~/.ssh/authorized_keys (mode 600). |
| Reload sshd safely | sudo systemctl reload ssh | Keeps live sessions; ssh on Ubuntu, sshd on RHEL. |
| Open a firewall port | sudo ufw allow 443/tcp | Then sudo ufw enable once SSH is allowed. |
| Firewall status | sudo ufw status verbose | Shows default policy + rules. |
| Ban status (fail2ban) | sudo fail2ban-client status sshd | Lists currently banned IPs. |
| Unban an IP | sudo fail2ban-client set sshd unbanip 1.2.3.4 | For when you ban yourself. |
| Who's listening | sudo ss -tulpn | Open TCP/UDP sockets + owning process. |
| Service status / logs | systemctl status nginx · journalctl -u nginx -e | -f to follow, -p err for errors only. |
| Failed logins | sudo lastb | head · journalctl -u ssh -g "Failed" | Audit brute-force attempts. |
| Pending security updates | apt list --upgradable 2>/dev/null | grep -i security | Or trust unattended-upgrades. |
| Make a file owner-only | chmod 600 file · chmod 700 dir | 600 = rw owner; 700 = rwx owner. |
| Add sudo user | adduser deploy && usermod -aG sudo deploy | wheel 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.
sudo apt update && sudo apt full-upgrade -y
sudo apt autoremove --purge -ysudo dnf upgrade --refresh -y/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.
sudo adduser deploy # interactive: sets password
sudo usermod -aG sudo deploy # 'sudo' group grants admin
# verify:
groups deploywheel — sudo 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.
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.
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"eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519 # macOS: ssh-add --apple-use-keychainid_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
ssh-copy-id deploy@SERVER_IP # prompts for password one last time
ssh deploy@SERVER_IP # should log in with NO password promptcat ~/.ssh/id_ed25519.pub | ssh deploy@SERVER_IP \
"mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"~/.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.
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
EOFsudo sshd -t && echo "config OK" # aborts here if syntax is wrong
sudo systemctl reload ssh # 'reload' keeps your current session alivessh deploy@SERVER_IP. If it works, you're safe to close the first session. If it fails, fix it from the still-open original.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.
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 sshPhase 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.
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 verboselimit 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.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 #3sudo 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
-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.
# 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 findingPhase 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.
sudo apt install -y fail2bansudo 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/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.sudo systemctl enable --now fail2ban
sudo fail2ban-client status # lists active jails
sudo fail2ban-client status sshd # banned IPs, total failuressudo fail2ban-client set sshd unbanip 203.0.113.10Phase 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.
sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades # answer "Yes"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# 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";sudo unattended-upgrade --dry-run --debug | tail -n 20dnf-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.
sudo visudo -f /etc/sudoers.d/deploy
# put a single line, then save:
deploy ALL=(ALL:ALL) ALL
# validate the whole tree:
sudo visudo -cNOPASSWD: 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.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
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}'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 aging027 (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
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# 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/nullOctal permission reference
| Octal | Symbolic | Meaning | Typical use |
|---|---|---|---|
| 600 | rw------- | owner read/write | private keys, .env |
| 640 | rw-r----- | owner rw, group r | shared config, logs |
| 644 | rw-r--r-- | owner rw, all read | web assets, public files |
| 700 | rwx------ | owner only | ~/.ssh, private dirs |
| 750 | rwxr-x--- | owner rwx, group rx | app dirs, scripts (group) |
| 755 | rwxr-xr-x | owner rwx, all rx | binaries, public dirs |
| 1777 | rwxrwxrwt | world-write + sticky | /tmp (sticky = only owner deletes) |
| 4755 | rwsr-xr-x | SUID — 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.
[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.targetsudo systemctl daemon-reload
sudo systemctl enable --now myapp
systemd-analyze security myapp # lower "exposure" score = better; aim < 5.0ProtectSystem=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.
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/nullcups (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.
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 nownet.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
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 matchessudo 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-usagejournalctl -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 nowVerify 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.
# 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 /etcPost-hardening verification
Run these to confirm the work actually took effect — from the server and, for the firewall, from outside it.
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?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 offsudo 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
ufw limit OpenSSH first, then ufw enable.~/.ssh=700 / authorized_keys=600, or sshd ignores the key.jail.conf / sshd_config / sudoers directly. Use jail.local, a sshd_config.d/ drop-in, and visudo — upgrade-safe and validated.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.restart instead of reload for sshd. Both keep existing sessions, but reload is gentler; either way, never assume — test.Maintenance cadence
| When | Do |
|---|---|
| Daily (automated) | unattended-upgrades applies security patches; fail2ban bans abusers; journald rotates logs. |
| Weekly | Skim journalctl -p err -b and fail2ban-client status sshd. Check /var/run/reboot-required. |
| Monthly | Review users with shells & sudo; re-run ss -tulpn and external nmap; run lynis audit system; verify a backup restore. |
| Quarterly | Rotate SSH keys / secrets; review firewall rules for drift; audit SUID list against baseline; confirm LTS still in support window. |
| On event | After 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). |