How to Set Up a VPS for Beginners, from a Beginner (Part 2: Firewall, Bans, and Automatic Updates)
Part 2 of setting up my first VPS on Debian 13. An nftables firewall, fail2ban with a repeat-offender jail, automatic security updates, zram swap, kernel tuning, and capped logs.
A 5-part series.
- Part 1: Securing the Base System
- Part 2: Firewall, Bans & Updates
- Part 3: Rootless Podman
- Part 4: Traefik & HTTPS
- Part 5: Deploying, and One Script for All of It
Where We Are
Part 1 locked down the login: one user, keys only, no root.
Everything else is still wide open. Any program that listens on a port can be reached from the whole internet, nobody is watching the failed logins pile up, and security patches only land when I remember to run apt. This part fixes that. Nothing visible comes out of it, but it’s what makes the server safe to leave alone.
The Firewall
The idea is simple: block everything coming in, then allow only what needs to be reachable. That’s SSH, plus 80 and 443 for the web server later. If I ever start something on another port by mistake, nobody outside can reach it.
I used nftables directly. It’s the firewall Debian uses under the hood anyway, tools like ufw are a layer on top of it, and with nftables the whole ruleset is one file I can read top to bottom and check before loading.
sudo apt install -y nftables
sudo nano /etc/nftables.confReplace the whole file with this. Use your own SSH port from Part 1:
#!/usr/sbin/nft -f
table inet vps_filter
delete table inet vps_filter
table inet vps_filter {
chain input {
type filter hook input priority filter; policy drop;
ct state established,related accept
ct state invalid drop
iif "lo" accept
icmp type { destination-unreachable, time-exceeded, parameter-problem } accept
icmp type echo-request limit rate 5/second burst 10 packets accept
icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert, mld-listener-query } accept
icmpv6 type echo-request limit rate 5/second burst 10 packets accept
ip6 saddr fe80::/10 udp dport 546 accept
tcp dport 22022 accept comment "ssh"
tcp dport { 80, 443 } accept comment "http, https"
udp dport 443 accept comment "http/3"
}
chain forward {
type filter hook forward priority filter; policy drop;
}
chain output {
type filter hook output priority filter; policy accept;
}
}Reading it from the top:
policy droponinputis the “block everything” part. The rules below are the exceptions.established,relatedlets replies come back to connections the server started itself, likeaptdownloading packages. Without it, nothing works.lois the loopback interface, so programs on the server can talk to each other.- The ICMP lines keep the parts of ping and error reporting that the network needs to work properly (path MTU discovery, IPv6 neighbour discovery) and rate-limit actual pings.
- The
fe80::/10line lets DHCPv6 replies in. Some providers hand out IPv6 that way. - Then SSH, HTTP and HTTPS over TCP, and 443 over UDP. That last one is for HTTP/3, which runs over UDP. Without it, HTTPS still works and HTTP/3 just never kicks in.
The two lines before the table look weird. The first creates the table if it doesn’t exist, the second deletes it, and then the block recreates it. This makes the file safe to load again and again, and it only ever touches my own table. Debian’s original file starts with flush ruleset instead, which wipes every table on the machine. That matters once fail2ban adds its own table below.
Check the file before loading it:
sudo nft -c -f /etc/nftables.confNo output means the syntax is fine. It doesn’t mean the rules are right, though, so read your SSH port one more time. If it’s wrong, the next command locks you out. Keep your session open, same as in Part 1.
sudo systemctl enable --now nftables
sudo nft list table inet vps_filtertable inet vps_filter {
chain input {
type filter hook input priority filter; policy drop;
...
tcp dport 22022 accept comment "ssh"
tcp dport { 80, 443 } accept comment "http, https"
udp dport 443 accept comment "http/3"
}
...
}From now on, after editing the file, use reload, never restart:
sudo systemctl reload nftablesStopping nftables.service runs flush ruleset. A restart would throw away fail2ban’s bans along with everything else.
Containers Can’t Get Around It
People running Docker get bitten by this: Docker writes its own firewall rules, and a published container port ends up reachable even though the firewall says it’s blocked.
That won’t happen here. The containers in Part 3 are rootless, so they run as a normal user who isn’t allowed to touch the firewall at all. These rules are the only ones that exist.
Fail2ban
The firewall decides which ports are open. It doesn’t care what happens on them. Port 22022 has to accept connections from anyone, so anyone can keep trying.
With passwords disabled, nobody is going to guess their way in. What fail2ban gives me is quieter logs. It watches for repeated failures and bans the address for a while.
sudo apt install -y fail2ban python3-systemdpython3-systemd lets fail2ban read the systemd journal. Debian 13 doesn’t write SSH logins to /var/log/auth.log anymore, so without it the SSH jail has nothing to watch.
First, make sure fail2ban writes its own log to a file. The repeat-offender jail below reads it:
sudo nano /etc/fail2ban/fail2ban.d/90-local.local[Definition]
logtarget = /var/log/fail2ban.logThen the jails. I never edit jail.conf directly, because package updates replace it:
sudo nano /etc/fail2ban/jail.d/90-local.local[DEFAULT]
backend = systemd
banaction = nftables-multiport
banaction_allports = nftables-allports
bantime = 1h
bantime.increment = true
bantime.maxtime = 1w
findtime = 10m
maxretry = 5
ignoreip = 127.0.0.1/8 ::1
[sshd]
enabled = true
port = 22022
mode = aggressive
[recidive]
enabled = true
backend = auto
logpath = /var/log/fail2ban.log
banaction = %(banaction_allports)s
bantime = 1w
findtime = 1d
maxretry = 3What it does:
- Five failures within ten minutes gets an address banned for an hour.
bantime.incrementmakes each new ban of the same address longer, up to a week.mode = aggressivealso counts the noisier failures, like clients that disconnect during key exchange. Most scanners do exactly that.recidivewatches fail2ban’s own log. An address banned three times in a day gets blocked on every port for a week.- The bans are written as nftables rules, in a table of their own.
porthas to match your real SSH port. Get it wrong and the jail looks healthy while watching the wrong port.
Test the config, then start it:
sudo fail2ban-client -t
sudo systemctl enable fail2ban
sudo systemctl restart fail2ban
sudo fail2ban-client status sshdStatus for the jail: sshd
|- Filter
| |- Currently failed: 0
| |- Total failed: 0
| `- Journal matches: _SYSTEMD_UNIT=ssh.service + _COMM=sshd
`- Actions
|- Currently banned: 0
|- Total banned: 0
`- Banned IP list:All zeros on day one is normal. The line to check is Journal matches. If it’s empty, the jail is running and watching nothing.
A day later it looked like this:
Status for the jail: sshd
|- Filter
| |- Currently failed: 3
| |- Total failed: 847
| `- Journal matches: _SYSTEMD_UNIT=ssh.service + _COMM=sshd
`- Actions
|- Currently banned: 6
|- Total banned: 41
`- Banned IP list: 45.148.10.92 92.63.197.14 141.98.11.72 ...847 failed attempts on a server nobody knows about, on a non-standard port. That traffic was there from the start; now I can see it.
And this is why the firewall file only manages its own table:
sudo nft list tablestable inet vps_filter
table inet f2b-tableMy rules and fail2ban’s bans live side by side, and reloading mine leaves fail2ban’s alone.
Automatic Security Updates
I’m not going to remember to patch this every week, so the server does it itself:
sudo apt install -y unattended-upgrades apt-listchanges needrestart
sudo nano /etc/apt/apt.conf.d/20auto-upgradesAPT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";That turns it on: refresh the package lists daily, install updates daily, clean the download cache weekly.
The settings go in their own file, so package updates don’t overwrite them:
sudo nano /etc/apt/apt.conf.d/52unattended-upgrades-localUnattended-Upgrade::Remove-Unused-Kernel-Packages "true";
Unattended-Upgrade::Remove-New-Unused-Dependencies "true";
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-WithUsers "true";
Unattended-Upgrade::Automatic-Reboot-Time "04:00";
Unattended-Upgrade::SyslogEnable "true";By default, Debian only installs updates from the security repository, so a big version change won’t land by itself at night.
Removing old kernels keeps /boot from filling up. When it does fill up, apt breaks, and it’s not obvious why.
The reboot settings are the part I thought about longest. A kernel security fix only takes effect after a reboot, and I won’t do that on time if it’s up to me. So when an update needs one, the server reboots at 4 AM. That’s fine because everything I run comes back on its own afterwards, which Part 3 makes sure of.
needrestart handles the other half. When a library like OpenSSL gets patched, programs that already loaded the old version keep using it until they restart. This tells needrestart to restart them automatically instead of asking:
sudo mkdir -p /etc/needrestart/conf.d
echo "\$nrconf{restart} = 'a';" | sudo tee /etc/needrestart/conf.d/90-local.confDo a dry run instead of waiting a day to find out if it works:
sudo unattended-upgrade --dry-run --debugStarting unattended upgrades script
Allowed origins are: origin=Debian,codename=trixie,label=Debian-Security, ...
Packages that will be upgraded: libssl3t64 opensslAllowed origins should list the security repository.
Swap in RAM with zram
Most cheap VPS plans have 1 to 2 GB of RAM and no swap. When something uses too much memory, the kernel kills it, and the program usually doesn’t get to say why.
I used zram instead of a swap file. It’s swap that lives in RAM, compressed. That sounds strange, but compressed memory still fits a lot more than it takes, and getting a page back from it is much faster than reading it from the VPS’s network disk. It also doesn’t wear out the disk.
sudo apt install -y systemd-zram-generator
sudo nano /etc/systemd/zram-generator.conf[zram0]
zram-size = min(ram * 50 / 100, 8192)
compression-algorithm = zstd
swap-priority = 100Half the RAM, capped at 8 GB, compressed with zstd.
sudo systemctl daemon-reload
sudo systemctl start systemd-zram-setup@zram0.service
swapon --showNAME TYPE SIZE USED PRIO
/dev/zram0 partition 979M 0B 100Swapping to zram is cheap, so the kernel should use it readily. That’s the opposite of the usual advice for disk swap:
printf 'vm.swappiness = 100\nvm.page-cluster = 0\n' | sudo tee /etc/sysctl.d/91-swap.confpage-cluster = 0 stops the kernel from reading several pages at once, which only helps on spinning disks.
Kernel and Network Settings
The kernel has a lot of settings under sysctl. A handful are worth changing on a server facing the internet:
sudo nano /etc/sysctl.d/90-hardening.conf# don't accept tricks that reroute traffic
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv6.conf.default.accept_redirects = 0
net.ipv4.conf.all.secure_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0
net.ipv4.conf.all.accept_source_route = 0
net.ipv6.conf.all.accept_source_route = 0
net.ipv4.conf.all.log_martians = 1
net.ipv4.icmp_echo_ignore_broadcasts = 1
net.ipv4.icmp_ignore_bogus_error_responses = 1
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_rfc1337 = 1
# hide kernel details from normal users
kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1
kernel.unprivileged_bpf_disabled = 1
net.core.bpf_jit_harden = 2
kernel.yama.ptrace_scope = 1
kernel.kexec_load_disabled = 1
fs.protected_fifos = 2
fs.protected_regular = 2
fs.suid_dumpable = 0
# faster TCP
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr
net.core.somaxconn = 4096
net.ipv4.tcp_fastopen = 3
net.ipv4.tcp_mtu_probing = 1
net.ipv4.tcp_slow_start_after_idle = 0
vm.vfs_cache_pressure = 50The first two groups are standard hardening. The server isn’t a router, so it has no reason to accept redirects or source-routed packets, and normal users don’t need kernel addresses, the kernel log, or other users’ processes.
The last group is about speed. BBR is a newer TCP congestion control from Google that copes much better with slow or lossy connections, like mobile data. It needs its module loaded at boot:
echo tcp_bbr | sudo tee /etc/modules-load.d/bbr.conf
sudo modprobe tcp_bbr
sudo sysctl --system
sysctl net.ipv4.tcp_congestion_controlnet.ipv4.tcp_congestion_control = bbrCapping the Logs
Everything on this server logs to the systemd journal, including every container later on. By default it can grow to 10% of the disk, which nobody needs. I used a drop-in file instead of editing the main config:
sudo mkdir -p /etc/systemd/journald.conf.d
sudo nano /etc/systemd/journald.conf.d/90-local.conf[Journal]
Storage=persistent
Compress=yes
SystemMaxUse=500M
SystemKeepFree=1G
MaxRetentionSec=1monthStorage=persistent keeps logs across reboots, which matters once the server reboots itself at 4 AM. Then a cap of 500 MB, at least 1 GB always left free, and nothing older than a month.
sudo systemctl restart systemd-journald
journalctl --disk-usageArchived and active journals take up 46.8M in the file system.While I was at it, I turned off core dumps. When a program crashes, a core dump saves its memory to disk, and that memory can contain passwords and keys. I’m not going to debug crashes from dumps on this server anyway:
sudo mkdir -p /etc/systemd/coredump.conf.d
printf '[Coredump]\nStorage=none\nProcessSizeMax=0\n' | sudo tee /etc/systemd/coredump.conf.d/90-local.confWhere the Server Stands
After Part 2:
- Inbound traffic is blocked except SSH, 80 and 443.
- Repeated SSH failures get banned, and repeat offenders get banned everywhere for a week.
- Security updates install themselves, and the server reboots at 4 AM when a kernel fix needs it.
- zram swap catches memory spikes, and the kernel is a bit stricter and a bit faster.
- Logs can’t fill the disk.
Still nothing runs on it, though. Part 3 sets up rootless Podman so it can actually run something.