First login on a fresh VPS: the hardening I actually did
A Debian 13 box, a sudo user, a password, and one evening to make it safe enough to host a deployment platform. Here is the exact order I did things in, and the two traps that cost me a reboot.
I rented a small VPS this week to host my own deployment platform. It came the way most of them do: Debian 13, one user with sudo rights, and a password in an email. Before putting anything on it, I wanted it hardened. Not "paranoid enterprise" hardened, just the baseline that keeps a personal server out of the botnets.
This is the order I did things in, with the mistakes left in.
1. Get in once with a password, never again
The first thing I did was copy my SSH key. The provider exposes SSH on a non-standard port, so everything below uses -p <port>.
ssh-copy-id -p <port> user@serverThen, and this is the part people skip, I opened a second terminal and verified that the key alone gets me in:
ssh -p <port> -o PasswordAuthentication=no user@server 'echo key login ok'Only after that message came back did I touch the SSH daemon. If you lock yourself out of a box whose only door is SSH, the fix is the provider's rescue console, and that is never a fun evening.
I also added an alias in ~/.ssh/config so I stop typing the port:
Host vps
HostName <server>
Port <port>
User user
IdentityFile ~/.ssh/id_ed25519
2. Harden sshd with a drop-in, not by editing the main file
Debian's sshd_config has an Include /etc/ssh/sshd_config.d/*.conf line, and package upgrades keep their hands off that directory. So the hardening lives in its own file:
sudo tee /etc/ssh/sshd_config.d/50-hardening.conf >/dev/null <<'EOF'
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
MaxAuthTries 3
X11Forwarding no
AllowTcpForwarding local
EOF
sudo sshd -t && sudo systemctl reload sshAllowTcpForwarding local is there on purpose: I use SSH tunnels to reach admin interfaces that are not exposed publicly (more on that below). If you never tunnel, set it to no.
Trap number one. On this image, the Include line was missing entirely. My drop-in was silently ignored and password login stayed enabled. Worse, sshd keeps the first value it reads for a directive, so the Include must sit at the top of sshd_config, before any PasswordAuthentication that the image might already define. Check with:
grep -n '^Include' /etc/ssh/sshd_config
sudo sshd -T | grep -E 'passwordauthentication|permitrootlogin'If sshd -T does not say no for both, your drop-in is not winning.
3. Update everything, then reboot on purpose
sudo apt update && sudo apt full-upgrade -y
sudo rebootRebooting right away is deliberate. A kernel update that only takes effect "next time" is a surprise waiting for the worst moment. Better to find out now, while nothing is running yet.
Trap number two. After the reboot, Docker and fail2ban were marked failed. The root filesystem had come back read-only. The cause: the provider's cloud image ships a /boot/efi mount unit that is masked (the VM boots in BIOS mode), and the fstab line for it was marked as required. When the mount failed, systemd gave up on local-fs.target and left / read-only.
The fix is one word in /etc/fstab:
/dev/vda15 /boot/efi vfat defaults,noauto,nofail 0 2
nofail tells systemd that this mount is optional. After a second reboot, everything came up clean. Now, after any first reboot of a new box, I run these two before anything else:
findmnt -no OPTIONS / # must contain "rw"
systemctl --failed # must be empty4. Firewall: default deny
sudo apt install -y ufw
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow <port>/tcp # SSH, on the provider's port
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enableOne thing worth knowing: Docker publishes ports by writing its own iptables rules, and those bypass ufw. A container started with -p 5432:5432 is reachable from the internet even if ufw says otherwise. The practical rule: only publish ports you really want public, and bind everything else to 127.0.0.1 or keep it on an internal Docker network.
5. fail2ban on a system that has no auth.log
Debian 13 does not write /var/log/auth.log anymore; everything goes to the journal. fail2ban's default sshd jail still looks for the file, so it needs the systemd backend:
sudo apt install -y fail2ban
sudo tee /etc/fail2ban/jail.local >/dev/null <<'EOF'
[DEFAULT]
backend = systemd
bantime = 1h
findtime = 10m
maxretry = 5
[sshd]
enabled = true
port = <port>
EOF
sudo systemctl enable --now fail2ban
sudo fail2ban-client status sshdWith password login already disabled, fail2ban is mostly noise reduction. It still keeps the journal readable.
6. Security updates without me
sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgradesI left automatic reboots off. I would rather get a kernel update installed and choose the reboot moment myself, especially on a box that runs other people's containers.
7. A non-root user and Docker
The provider had already created a non-root sudo user, so I kept it instead of adding another one. If yours only gives you root, do this first:
adduser deploy && usermod -aG sudo deployDocker came from the official repository, not from Debian's docker.io package, because the deployment platform I wanted needs a recent version:
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker "$USER"Be honest with yourself about that last line: membership in the docker group is equivalent to root. It is convenient, and it is a privilege, not a convenience.
8. What my provider taught me
This is the part no checklist mentions, because it depends on where you rent.
-
The VM had no public IP of its own. It sits behind the provider's NAT with a private address, and only the SSH port is mapped to the outside. Nothing listening on 80, 443 or 3000 is reachable until you configure a mapping in their panel. My deployment platform's admin UI therefore lives behind an SSH tunnel, which is honestly where an admin UI belongs:
ssh -N -L 127.0.0.1:3300:127.0.0.1:3000 vps -
Ports 80 and 443 arrive through a shared nginx that speaks the PROXY protocol to the VM. My reverse proxy (Traefik) refused those connections until I told it to accept PROXY protocol on both entry points.
-
Port 80 is never forwarded. The provider answers HTTP with a redirect to HTTPS itself. That kills the classic HTTP-01 Let's Encrypt challenge; the TLS-ALPN challenge on 443 works fine.
-
The certificates are provided for you, mounted read-only and renewed by the provider. Rather than fighting for my own, a small cron copies them into Traefik's dynamic configuration every thirty minutes.
None of this is in the onboarding email. All of it took longer to discover than the hardening itself.
The checklist
For the next box, in this order:
- Copy the SSH key, verify key-only login in a second terminal.
- Drop-in for sshd: no passwords, no root,
sshd -t, reload. Confirm withsshd -T. apt full-upgrade, reboot now, thenfindmnt -no OPTIONS /andsystemctl --failed.- ufw with default deny; remember Docker bypasses it.
- fail2ban with
backend = systemd. - unattended-upgrades, automatic reboot off.
- Non-root user; Docker from the official repository.
- Read what the provider does to the network before blaming your own config.
The whole thing took an evening, including the two reboots I did not plan. The next one will take twenty minutes.