How to Set Up a VPS for Beginners, from a Beginner (Part 1: Securing the Base System)
Part 1 of setting up my first VPS on Debian 13 Minimal. Initial access, system updates, SSH keys, a non-root user, and SSH hardening, before any services go on the box.
A 5-part series.
· Part 1 — Securing the Base System
Introduction
A fresh VPS is clean and completely exposed at the same time. Nothing is configured, which also means nothing is protecting it. Whatever state it ends up in is down to what you do in the first hour.
I am not writing this as a set of instructions to follow exactly. It is a record of how I got a raw server into a state I was willing to build on.
First Access
The first connection is over SSH as root:
ssh root@IP_VPSThe authenticity of host 'IP_VPS (IP_VPS)' can't be established.
ED25519 key fingerprint is SHA256:qL3nR8vT1wX5yZ0aB4cD7eF9gH2jK6mN8pQ1rS3tU5v.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
root@IP_VPS's password:
Linux vps 6.12.48+deb13-amd64 #1 SMP Debian 6.12.48-1 x86_64
root@vps:~#You get that fingerprint prompt once per server. If it shows up again later for a host you have already connected to, something about the machine answering you has changed, and it is worth finding out what before typing yes.
Root has no restrictions at all, which is convenient for about ten minutes and then stops being a good idea. There is no separation between routine work and things that can break the system, so every command carries the same weight. Root access here is a way in, not the account I intend to keep using.
Updating
Even though the server was created minutes ago, its packages come from whatever base image the provider built it from. So the first thing is to update:
apt update && apt upgrade -yNo
sudohere, because we are still root. On a genuinely minimal Debian installsudois not present yet. It gets installed in the next section.
Hit:1 http://deb.debian.org/debian trixie InRelease
Get:2 http://security.debian.org/debian-security trixie-security InRelease [48.0 kB]
Get:3 http://deb.debian.org/debian trixie-updates InRelease [55.4 kB]
Fetched 103 kB in 1s (98.2 kB/s)
Reading package lists... Done
Building dependency tree... Done
14 packages can be upgraded. Run 'apt list --upgradable' to see them.
...
14 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.Fourteen packages already out of date on a server that is minutes old. That is the reason this goes first.
Choosing What to Install
It is easy to install everything that looks useful at this point. I tried to keep it to things I would actually use for administering the box and running containers on it later:
apt install -y sudo curl git btop ufw fail2ban podmanReading package lists... Done
Building dependency tree... Done
The following additional packages will be installed:
catatonit conmon containers-storage crun golang-github-containers-common
libgpgme11t64 netavark passt uidmap
Suggested packages:
containers-storage docker-compose
0 upgraded, 34 newly installed, 0 to remove and 0 not upgraded.
Need to get 41.2 MB of archives.
After this operation, 168 MB of additional disk space will be used.Worth glancing at that additional packages list rather than scrolling past it. Seven requested packages pulled in twenty-eight more. On a 25 GB disk that is fine, but it is a habit worth keeping.
sudo is first in the list for a reason that caught me out. Debian Minimal does not ship it. The sudo group exists either way, so usermod -aG sudo admin further down succeeds and prints nothing whether or not the binary is there. Disable root login after that and you have no account left that can administer the machine. Install it now.
Why Podman
The obvious question is why podman and not docker, given Docker is still what most production environments run.
It comes down to context. This is one machine I manage on my own, not part of a cluster. Podman runs containers without a daemon, so there is no central process that takes everything down with it when it dies. It also treats rootless as the normal mode rather than an opt-in, which suits a box that is newly exposed to the internet.
Docker’s ecosystem and tooling are more mature, and that is a real advantage in most teams. It also assumes a more complex operational setup than I have here. I picked Podman because it removes things I do not need yet, not because Docker is wrong.
Switching to SSH Keys
Password login works, but passwords depend on human behaviour, which makes them guessable and reusable. On a public server that is a weak spot I would rather not keep.
Generate a key pair:
ssh-keygen -t ed25519 -C "vps-personal"Generating public/private ed25519 key pair.
Enter file in which to save the key (/home/you/.ssh/id_ed25519):
Enter passphrase for "/home/you/.ssh/id_ed25519" (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /home/you/.ssh/id_ed25519
Your public key has been saved in /home/you/.ssh/id_ed25519.pub
The key fingerprint is:
SHA256:7hJ2kL9mN4pQ6rS8tU0vW3xY5zA1bC7dE9fG2hI4jK6 vps-personalTwo files. id_ed25519 stays on this machine. id_ed25519.pub is the half you hand out.
cat ~/.ssh/id_ed25519.pubssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH8kR2pQ7mZvN1xW4tYbL9cD0eF3gH5jK7lM2nP4qR6s vps-personalOne line: key type, the key, and the comment from -C. If what you see starts with -----BEGIN OPENSSH PRIVATE KEY-----, that is the wrong file and it should never leave your machine.
After this the server stops checking a password and starts checking whether the private key you hold matches the public key in authorized_keys. There is nothing left to brute force.
Installing the Key on the Server
The public key needs to end up in ~/.ssh/authorized_keys on the server. You can do that by hand, but ssh-copy-id gets the permissions right in one line. Run it from your local machine:
ssh-copy-id root@IP_VPS/usr/bin/ssh-copy-id: INFO: Source of key(s) to be installed: "/home/you/.ssh/id_ed25519.pub"
/usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are already installed
/usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys
root@IP_VPS's password:
Number of key(s) added: 1
Now try logging into the machine, with: "ssh 'root@IP_VPS'"
and check to make sure that only the key(s) you wanted were added.It uses the password one last time, appends the key rather than overwriting what is there, and creates ~/.ssh with the right modes.
That last part is where manual installs usually go wrong. SSH will not use a key directory that other users can read, and it fails silently: you get a password prompt with no explanation. Check what landed:
ls -la ~/.sshdrwx------ 2 root root 4096 May 20 09:22 .
drwx------ 6 root root 4096 May 20 09:22 ..
-rw------- 1 root root 102 May 20 09:22 authorized_keysdrwx------ is 700, -rw------- is 600. Owner only. If you ever install a key by hand and logins keep asking for a password, look here first.
Confirm it works before going further:
ssh root@IP_VPSLinux vps 6.12.48+deb13-amd64 #1 SMP Debian 6.12.48-1 x86_64
Last login: Wed May 20 09:18:44 2026 from 203.0.113.41
root@vps:~#Straight to a prompt, no password line. If you are still asked for one, the key is not being accepted, and it is almost always the permissions above.
Creating a User
Running everything as root removes any boundary between ordinary work and system-level changes. So the next step is an account for day-to-day use:
adduser admininfo: Adding user `admin' ...
info: Selecting UID/GID from range 1000 to 59999 ...
info: Adding new group `admin' (1000) ...
info: Adding new user `admin' (1000) with group `admin (1000)' ...
info: Creating home directory `/home/admin' ...
info: Copying files from `/etc/skel' ...
New password:
Retype new password:
passwd: password updated successfully
Changing the user information for admin
Enter the new value, or press ENTER for the default
Full Name []:
Room Number []:
...
Is the information correct? [Y/n] YEverything after the password can be left blank. Set the password even though logins will use keys, because sudo will ask for it.
Then give it sudo:
usermod -aG sudo admin
groups adminadmin : admin sudousermod prints nothing, so groups is how you check it worked.
Group membership and a working sudo are not the same thing, and the gap only shows up once root login is gone. Test it from an admin shell now:
sudo -v[sudo] password for admin:A prompt that accepts the password and drops you back to the shell is what you want. sudo: command not found means the package is missing, so go back and install it while root still works.
The new account needs the same key. Same command, different target, still from your local machine:
ssh-copy-id admin@IP_VPS/usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys
admin@IP_VPS's password:
Number of key(s) added: 1The password it asks for is the one from adduser. This is close to the last time it will be useful, since password authentication gets turned off in a moment and this command stops working entirely after that.
You can copy /root/.ssh/authorized_keys across by hand instead, but it arrives owned by root and SSH ignores it. ssh-copy-id writes as the target user, so there is nothing to clean up.
Check the new account:
ssh admin@IP_VPSLinux vps 6.12.48+deb13-amd64 #1 SMP Debian 6.12.48-1 x86_64
admin@vps:~$The prompt ends in $ instead of #. That is the shell telling you it is not root.
Reducing Exposure
The system works, but it is still open in ways automated attacks routinely target. Time to close some of that off:
nano /etc/ssh/sshd_configChange the port. This does not add real security on its own, but it cuts down the noise from bots scanning port 22:
Port 22022Turn off password authentication:
PasswordAuthentication no
PubkeyAuthentication yesAnd disable root login, so a compromised account cannot immediately operate with full privileges:
PermitRootLogin noThose last two are the meaningful ones. They remove the entry points automated SSH attacks actually use.
The Include Line at the Top of That File
Before restarting anything, scroll to the top of sshd_config. Debian puts this there:
Include /etc/ssh/sshd_config.d/*.confsshd resolves conflicts by taking the first value it finds, not the last. So anything in that directory beats what you just wrote further down. Plenty of VPS images ship a 50-cloud-init.conf containing exactly the setting you came to change:
ls /etc/ssh/sshd_config.d/
cat /etc/ssh/sshd_config.d/*.conf50-cloud-init.conf
PasswordAuthentication yesYour edit is saved, correct, and doing nothing. Delete that file or edit it instead. A drop-in of your own that sorts before it by name also works.
Rather than working out the precedence in your head, ask sshd what it resolved to:
sudo sshd -T | grep -Ei '^(port|passwordauthentication|permitrootlogin|pubkeyauthentication)'port 22022
permitrootlogin no
pubkeyauthentication yes
passwordauthentication noThat is the effective config after every include is processed, and it is the only reading of this file I trust. sshd -T also refuses to print anything if the syntax is broken, which is a free validity check before restarting the service you are connected through.
Restart it:
sudo systemctl restart ssh
sudo systemctl status ssh● ssh.service - OpenBSD Secure Shell server
Loaded: loaded (/usr/lib/systemd/system/ssh.service; enabled; preset: enabled)
Active: active (running) since Wed 2026-05-20 09:41:02 UTC; 4s ago
Main PID: 1284 (sshd)
Tasks: 1 (limit: 1131)active (running) only means the service came back. Whether your changes did what you wanted takes an actual connection to find out.
So before closing this session, open a second terminal:
ssh -p 22022 admin@IP_VPSIf it works you land at the same prompt as before. What I got was:
ssh: connect to host IP_VPS port 22022: Connection refusedKeeping the original session open is what saves you here. If the config is wrong, that connection is the only way back in.
If the new port does not answer, you are probably hitting what I hit. Debian 13 activates SSH through a systemd socket by default, which means the Port directive is never read. Part 2 opens with that and the fix.
Making the Connection Shorter
Every connection now needs a non-default port, a username, and an IP I do not have memorised:
ssh -p 22022 admin@IP_VPSThat is not a security problem, but it is the kind of friction that gets you copy-pasting commands out of shell history without reading them. It also breaks anything expecting a plain hostname. scp, rsync, and editor remote extensions each want the port and user passed in their own slightly different syntax.
SSH has a client-side config for this. It lives on your local machine; the server neither knows nor cares that it exists:
nano ~/.ssh/configHost vps
HostName IP_VPS
User admin
Port 22022
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yesPermissions matter for the same reason they did on authorized_keys:
chmod 600 ~/.ssh/configNow it is just:
ssh vpsLinux vps 6.12.48+deb13-amd64 #1 SMP Debian 6.12.48-1 x86_64
Last login: Wed May 20 10:02:11 2026 from 203.0.113.41
admin@vps:~$Host vps is only a label. Every option under it applies whenever that label is the destination, so the port and username stop being things to remember and become part of what vps means.
What I did not expect is how far this reaches past the ssh command. Anything that speaks SSH reads the same file:
scp backup.tar.gz vps:~/
rsync -avz ./dist/ vps:~/apps/site/Editors too. Pointing VS Code’s Remote-SSH at vps is enough, with no port field, user field, or key path.
IdentitiesOnly yes is worth understanding rather than copying. Without it, SSH offers every key in your agent one after another until one is accepted. With more than a few keys loaded, the server can hit MaxAuthTries and reject you before reaching the right one. The error is “too many authentication failures”, which looks exactly like a broken key setup. This line tells SSH to offer only the key on the IdentityFile line.
You can keep adding hosts, and each entry stands alone:
Host vps
HostName IP_VPS
User admin
Port 22022
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
Host vps-root
HostName IP_VPS
User root
Port 22022
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yesThat second entry will not work anymore, since PermitRootLogin no is doing its job. Which is a reasonable way to confirm the hardening took effect.
Next Part
The server is no longer in its fresh-install state. Root and a normal user are separated, SSH requires keys, and the obvious entry points are closed.
That is only the login layer, though. Every other port on the machine is still open, nothing is watching for repeated failures, and security updates only happen when I remember to run them.
Part 2 picks up from here with firewall rules, fail2ban, and automatic patching, and the series carries on through rootless Podman, Caddy and HTTPS, and deploying a real application.