How to Set Up a VPS for Beginners, from a Beginner (Part 2: Firewall, Bans, and Automatic Updates)

Part 2 of a journey learning how I set up my first VPS on Debian 13 Minimal. Fixing the Debian 13 SSH port gotcha, adding swap, closing every port with ufw, banning repeat offenders with fail2ban, and letting security patches apply themselves.

A 5-part series. Part 1 — Securing the Base System · Part 2 — Firewall & Bans · Part 3 — Rootless Podman · Part 4 — Caddy & HTTPS · Part 5 — Deploying SvelteKit

Where This Continues From

In Part 1, the server went from a fresh Debian 13 Minimal install to something with actual boundaries: system updated, SSH keys instead of passwords, a normal user instead of root, and a hardened sshd_config.

That covered one question only: who is allowed in through the front door.

It did not cover anything else. Every other port on the machine was still open. Nothing was watching for repeated failed attempts. Security updates only happened when I remembered to run them.

So this part is the boring middle layer. No application appears at the end of it. But it is the part that decides whether the server stays stable once something real is running on it.

A Debian 13 Detail That Bit Me

Before anything else, the thing that confused me for a solid twenty minutes at the end of Part 1.

I changed Port 22022 in /etc/ssh/sshd_config, restarted SSH, and then found the server still answering on port 22 and completely ignoring 22022.

The reason is that Debian 13 uses socket activation for SSH by default. systemd holds the listening socket, not sshd, which means the Port directive in sshd_config is never consulted. Restarting ssh.service changes nothing at all.

Check which situation you are in first:

systemctl is-enabled ssh.socket

If that returns enabled, socket activation is in charge. The simplest fix is to hand the port back to sshd:

sudo systemctl disable --now ssh.socket
sudo systemctl enable --now ssh.service

Now sshd binds the port itself, and the Port 22022 line from Part 1 takes effect.

If you would rather keep socket activation, tell the socket instead:

sudo systemctl edit ssh.socket

And add:

[Socket]
ListenStream=
ListenStream=22022

The empty ListenStream= matters. It clears the inherited value of port 22 first — without it you would be listening on both ports, which defeats the point.

Then apply:

sudo systemctl daemon-reload
sudo systemctl restart ssh.socket

Either way, verify before you trust it:

sudo ss -tlnp | grep ssh

And here is the habit worth building now: keep your current SSH session open while you test the new one from a second terminal. If something is wrong, that open session is the only thing standing between you and a locked door. I have needed it more than once.

ssh -p 22022 admin@IP_VPS

Naming and Time

Two small things that cost nothing and pay for themselves later.

The hostname shows up in your shell prompt, in logs, and in every alert the system might send. A server named debian tells you nothing six months from now:

sudo hostnamectl set-hostname my-vps

The timezone matters more than it looks. Every log line, every ban, every scheduled job gets stamped with it. Reading logs in a timezone you have to mentally convert from is a small tax you pay every single time:

sudo timedatectl set-timezone Asia/Jakarta

Then confirm the clock is actually synchronized:

timedatectl status

You want System clock synchronized: yes. A drifting clock quietly breaks TLS certificate validation — exactly the kind of failure that wastes an afternoon because it looks like something else entirely.

Adding Swap

Most cheap VPS plans come with 1–2 GB of RAM and no swap at all. For serving traffic that is usually fine. For building anything it is not.

I found this out the direct way: a Node build got killed partway through with no useful error message. What actually happened was the kernel’s OOM killer terminating the process because memory ran out. Nothing in the build output says that. You have to go and look:

sudo dmesg | grep -i "killed process"

Swap does not make a server fast. It makes it survive short memory spikes instead of killing whatever caused them. That is the entire point, and it is enough.

Create a 2 GB swap file:

sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile

The chmod 600 is not optional. That file holds whatever memory got paged out to it, which can include secrets. Root-only, always.

Make it survive a reboot:

echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

Then tell the kernel to prefer RAM and only reach for swap under real pressure:

echo 'vm.swappiness=10' | sudo tee /etc/sysctl.d/99-swappiness.conf
sudo sysctl --system

Verify:

free -h

The Firewall

SSH is locked down, but SSH was never the only way in. Anything that binds a port on this machine is reachable from the entire internet unless something says otherwise.

The model I settled on is the one that is easiest to reason about: deny everything inbound, then allow the specific things that need to exist. With that default, a service accidentally exposed on some port is simply unreachable rather than quietly public. You do not have to be perfect. You have to be closed by default.

ufw was already installed in Part 1. Set the defaults first:

sudo ufw default deny incoming
sudo ufw default allow outgoing

