VPS operations

Harden a fresh Debian VPS in the first hour, step by step

· 11 min read

On this page (12)
  1. Who needs this
  2. Step 0: keep a way back in
  3. Step 1: update the system
  4. Step 2: create a user you will actually use
  5. Step 3: prove the key works, then close the door
  6. Step 4: a firewall that starts closed
  7. Step 5: automatic security updates
  8. Step 6: time sync
  9. Step 7: swap, as a cushion
  10. The whole list in one place
  11. Mistakes we see
  12. FAQ

A new VPS starts taking login attempts within minutes of getting a public IP. Nobody is targeting you. Scanners walk the whole address space, try root with a list of common passwords, and move on. If your server has password login enabled and a weak password, that is the whole story of how small servers get taken over.

The fix is not clever. It is a short list of boring steps, done in the right order so you do not lock yourself out halfway through. This post is that list for Debian 12 or 13 (Ubuntu is close, with differences we point out). It takes about an hour the first time and ten minutes once you have done it twice.

The first hour, in order

Seven numbered steps for a fresh Debian VPS: keep a console open, update, create a key-only user, lock down SSH, enable the firewall, turn on automatic updates, then set time sync and swap.1Keep a waybackOpen sessionplus providerconsole2Update andrebootfull-upgrade,then systemctlreboot3Create a usersudo rights,your public key4Lock down SSHTest the keyfirst, thendrop-in file5FirewallAllow 22, 80,443, thenenable6Auto updatesunattended-upgrades, rebootdecision7Time synctimedatectlshowssynchronized:yes8Swap cushion1 GB file, mode600

Who needs this

  • A solo developer with a fresh $5 to $10 box. You rented it to host a side project or a client's small app. Nothing on it is precious yet, which is exactly when to set it up properly, because retrofitting security onto a server that already has traffic is stressful and gets postponed forever.
  • A small team inheriting "the server". Someone set it up two years ago, everyone logs in as root with a shared password, and nobody remembers what is installed. Running through this list as an audit shows you which of the steps were skipped.
  • A freelancer who spins up a server per client. You repeat this setup many times, so it should be a script and a checklist rather than something you reconstruct from memory every time. The commands below are short enough to paste in order.
  • Someone who wants a rebuildable server. If the box dies you will provision another one. A written first-hour list is half of your recovery plan. The other half is a backup you have restored at least once (see destroy your server on purpose).

Step 0: keep a way back in

Before you change anything about SSH, make sure you have a second path onto the machine. Two things give you that.

First, keep your current root session open and do not close it until the end of the SSH section. Every change below gets tested from a different terminal while the first one stays connected.

Second, find your provider's web console or rescue mode now, not when you need it. Almost every VPS provider has a browser-based console that logs in with a password you set in their dashboard, independent of SSH and independent of the firewall on the box. Write down where it is. It is your fire exit.

Step 1: update the system

apt update
apt full-upgrade -y
apt install -y sudo ufw unattended-upgrades curl ca-certificates

full-upgrade is fine on a fresh box because there is nothing to break. If the kernel was upgraded, reboot now with systemctl reboot and log back in. A reboot is much cheaper at minute five than after the app is running. Minimal images sometimes lack sudo, which is why it is in the install line.

Set the hostname and timezone while you are here. Servers should usually run in UTC, so that log timestamps from your app, your database and the system all agree.

hostnamectl set-hostname web-1
timedatectl set-timezone UTC

Step 2: create a user you will actually use

Logging in as root over the network is the thing to stop doing. Create a normal user with sudo rights and no password, and give it your public key.

adduser --disabled-password --gecos "" deploy
usermod -aG sudo deploy
install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
echo 'ssh-ed25519 AAAA... you@laptop' > /home/deploy/.ssh/authorized_keys
chown deploy:deploy /home/deploy/.ssh/authorized_keys
chmod 600 /home/deploy/.ssh/authorized_keys

A user with no password raises a fair question: how does sudo work? By default sudo asks for the caller's own password, and this user has none. You have two options. Set a password (passwd deploy) that is used only for sudo and can never be used to log in once password SSH is off, or allow passwordless sudo for this user with a file in /etc/sudoers.d/. For a single-person server, the first is the safer default: someone who steals your SSH key still needs your sudo password to become root.

If your laptop does not have a key yet, generate one there, not on the server:

ssh-keygen -t ed25519 -C "you@laptop"
cat ~/.ssh/id_ed25519.pub

Use a passphrase. Ed25519 keys are small, fast and supported by every OpenSSH from the last decade. If your provider lets you inject a key at server creation, use that, and then you only need to check which user it landed on.

The handbook that goes with this site wraps these steps in a script, create-user.sh, that is safe to run twice: it installs the key only if it is not already there, so a second run does not leave you with duplicate lines in authorized_keys. If you do it by hand, check that you do not append the same key repeatedly.

