Subsections of deb13

banner

sudo apt-get update -qq
sudo apt-get install -y -qq git figlet bc
git clone -q https://github.com/xeylou/my-motd-s.git /tmp/my-motd-s
sudo cp /tmp/my-motd-s/gnu-linux/banner_4.txt /etc/ssh/banner
sudo tee /etc/ssh/sshd_config.d/02-banner.conf > /dev/null << 'EOF'
Banner /etc/ssh/banner
# PrintLastLog no
EOF
sudo sshd -t && sudo systemctl reload ssh
sudo cp /tmp/my-motd-s/gnu-linux/update-motd.d-en/* /etc/update-motd.d/
sudo chmod 755 /etc/update-motd.d/[0-3]*
sudo rm -f /etc/motd /tmp/my-motd-s
run-parts --lsbsysinit /etc/update-motd.d

docker

sudo apt-get remove $(dpkg --get-selections docker.io docker-compose docker-doc docker-buildx podman-docker containerd runc | cut -f1)
sudo apt-get update -qq
sudo apt-get install -y -qq ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/debian
Suites: $(. /etc/os-release && echo "$VERSION_CODENAME")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt-get update -qq
sudo apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker $USER
systemctl status --no-pager docker

hardening

based on sshaudit.com, updated for deb13 openssh 10 for the ssh part

keep a parallel ssh session open until the end

prep

safety

cat /home/$USER/.ssh/authorized_keys

ssh.socket

ssh.socket is active, so Port and AddressFamily directives get ignored and systemctl reload ssh fails with “Cannot bind any address”

# expected disabled for the socket && enabled for the service
systemctl is-enabled ssh.socket ssh.service
sudo systemctl disable --now ssh.socket

ssh

service

sudo rm /etc/ssh/ssh_host_*
sudo ssh-keygen -t ed25519 -f /etc/ssh/ssh_host_ed25519_key -N ""
# clients must clear the old one (ssh-keygen -R <ip>)
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub

# wipes the hardening in 10 min unless cancelled
sudo systemd-run --on-active=10min --unit=sshd-rollback \
  sh -c 'rm -f /etc/ssh/sshd_config.d/00-hardening.conf && systemctl reload ssh'

sudo tee /etc/ssh/sshd_config.d/00-hardening.conf > /dev/null << 'EOF'
HostKey /etc/ssh/ssh_host_ed25519_key

KexAlgorithms mlkem768x25519-sha256
Ciphers chacha20-poly1305@openssh.com
# kept in case ciphers get extended
MACs hmac-sha2-512-etm@openssh.com

HostKeyAlgorithms sk-ssh-ed25519-cert-v01@openssh.com,ssh-ed25519-cert-v01@openssh.com,sk-ssh-ed25519@openssh.com,ssh-ed25519
PubkeyAcceptedAlgorithms sk-ssh-ed25519-cert-v01@openssh.com,ssh-ed25519-cert-v01@openssh.com,sk-ssh-ed25519@openssh.com,ssh-ed25519
# inert w/o TrustedUserCAKeys, kept in case a ca shows up one day
CASignatureAlgorithms sk-ssh-ed25519@openssh.com,ssh-ed25519

PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
X11Forwarding no
DebianBanner no
AllowUsers debian
AuthenticationMethods publickey
MaxAuthTries 3
LoginGraceTime 30
LogLevel VERBOSE
ClientAliveInterval 300
ClientAliveCountMax 2
EOF

sudo sshd -t && sudo systemctl reload ssh
sudo sshd -T | grep -iE 'passwordauthentication|kbdinteractive|permitrootlogin'

# from ANOTHER terminal, must succeed before going further:
# ssh -o IdentitiesOnly=yes -i <key> debian@<ip>

# then cancel the rollback
sudo systemctl stop sshd-rollback.timer

ipv4 only

if no use of ipv6 (see ipv6 below)

echo 'AddressFamily inet' | sudo tee /etc/ssh/sshd_config.d/10-ipv4only.conf
sudo sshd -t && sudo systemctl reload ssh
sudo ss -tlnp | grep sshd

firewall

single apply path for everything nft: sudo nft -c -f /etc/nftables.conf && sudo systemctl reload nftables. reloading replays flush ruleset, which also wipes fail2ban runtime tables → always sudo systemctl restart fail2ban right after (once it is installed)

base

sudo apt-get update -qq
sudo apt-get install -y -qq nftables
sudo install -d -m 0755 /etc/nftables.d

sudo cp -a /etc/nftables.conf "/etc/nftables.conf.bak.$(date +%F-%H%M%S)"
sudo tee /etc/nftables.conf > /dev/null << 'EOF'
#!/usr/sbin/nft -f
# everything lives in /etc/nftables.d/, this file only orchestrates.
# reloading flushes fail2ban tables -> systemctl restart fail2ban after.
flush ruleset
define ssh_port = 22
include "/etc/nftables.d/*.nft"
EOF

sudo tee /etc/nftables.d/10-filter.nft > /dev/null << 'EOF'
#!/usr/sbin/nft -f
# main input chain, default drop.
# stack: 51-icmp (-10) and 50-ssh-ratelimit (-5) shave, fail2ban (-1) bans,
# this chain (0) decides.
table inet filter_main
delete table inet filter_main
table inet filter_main {
    chain input {
        type filter hook input priority filter; policy drop;
        iif "lo" accept
        ct state established,related accept
        ct state invalid drop
        # icmp needed for pmtud; fine-grained drops live in 51-icmp.nft
        ip protocol icmp icmp type { echo-request, destination-unreachable, time-exceeded, parameter-problem } accept
        # meta l4proto ipv6-icmp accept   # uncomment if ipv6 comes back (ndp is vital)
        # udp sport 67 udp dport 68 accept   # uncomment if the vps gets its ip via dhcp
        tcp dport $ssh_port ct state new accept
    }
    # forward/output: accept. docker will manage forward when it lands.
}
EOF

# rollback timer: opens everything back up in 5 min unless cancelled
sudo systemd-run --on-active=5min --unit=fw-rollback \
  sh -c 'nft flush ruleset; systemctl try-restart fail2ban'

sudo nft -c -f /etc/nftables.conf
sudo systemctl enable --now nftables
sudo systemctl reload nftables

# from ANOTHER terminal: a new ssh session must work,
# and from outside `nc -vz <ip> 80` must time out. then:
sudo systemctl stop fw-rollback.timer

sudo nft list chain inet filter_main input

ssh ratelimit

sudo tee /etc/nftables.d/50-ssh-ratelimit.nft > /dev/null << 'EOF'
#!/usr/sbin/nft -f
# $ssh_port comes from /etc/nftables.conf
table inet ssh_ratelimit
delete table inet ssh_ratelimit
table inet ssh_ratelimit {
    set flood4 {
        type ipv4_addr
        flags dynamic
        size 65536
        timeout 10m
    }
    set flood6 {
        type ipv6_addr
        flags dynamic
        size 65536
        timeout 10m
    }
    chain input {
        type filter hook input priority -5; policy accept;
        tcp dport $ssh_port ct state new update @flood4 { ip saddr limit rate over 10/minute burst 5 packets } counter drop
        tcp dport $ssh_port ct state new update @flood6 { ip6 saddr limit rate over 10/minute burst 5 packets } counter drop
    }
}
EOF

sudo nft -c -f /etc/nftables.conf && sudo systemctl reload nftables
sudo nft list table inet ssh_ratelimit

icmp

sudo tee /etc/nftables.d/51-icmp.nft > /dev/null << 'EOF'
#!/usr/sbin/nft -f
# icmp/icmpv6 hardening, drops only. accepts live in 10-filter.nft.
table inet icmp_hardening
delete table inet icmp_hardening
table inet icmp_hardening {
    chain input_icmp {
        type filter hook input priority filter - 10; policy accept;

        meta l4proto icmp icmp type echo-request limit rate over 5/second burst 10 packets counter drop comment "icmp echo flood"

        meta l4proto icmp icmp type { redirect, router-advertisement, router-solicitation, timestamp-request, timestamp-reply, info-request, info-reply, address-mask-request, address-mask-reply } counter drop comment "useless icmp types"

        icmpv6 type echo-request limit rate over 5/second burst 10 packets counter drop comment "icmpv6 echo flood"

        icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert, nd-router-advert, nd-router-solicit } ip6 hoplimit != 255 counter drop comment "ndp invalid hoplimit"

        icmpv6 type nd-redirect counter drop comment "icmpv6 redirect"
    }
}
EOF

sudo nft -c -f /etc/nftables.conf && sudo systemctl reload nftables
sudo nft list table inet icmp_hardening

fail2ban

sudo apt-get update -qq
sudo apt-get install -qq -y fail2ban python3-systemd

sudo tee /etc/fail2ban/jail.d/sshd-strict.local > /dev/null << 'EOF'
[DEFAULT]
banaction = nftables-multiport
banaction_allports = nftables-allports
bantime.increment = true
bantime.factor = 4
bantime.maxtime = 5w
# add your static ip here if you have one (maxretry 2 self-ban is easy)
ignoreip = 127.0.0.1/8

[sshd]
enabled = true
backend = systemd
mode = aggressive
port = 22
maxretry = 2
findtime = 6h
bantime = 24h
EOF

sudo systemctl enable --now fail2ban
sudo systemctl restart fail2ban
sleep 2
sudo fail2ban-client status sshd
sudo tail -n 50 /var/log/fail2ban.log

sudo fail2ban-client get sshd journalmatch
sudo fail2ban-client status sshd
# sudo fail2ban-client set sshd unbanip <ip>

only if “Total failed” stays at 0 after a failed attempt, journalmatch may target sshd.service / _COMM=sshd instead of debian’s ssh.service and openssh 10’s sshd-session:

sudo tee /etc/fail2ban/jail.d/sshd-journalmatch.local > /dev/null << 'EOF'
[sshd]
journalmatch = _SYSTEMD_UNIT=ssh.service + _COMM=sshd-session
EOF
sudo systemctl restart fail2ban

sysctl

network

# file from a previous version of this doc
sudo tee /etc/sysctl.d/99-network-hardening.conf > /dev/null <<'EOF'
# icmp emission (defaults 1000/50)
net.ipv4.icmp_msgs_per_sec = 500
net.ipv4.icmp_msgs_burst = 25

# icmp redirects: kernel defaults are 1 on a host -> real effect
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv4.conf.all.secure_redirects = 0
net.ipv4.conf.default.secure_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0

# inert while ipv6 is disabled, kept in case it comes back
net.ipv6.conf.all.accept_redirects = 0
net.ipv6.conf.default.accept_redirects = 0

# already 0 on trixie ("all" is kernel default, "default" comes from systemd's
# /usr/lib/sysctl.d/50-default.conf), pinned so it survives vendor-file changes
net.ipv4.conf.default.accept_source_route = 0

# log impossible packets (observability, default 0)
net.ipv4.conf.all.log_martians = 1
net.ipv4.conf.default.log_martians = 1

# anti-spoofing (2 = loose): trixie already ships rp_filter=2 via systemd's
# 50-default.conf for "default" + existing interfaces, but not all
# pinned here, "all" added (max of all/interface wins)
net.ipv4.conf.all.rp_filter = 2
net.ipv4.conf.default.rp_filter = 2
EOF
# full path /usr/sbin is not in the user PATH
sudo /usr/sbin/sysctl --system
/usr/sbin/sysctl net.ipv4.conf.all.rp_filter net.ipv4.conf.all.log_martians net.ipv4.icmp_msgs_per_sec

left out because already kernel defaults icmp_echo_ignore_broadcasts, icmp_ignore_bogus_error_responses, icmp_ratelimit=1000, icmp_ratemask=6168, net.ipv6.icmp.ratelimit=1000, conf.all.accept_source_route=0

ipv6

if no use or no filter of ipv6

sudo tee /etc/sysctl.d/99-ipv6.conf > /dev/null << 'EOF'
# all + default, covers existing && future interfaces
# also kills ::1 on lo, if something local needs ::1, drop the "all" line
# and list interfaces explicitly instead (eno1...)
net.ipv6.conf.all.disable_ipv6 = 1
net.ipv6.conf.default.disable_ipv6 = 1
EOF
sudo /usr/sbin/sysctl --system
# must be empty
ip -6 addr

NOT persistent alone bc sysctl runs early at boot, then the network flips disable_ipv6=0. check netplan

#example
      dhcp6: false
      accept-ra: false
      set-name: "eno1"
      link-local: []

change ssh port

if you want to reduce fail2ban logs size lol (not security related)

NEWPORT=27520

# transition: listen on both
sudo tee /etc/ssh/sshd_config.d/01-port.conf > /dev/null << EOF
Port 22
Port $NEWPORT
EOF
sudo sshd -t && sudo systemctl reload ssh
sudo ss -tlnp | grep sshd

sudo sed -i "s|^define ssh_port = .*|define ssh_port = { 22, $NEWPORT }|" /etc/nftables.conf
sudo sed -i "s|^port = .*|port = 22,$NEWPORT|" /etc/fail2ban/jail.d/sshd-strict.local
sudo nft -c -f /etc/nftables.conf && sudo systemctl reload nftables
sudo systemctl restart fail2ban

# check NEWPORT access from outside
# then close port 22

sudo sed -i '/^Port 22$/d' /etc/ssh/sshd_config.d/01-port.conf
sudo sshd -t && sudo systemctl reload ssh

sudo sed -i "s|^define ssh_port = .*|define ssh_port = $NEWPORT|" /etc/nftables.conf
sudo sed -i "s|^port = .*|port = $NEWPORT|" /etc/fail2ban/jail.d/sshd-strict.local
sudo nft -c -f /etc/nftables.conf && sudo systemctl reload nftables
sudo systemctl restart fail2ban

sudo ss -tlnp | grep sshd

llmnr

if systemd-resolved is installed

dpkg -l systemd-resolved || echo "not installed, skip this section"

sudo mkdir -p /etc/systemd/resolved.conf.d
sudo tee /etc/systemd/resolved.conf.d/99-hardening.conf > /dev/null << 'EOF'
[Resolve]
LLMNR=no
MulticastDNS=no
EOF
sudo systemctl restart systemd-resolved

auto updates

biggest security/effort ratio of the whole page

sudo apt-get install -qq -y unattended-upgrades needrestart
sudo dpkg-reconfigure -plow unattended-upgrades
sudo unattended-upgrade --dry-run --debug

# optional: version /etc
sudo apt-get install -qq -y etckeeper

check

create a report script checking everything this page applies. (AI generated)

sudo tee /usr/local/sbin/check-hardening > /dev/null << 'EOF'
#!/usr/bin/env bash
# report on everything the hardening page applies. run as root.
export PATH=/usr/local/sbin:/usr/sbin:/sbin:/usr/bin:/bin
pass=0; warn=0; fail=0
ok(){ echo "  ok    $*"; pass=$((pass+1)); }
wr(){ echo "  warn  $*"; warn=$((warn+1)); }
ko(){ echo "  FAIL  $*"; fail=$((fail+1)); }
hdr(){ printf '\n== %s\n' "$*"; }
[ "$(id -u)" -eq 0 ] || { echo "run as root: sudo check-hardening"; exit 1; }

hdr "ssh"
[ "$(systemctl is-enabled ssh.socket 2>/dev/null)" = enabled ] \
  && ko "ssh.socket enabled -> Port/AddressFamily ignored" \
  || ok "ssh.socket not enabled"
systemctl is-active ssh >/dev/null 2>&1 && ok "ssh.service active" || ko "ssh.service not active"
T=$(sshd -T 2>/dev/null) || ko "sshd -T failed"
chk(){ v=$(printf '%s\n' "$T" | awk -v k="$1" '$1==k{$1="";sub(/^ /,"");print;exit}')
  [ "$v" = "$2" ] && ok "$1 = $2" || ko "$1 = '$v' (expected: $2)"; }
chk passwordauthentication no
chk kbdinteractiveauthentication no
chk permitrootlogin no
chk x11forwarding no
chk allowusers debian
chk authenticationmethods publickey
chk kexalgorithms mlkem768x25519-sha256
chk ciphers chacha20-poly1305@openssh.com
chk addressfamily inet
chk loglevel VERBOSE
printf '%s\n' "$T" | awk '$1=="banner"{print "  info  banner: " $2}'
extra=$(ls /etc/ssh/ssh_host_* 2>/dev/null | grep -v ed25519)
[ -z "$extra" ] && ok "only ed25519 host keys on disk" \
  || wr "extra host keys (unused, HostKey pins ed25519): $extra"

hdr "ssh port sources (must all match)"
p_sshd=$(printf '%s\n' "$T" | awk '$1=="port"{print $2}' | sort -n | xargs)
p_nft=$(grep '^define ssh_port' /etc/nftables.conf 2>/dev/null | grep -oE '[0-9]+' | sort -n | xargs)
p_f2b=$(grep '^port' /etc/fail2ban/jail.d/sshd-strict.local 2>/dev/null | grep -oE '[0-9]+' | sort -n | xargs)
echo "  info  sshd: ${p_sshd:-?} | nftables: ${p_nft:-?} | fail2ban: ${p_f2b:-?}"
{ [ -n "$p_sshd" ] && [ "$p_sshd" = "$p_nft" ] && [ "$p_sshd" = "$p_f2b" ]; } \
  && ok "ports in sync" || ko "ports out of sync"
ss -tlnp 2>/dev/null | awk '/sshd/{print "  info  listening on " $4}'
ss -tlnp 2>/dev/null | grep sshd | grep -q '\[::\]' \
  && wr "sshd still listens on ipv6" || ok "no ipv6 listener"

hdr "nftables"
[ "$(systemctl is-enabled nftables 2>/dev/null)" = enabled ] && ok "enabled at boot" || ko "not enabled at boot"
systemctl is-active nftables >/dev/null 2>&1 && ok "service active" || ko "service not active"
nft list chain inet filter_main input 2>/dev/null | grep -q 'policy drop' \
  && ok "filter_main input policy drop" || ko "no default-drop input chain"
for t in ssh_ratelimit icmp_hardening; do
  nft list tables 2>/dev/null | grep -q "inet $t" && ok "table $t loaded" || ko "table $t missing"
done

hdr "fail2ban"
systemctl is-active fail2ban >/dev/null 2>&1 && ok "service active" || ko "service not active"
nft list tables 2>/dev/null | grep -q f2b \
  && ok "f2b tables present" \
  || wr "no f2b table: wiped by an nftables reload? -> systemctl restart fail2ban"
echo "  info  journalmatch: $(fail2ban-client get sshd journalmatch 2>/dev/null)"
tf=$(fail2ban-client status sshd 2>/dev/null | grep -i 'total failed' | grep -oE '[0-9]+' | tail -1)
case "${tf:-}" in
  ''|0) wr "total failed = ${tf:-?}: fresh install or broken journalmatch (see fail2ban section)" ;;
  *) ok "journal matching works (total failed: $tf)" ;;
esac

hdr "sysctl"
sck(){ v=$(sysctl -n "$1" 2>/dev/null)
  [ "$v" = "$2" ] && ok "$1 = $2" || ko "$1 = '$v' (expected: $2)"; }
sck net.ipv4.conf.all.accept_redirects 0
sck net.ipv4.conf.all.send_redirects 0
sck net.ipv4.conf.all.rp_filter 2
sck net.ipv4.conf.all.log_martians 1
sck net.ipv4.icmp_msgs_per_sec 500
if [ -d /proc/sys/net/ipv6 ]; then sck net.ipv6.conf.all.disable_ipv6 1
else ok "ipv6 disabled at kernel level (grub)"; fi
[ -e /etc/sysctl.d/99-icmp-hardening.conf ] \
  && wr "old 99-icmp-hardening.conf still present" || ok "old sysctl file gone"

hdr "ipv6"
n=$(ip -6 addr 2>/dev/null | grep -c inet6)
[ "$n" -eq 0 ] && ok "no ipv6 address" || wr "$n ipv6 address(es) left: ip -6 addr"

hdr "leftovers"
systemctl list-timers --all 2>/dev/null | grep -qE 'sshd-rollback|fw-rollback' \
  && wr "rollback timer still armed" || ok "no rollback timer armed"
if systemctl is-active systemd-resolved >/dev/null 2>&1; then
  resolvectl status 2>/dev/null | grep -q '+LLMNR' && wr "llmnr still on" || ok "llmnr off"
fi

hdr "auto updates"
dpkg -s unattended-upgrades >/dev/null 2>&1 && ok "unattended-upgrades installed" || ko "unattended-upgrades missing"
dpkg -s needrestart >/dev/null 2>&1 && ok "needrestart installed" || wr "needrestart missing"
apt-config dump APT::Periodic::Unattended-Upgrade 2>/dev/null | grep -q '"1"' \
  && ok "periodic unattended-upgrade on" || ko "periodic unattended-upgrade off"
for t in apt-daily.timer apt-daily-upgrade.timer; do
  systemctl is-active "$t" >/dev/null 2>&1 && ok "$t active" || ko "$t not active"
done

printf '\n== %s ok, %s warn, %s FAIL\n' "$pass" "$warn" "$fail"
[ "$fail" -eq 0 ]
EOF
sudo chmod 755 /usr/local/sbin/check-hardening

sudo check-hardening