Now the part where the order genuinely matters. Allow your SSH port before enabling the firewall. ufw will lock you out of your own server, and it will do it instantly and without asking:

sudo ufw allow 22022/tcp comment 'SSH'

Then the web ports, which later parts will need:

sudo ufw allow 80/tcp comment 'HTTP'
sudo ufw allow 443/tcp comment 'HTTPS'
sudo ufw allow 443/udp comment 'HTTP/3 QUIC'

That last one is for HTTP/3. HTTPS works fine without it, but QUIC runs over UDP, so leaving it out silently disables HTTP/3 while everything still appears to work correctly.

Now enable it:

sudo ufw enable

And read back what you actually created, rather than what you meant to create:

sudo ufw status numbered

Rules can be removed by number if one is wrong:

sudo ufw delete 3

Why This Firewall Actually Holds

There is a well-known gotcha where Docker writes its own iptables rules and publishes container ports straight past ufw. You add a firewall rule, it reads correctly, and the container is exposed anyway. People lose databases to this.

Rootless Podman — which Part 3 sets up — does not behave that way. Because it runs as a normal user, it has no privilege to rewrite the host firewall. Published ports are handled in userspace and arrive as ordinary traffic on the host, which means ufw sees them like anything else and your rules mean what they say.

This is one of those advantages that does not show up in feature comparisons but genuinely reduces the number of ways you can be wrong. I wrote more about that trade-off in Why I Chose Podman Over Docker.

Fail2ban

The firewall controls which ports are open. It has no opinion whatsoever about what happens on the ports that are open.

Port 22022 has to accept connections from anywhere — that is the point of it. So anyone can keep knocking, forever. fail2ban watches the logs and temporarily bans addresses that fail repeatedly.

Worth being honest about what this buys you. With password authentication disabled in Part 1, brute force is not really a threat anymore. Nobody guesses an Ed25519 key. So fail2ban is not the thing keeping attackers out here — SSH keys are. What it actually buys is quieter logs, and that is worth more than it sounds. Logs you can read are logs where you will notice something unusual.

Never edit jail.conf directly; package updates overwrite it. Create your own override:

sudo nano /etc/fail2ban/jail.local
[DEFAULT]
backend = systemd
bantime = 1h
findtime = 10m
maxretry = 5
ignoreip = 127.0.0.1/8 ::1

[sshd]
enabled = true
port = 22022

Line by line:

  • backend = systemd reads from the journal instead of a log file. Debian 13 does not ship a traditional /var/log/auth.log by default, so a file-based backend would sit there watching nothing and reporting no problems.
  • bantime = 1h is how long a ban lasts.
  • findtime and maxretry together mean five failures within ten minutes triggers it.
  • ignoreip keeps localhost from ever being banned. If you have a static IP at home, adding it here is cheap insurance against banning yourself.
  • port = 22022 must match your actual SSH port. This is the line people forget, and the jail then silently watches the wrong port while looking perfectly healthy.

Start it and check:

sudo systemctl enable --now fail2ban
sudo fail2ban-client status sshd

Come back to that command in a day. Seeing a real ban count is the moment it stops being theoretical — that traffic was always there, you just could not see it before.

Automatic Security Updates

Everything above is a one-time setup. Patching is not. It is a recurring obligation, and the honest problem is that I will not remember to do it every week.

So I made the machine do it:

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

By default this only applies security updates, which is the right trade-off. Automatic upgrades of everything would risk a breaking change landing at 3 AM with nobody watching. Security patches are the ones where the risk of waiting exceeds the risk of applying.

One setting worth turning on:

sudo nano /etc/apt/apt.conf.d/50unattended-upgrades
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";

Old kernels accumulate in /boot, and on a VPS with a small boot partition they will eventually fill it. That failure is genuinely confusing when it happens, because it presents as a broken apt rather than a full disk.

Force a dry run to confirm it works, instead of waiting a day to find out:

sudo unattended-upgrade --dry-run --debug

Note that this does not reboot for kernel updates. Automatic reboots are configurable, but I would rather do that one myself. A sudo reboot after checking nothing is mid-flight is a small price for not having the server restart during something important.

Where the Server Stands

The machine has changed shape:

  • SSH answering on a non-default port, keys only, no root login
  • A default-deny firewall with exactly four ports open
  • Repeated failures getting banned automatically
  • Security patches applying on their own
  • Swap absorbing memory spikes instead of losing to the OOM killer

None of that serves a single request. There is no application, no domain, no certificate. What there is, is a machine I can put something on without worrying about the foundation underneath it.

Next Part

In Part 3, the server gets a way to actually run things: rootless Podman, the pieces Debian Minimal does not install for you, and the one setting that decides whether your containers survive logging out.

© 2026 r3p.dev. All rights reserved.