Step 3: prove the key works, then close the door

Open a new terminal on your laptop and log in:

ssh deploy@SERVER_IP
sudo -v

If that works, and only then, turn off password and root login. Do it with a drop-in file rather than editing the main config, so package upgrades never fight your changes:

cat > /etc/ssh/sshd_config.d/10-hardening.conf <<'EOF'
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
MaxAuthTries 3
X11Forwarding no
EOF
sshd -t && systemctl reload ssh

Lock down SSH: the order matters

Two cards compare hardening SSH before testing your key, which can end in a lockout and console rescue, with testing the key in a second terminal first, which keeps you connected throughout.Harden firstSet PasswordAuthentication no and reloadClose the only sessionKey was pasted wrongly, no way inConsole or rescue mode is the only fixTest firstLog in as deploy from a second terminalRun sudo -v to confirm it worksWrite the drop-in, sshd -t, then reloadRoot session stays open until verified

Three details here matter more than the options themselves.

The 10- prefix. Debian's main sshd_config starts with Include /etc/ssh/sshd_config.d/*.conf. For most keywords sshd uses the first value it reads, and the included files are read in alphabetical order. Name yours 10-hardening.conf so it sorts before anything a provider drops in later. A cloud image that ships a file setting PasswordAuthentication yes will otherwise win, and you will believe you disabled passwords when you did not. Ubuntu cloud images are known for this (50-cloud-init.conf).

sshd -t before reload. The test parses the config and exits non-zero on a mistake. Chaining it with && means a typo never reaches the running daemon. Reload (not restart) also keeps existing sessions alive.

KbdInteractiveAuthentication no. With PAM enabled, keyboard-interactive can ask for a password even when PasswordAuthentication is off. On OpenSSH older than 8.7 the keyword was called ChallengeResponseAuthentication; Debian 12 and 13 ship a newer version, so use the new name.

Verify the effective settings rather than trusting the file:

sshd -T | grep -E '^(permitrootlogin|passwordauthentication|kbdinteractiveauthentication|pubkeyauthentication) '

You can also see the effect from the client side. Before hardening, a failed password attempt ends with Permission denied (publickey,password). After, it ends with Permission denied (publickey). The list in parentheses is the set of methods the server still offers, and password should be gone. Now open yet another new terminal, log in as deploy, and only then close the original root session.

Step 4: a firewall that starts closed

The goal is simple: deny everything inbound, then allow the three ports you serve. Add the SSH rule before you enable the firewall, or enabling it will cut off your current session.

ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable
ufw status verbose

Read the status output. ufw adds IPv6 rules automatically, so you should see each port twice, once for (v6). Do not open your database port. Postgres should listen on a Unix socket or on 127.0.0.1, and the default-deny policy is your second line of defence if a config change ever makes it listen more widely.

One warning that catches people: ports published by Docker (-p 8080:80) are inserted into iptables ahead of ufw's rules, so ufw does not block them. If you run containers, bind published ports to loopback (-p 127.0.0.1:8080:80) and let your reverse proxy do the public side. The details, including rate limiting and what ufw does and does not stop, are in SSH, firewall and fail2ban basics.

Also check whether your provider has its own network firewall in the dashboard. If it does, it sits in front of the machine and can be the reason a port you opened in ufw still times out.

Step 5: automatic security updates

You will not log in every week to run apt upgrade. Let the machine apply security fixes itself.

cat > /etc/apt/apt.conf.d/20auto-upgrades <<'EOF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
EOF
cat > /etc/apt/apt.conf.d/52unattended-upgrades-local <<'EOF'
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Unattended-Upgrade::Automatic-Reboot "false";
EOF
unattended-upgrade --dry-run -d 2>&1 | grep -E 'Allowed origins|packages that can be upgraded|No packages found'

Debian's stock 50-unattended-upgrades already allows the Debian and Debian-Security origins, so the local override file only changes behaviour you care about. The 52 prefix sorts after 50 and wins. The dry run prints the origins it will accept, which confirms the files parse.

Rule of thumb: leave Automatic-Reboot off until you have proved the app starts by itself at boot.

The decision you have to make yourself is automatic reboot. Kernel and libc fixes only take effect after a reboot. With Automatic-Reboot "false" the packages are installed but the running kernel stays old until you reboot. With "true" the box restarts itself, by default at the time set in Automatic-Reboot-Time, so your app must come back on its own (a systemd unit with enable, not something you started by hand) and you need an outside check that tells you when it did not. If you are not ready for that, leave it off and put "reboot if a new kernel was installed" on a weekly list. Compare uname -r with the newest linux-image package from dpkg -l 'linux-image*'; on Ubuntu the file /var/run/reboot-required tells you directly.

To see what the updater has been doing, read /var/log/unattended-upgrades/unattended-upgrades.log, and systemctl list-timers 'apt-daily*' shows when it runs next.

