The First Hour on a Fresh Debian or Ubuntu VPS
Set up a sudo user, SSH keys, an sshd drop-in that blocks password and root logins, a ufw firewall, and automatic security updates on a new Debian or Ubuntu server, checking each step as you go.

A new VPS usually arrives with a root login (a password, or a key the provider injected) and SSH open to the whole internet. If you want to know whether anyone has noticed, run journalctl -u ssh on any server that’s been up for a day and look at the failed logins.
Here’s a short list worth working through before you install anything interesting. By the end, you log in as a normal user with a key, root and passwords can’t log in over SSH, only the ports you chose are open, and security updates install themselves. Every step ends with a check, so you never stack one assumption on another.
One rule for the whole hour: keep your current SSH session open until a fresh login has worked. It’s like keeping the door propped open while you change the lock. Before starting, also find your provider’s web or serial console. You want to know where it is now, not after you’ve locked yourself out.
Step 1 runs as root; if your provider gave you a sudo user instead, put sudo in front. From step 3 on, the commands use sudo, because by then you’ll be working as the new user.
1. Create a normal user with sudo
Some images already come with a sudo user. Check first:
getent group sudo
sudo:x:27:
Nothing after the last colon means the group has no members yet. (If a name shows up there, that user already exists; skip to step 2 and use it.) Otherwise, create a user and add it:
apt update && apt install -y sudo
adduser deploy
usermod -aG sudo deploy
adduser asks for a password. That password is for sudo. You might type it once more for ssh-copy-id in the next step, but SSH password logins are about to be switched off. Make it long and keep it in a password manager.
Confirm what the user is allowed to do:
sudo -l -U deploy
User deploy may run the following commands on vps1:
(ALL : ALL) ALL
2. Put your SSH key on the new user
Do this on your own machine, not the server. If you don’t have a key yet, create one:
ssh-keygen -t ed25519 -C "laptop"
ssh-copy-id -i ~/.ssh/id_ed25519.pub deploy@203.0.113.10
ssh-copy-id logs in with the user’s password one last time and appends the public key to ~/.ssh/authorized_keys on the server. Your first connection will also ask you to trust the server’s host key. A Calm SSH Host Key Check covers how to compare that fingerprint against the provider console instead of just typing yes.
If the provider put your key on root and password login is already disabled, copy root’s file across on the server instead:
install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
install -m 600 -o deploy -g deploy /root/.ssh/authorized_keys /home/deploy/.ssh/authorized_keys
Now test from a new terminal, telling your client not to fall back to a password:
ssh -o PasswordAuthentication=no deploy@203.0.113.10
sudo whoami
You should get a shell without a password prompt, and sudo whoami should print root after asking for the sudo password.
If you see Permission denied (publickey), the key isn’t being accepted. The server’s log usually says why; run journalctl -u ssh -n 20 in the root session. A line like Authentication refused: bad ownership or modes for directory /home/deploy/.ssh means the permissions are too loose; sshd checks them by default (StrictModes yes). The two install commands above set them correctly.
Once this works, do the rest from the deploy session and leave the original root session alone. That one is your safety net.
3. Turn off password and root logins with a drop-in
On Debian and Ubuntu, the main config file starts with this line:
grep '^Include' /etc/ssh/sshd_config
Include /etc/ssh/sshd_config.d/*.conf
It comes before every other directive. For most of them, sshd keeps the first value it reads and ignores later ones. Because the Include comes first, a small file in sshd_config.d/ beats anything further down the main file. Between drop-ins, files load in name order, so a lower number wins.
That last part matters. cloud-init can write /etc/ssh/sshd_config.d/50-cloud-init.conf, sometimes with PasswordAuthentication yes. A file named 10-… gets read first and wins.
Look at what’s there and what sshd currently believes:
ls /etc/ssh/sshd_config.d/
sudo sshd -T | grep -E '^(permitrootlogin|passwordauthentication|kbdinteractiveauthentication)'
sshd -T prints the effective configuration after every file and default has been merged. With the packages’ stock configuration on Debian and Ubuntu, it shows:
permitrootlogin without-password
passwordauthentication yes
kbdinteractiveauthentication no
without-password is the older spelling of prohibit-password, the OpenSSH default: root may log in with a key but not with a password. Time to close both doors, plus the side door:
sudo tee /etc/ssh/sshd_config.d/10-hardening.conf >/dev/null <<'EOF'
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
EOF
KbdInteractiveAuthentication is the other way a password can arrive, through PAM’s prompt. Debian and Ubuntu already set it to no in the main file, but repeating it in your own file costs nothing.
Test the syntax, then reload:
sudo sshd -t && sudo systemctl reload ssh
sshd -t prints nothing when it’s happy. A typo looks like this, and the reload never runs:
/etc/ssh/sshd_config.d/10-hardening.conf: line 2: Bad configuration option: PasswordAuthenticaton
/etc/ssh/sshd_config.d/10-hardening.conf: terminating, 1 bad configuration options
Reloading doesn’t drop existing sessions. On Debian and Ubuntu the service is called ssh (sshd is an alias), and its reload action runs sshd -t itself before signalling the daemon. A broken file makes the reload fail and the old settings stay in place. Running the check yourself just means you get to read the error.
Run the sshd -T line again. You want:
permitrootlogin no
passwordauthentication no
kbdinteractiveauthentication no
If passwordauthentication still says yes, something read earlier is setting it. grep -ri passwordauthentication /etc/ssh/sshd_config /etc/ssh/sshd_config.d/ will find it.
Now the real test. With the old session still open, try three logins from a new terminal:
ssh deploy@203.0.113.10 # should work
ssh -o PubkeyAuthentication=no deploy@203.0.113.10 # should be refused
ssh root@203.0.113.10 # should be refused
The refusals look like this:
deploy@203.0.113.10: Permission denied (publickey).
The word in brackets is the server’s list of methods it still accepts. It used to include password.
To roll back, delete 10-hardening.conf, run sshd -t, and reload. Do it from the session you kept open, or from the provider console if you closed it anyway.
4. Add a firewall, SSH first
ufw is a friendly front end to the kernel firewall. Its defaults are deny incoming, allow outgoing, and it writes IPv6 rules alongside IPv4 ones (IPV6=yes in /etc/default/ufw). The classic mistake is enabling it before allowing SSH. ufw does warn you, but it takes y for an answer. So allow SSH first:
sudo apt install -y ufw
sudo ufw allow OpenSSH
sudo ufw allow 80,443/tcp # only if this server hosts a website
sudo ufw enable
Over SSH, ufw enable asks Command may disrupt existing ssh connections. Proceed with operation (y|n)?. You’ve just allowed SSH, so y is safe.
If you see ERROR: Could not find a profile matching 'OpenSSH', the profile that ships with openssh-server isn’t installed. Use sudo ufw allow 22/tcp instead.
Check the result:
sudo ufw status verbose
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), deny (routed)
New profiles: skip
To Action From
-- ------ ----
22/tcp (OpenSSH) ALLOW IN Anywhere
80,443/tcp ALLOW IN Anywhere
22/tcp (OpenSSH (v6)) ALLOW IN Anywhere (v6)
80,443/tcp (v6) ALLOW IN Anywhere (v6)
One trap if you use Docker: Docker’s own documentation says Docker and ufw are incompatible, because traffic to published container ports gets rerouted before ufw’s rules see it. A container started with -p 8080:80 is reachable from the internet whatever ufw status says. For anything that sits behind a reverse proxy, publish it on loopback only, such as -p 127.0.0.1:8080:80.
To roll back: sudo ufw disable. Rate limits, a timed safety net for remote changes, and the rest of ufw are in ufw Without Locking Yourself Out.
5. Let security updates install themselves
unattended-upgrades is installed and enabled by default on Ubuntu Server. On Debian it’s one package away, and usually switches itself on during installation. Either way, check instead of assuming:
sudo apt install -y unattended-upgrades
apt-config dump APT::Periodic::Unattended-Upgrade
APT::Periodic::Unattended-Upgrade "1";
"1" means it runs daily. If you see "0", or nothing at all, run sudo dpkg-reconfigure -plow unattended-upgrades and answer yes. That writes /etc/apt/apt.conf.d/20auto-upgrades.
Then ask it what it would do, without doing it:
sudo unattended-upgrade --dry-run -v
systemctl list-timers 'apt-daily*'
The dry run lists the allowed origins: the security archive, plus regular point-release updates on Debian. The timers show when the next automatic run is due. Afterwards, the logs live in /var/log/unattended-upgrades/.
It won’t reboot for you by default (Unattended-Upgrade::Automatic-Reboot is false), so a new kernel sits on disk until you restart. On Ubuntu, a pending reboot shows up as the file /var/run/reboot-required. Reboot policies and how to prove the timer really ran are in Unattended Upgrades You Can Actually Check.
Left out on purpose
- Changing the SSH port. It cuts down on log noise but isn’t a security boundary. On Ubuntu, where SSH is socket-activated by systemd, changing the port involves systemd as well as sshd.
- fail2ban. With passwords off, those password-guessing attempts can’t succeed. fail2ban mainly makes the logs quieter. It’s a perfectly good addition, just not a first-hour essential. When you want it, fail2ban for SSH on Debian 13 covers the setup.
- A long list of “disable every service” commands. On a minimal VPS image there usually isn’t much to disable.
ss -tlnpshows what’s actually listening, which is a better place to start than a generic list.
Once logins are key-only, a short ~/.ssh/config entry saves you from typing deploy@203.0.113.10 every time. SSH Config Aliases for a Small Fleet has a template.
References
- OpenBSD manual: sshd_config(5)
- OpenBSD manual: sshd(8)
- Ubuntu Server documentation: OpenSSH server
- Ubuntu Server documentation: Automatic updates
- Debian Wiki: UnattendedUpgrades
- Debian manpages: ufw(8)
- Docker documentation: Packet filtering and firewalls
- Docker documentation: Port publishing and mapping
- cloud-init source: ssh_util.py
Cover photo: Unsplash.