Step 6: time sync

Wrong clocks cause strange failures. TLS certificates look not-yet-valid or expired, webhook signatures with timestamps get rejected, and log lines from different machines cannot be lined up. Check:

timedatectl

You want System clock synchronized: yes and NTP service: active. Most provider images have systemd-timesyncd running. If timedatectl says the service is inactive or missing, install it:

apt install -y systemd-timesyncd
systemctl enable --now systemd-timesyncd

chrony is the heavier alternative with better handling of large corrections and detailed stats. For a small VPS that only needs to be within a second or so, timesyncd is enough. Pick one, not both.

Step 7: swap, as a cushion

A 1 GB box running Node or Go, Postgres and a proxy can briefly run out of memory during a deploy or a backup. With no swap, the kernel's OOM killer picks a process and ends it, and it may not choose the one you would. A small swap file turns a spike into a slowdown rather than an outage.

fallocate -l 1G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
grep -q '^/swapfile ' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab
echo 'vm.swappiness=10' > /etc/sysctl.d/99-swappiness.conf
sysctl -q -p /etc/sysctl.d/99-swappiness.conf
swapon --show; free -m

The chmod 600 matters: swap can contain secrets from memory, so only root should read the file. vm.swappiness=10 tells the kernel to prefer dropping page cache before swapping out application memory. Check first with swapon --show, because some images already have swap, and do not use this recipe as written on a btrfs or ZFS root, which need extra steps.

Swap is a cushion and not a plan. If free -m shows swap in constant use, the box is too small or something leaks. Cap the app itself with MemoryMax= in its systemd unit so that a runaway process dies alone instead of dragging the whole machine into swap.

What stands between the internet and your app

Stacked layers from the internet down to the app: the provider firewall if any, ufw default deny, SSH with keys only, a non-root user with sudo, and the application itself.Internet scannersProvider firewallIf your dashboard has oneufw default denyOnly 22, 80, 443 openSSH keys onlyNo root login, no passwordsNon-root user with sudoOwn sudo passwordYour app

The whole list in one place

StepWhat it preventsHow you check it
Full upgrade and rebootKnown bugs from the day the image was builtapt list --upgradable is empty
Non-root user with keyShared root passwords, no audit trailssh deploy@host works
Drop-in with PasswordAuthentication noPassword guessing, root takeoversshd -T and a failed password login
ufw default denyServices listening by accidentScan from another machine
unattended-upgradesMonths-old unpatched packagesunattended-upgrades.log
Time syncCert and signature failurestimedatectl
Swap file plus MemoryMax=One spike killing a random processswapon --show

Mistakes we see

Hardening SSH before testing the key. The classic. Someone sets PasswordAuthentication no, reloads, closes the session, and discovers the key was pasted wrongly. Then it is console and rescue mode. Test in a second terminal every time. Our script for this refuses to run if the login user has no authorized_keys at all, because that is the one state where hardening guarantees a lockout.

Editing sshd_config directly and fighting the package. On upgrade, dpkg asks what to do about your modified file, and people accept the maintainer version without reading. A drop-in survives.

Enabling ufw before allowing SSH. The rule order is the whole point. Allow first, enable second.

Trusting that Docker respects ufw. It does not for published ports.

Skipping the reboot. Two weeks later the box reboots for a provider maintenance window, the app does not start because it was launched by hand, and you find out from a customer. Reboot once at the end of setup, on your terms, and check that everything comes back.

Changing the SSH port and calling it security. It cuts log noise a little and stops nothing aimed at you. Spend the effort on keys. (If you do move it, remember that on recent Ubuntu releases SSH can be socket-activated, so a Port line in sshd_config alone is not enough.)

FAQ

Do I still need fail2ban if password login is off?

It is optional. With passwords disabled, guessing cannot succeed, so fail2ban mostly reduces log noise and the small CPU cost of rejected connections. It still helps against other abuse of exposed services. We cover what it does and does not stop in SSH, firewall and fail2ban basics.

Is it safe to enable unattended-upgrades on a production box?

For security updates from the Debian-Security origin, yes for most small apps, because those are narrow fixes. The risk is a service restart in the middle of the day. Keep automatic reboot off until you have tested that the app starts at boot, and keep backups you have actually restored.

Should I use a different user than root even on a throwaway server?

Yes. It costs two minutes, and the habits carry over to servers that matter. It also gives you a log of who ran sudo and what, which root logins do not.

How do I get back in if I lock myself out?

Use the provider's web console or rescue mode, log in with the password you set in their dashboard, and fix or delete /etc/ssh/sshd_config.d/10-hardening.conf, then sshd -t && systemctl reload ssh. This is why Step 0 exists.

The VPS Handbook turns this list into scripts that are safe to run twice, plus a one-page first-day checklist you can print; see the handbook if you would rather not retype commands for every new server.

sshsecurityvps