Try describing what you need:

selfhostedworld.com·Guide·Checked October 2026

Your first hour on a new server

A fresh VPS (a virtual server you rent) starts answering scanners within minutes. This guide walks you through the first hour: a normal user, SSH keys, a firewall, and automatic security updates.

It assumes a server without a desktop, running Ubuntu 24.04 LTS or Debian 13. Pick Ubuntu 24.04 LTS if you have no preference. It has the most tutorials. The package, user, SSH-config and update commands below were tested on both systems.

Replace 203.0.113.10 with your server's IP address and alice with the user name you want.

Log in and update

Your provider gives you an IP address and a first login, usually root. SSH (secure shell) is the tool that opens a terminal on the server from your computer. From your own computer, run:

ssh root@203.0.113.10

Update the package list and install what the next steps need. Some minimal images do not include sudo, so install it now:

apt update && apt upgrade -y
apt install -y sudo ufw

Create a user that is not root

Root can do anything, and every scanner on the internet tries that name first. Do your daily work as a normal user who borrows root power with sudo.

adduser alice
usermod -aG sudo alice

adduser asks for a password. Pick a long one. sudo asks for it later.

Set up SSH keys

A key pair has two files. The private key stays on your computer. The public key goes on the server. The server lets in anyone who proves they hold the private key, so there is no password to guess.

On your own computer, not the server:

ssh-keygen -t ed25519 -C "my-laptop"
ssh-copy-id alice@203.0.113.10

ssh-keygen asks for a passphrase. Set one: it encrypts the private key on disk. ssh-copy-id asks for alice's password once, then installs the public key. Windows PowerShell has no ssh-copy-id. Use WSL, or paste the contents of id_ed25519.pub into ~/.ssh/authorized_keys on the server by hand.

If you gave the provider a key when creating the server, password login may already be off, and ssh-copy-id fails with Permission denied (publickey). In that case, copy root's key to alice in your root session:

mkdir -p /home/alice/.ssh
cp /root/.ssh/authorized_keys /home/alice/.ssh/
chown -R alice:alice /home/alice/.ssh
chmod 700 /home/alice/.ssh && chmod 600 /home/alice/.ssh/authorized_keys

Test the key before you close anything

Keep your first session open. Open a second terminal on your computer and force key-only login:

ssh -o PreferredAuthentications=publickey alice@203.0.113.10
sudo whoami

The first command must log you in without asking for alice's account password. (It may ask for your key passphrase.) The second must print root. If either fails, fix it now, in the session that is still open.

Turn off password and root login

This is where beginners lock themselves out. If you switch off passwords before the key works, nobody can log in, including you. The only way back is your provider's web console, if it has one. That is why you test first and keep the first session open.

Current Debian and Ubuntu read every *.conf file in /etc/ssh/sshd_config.d/. Put your changes in your own file instead of editing the main one. File names matter. OpenSSH uses the first value it finds for each setting, and it reads files in alphabetical order. Some cloud images ship a file such as 50-cloud-init.conf that allows passwords. Name yours 00-hardening.conf so it wins.

In the first session, as alice:

sudo tee /etc/ssh/sshd_config.d/00-hardening.conf > /dev/null <<'EOF'
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
EOF
sudo sshd -t
sudo sshd -T | grep -E '^(permitrootlogin|passwordauthentication|kbdinteractiveauthentication)'

sshd -t checks the syntax and prints nothing when it is fine. A mistake in this file can stop sshd from starting, so never skip that check. sshd -T prints the values that actually apply. You want no on all three lines.

Now restart the service. On both systems it is called ssh, and sshd is only an alias:

sudo systemctl restart ssh

Ubuntu 24.04 starts ssh through a systemd socket (ssh.socket), which listens on the port for it. For the settings above this changes nothing. Do not change the SSH port as a beginner. Moving it hides nothing from a scanner, and on Ubuntu 24.04 a changed Port also needs sudo systemctl daemon-reload, or sshd keeps listening on 22.

Restarting does not end sessions that are already open. In your second terminal, check that both doors are shut:

ssh root@203.0.113.10
ssh -o PubkeyAuthentication=no alice@203.0.113.10

Both should answer Permission denied (publickey). Then confirm that ssh alice@203.0.113.10 still works with your key. Only now close the first session.

Turn on the firewall

A firewall drops traffic to ports you did not open. Allow SSH first, or you cut off your own session. ufw ("uncomplicated firewall") is the simple tool for this on Debian and Ubuntu. The SSH server package ships an OpenSSH profile that opens port 22, and ufw blocks incoming traffic by default.

sudo ufw allow OpenSSH
sudo ufw enable
sudo ufw status verbose

ufw enable warns that it may disrupt SSH connections. Answer y, because the rule is already there. Open further ports only when something needs them, for example sudo ufw allow 443/tcp for a web server.

The Docker trap

Here ufw fails you without a sound. Docker says it plainly: if you use ufw, ports you expose with Docker bypass your firewall rules. The packet filtering page explains why: Docker routes container traffic before ufw sees it. So ports: "8080:80" in a Compose file puts that app on the public internet even with ufw on and port 8080 never allowed. Publishing ports is insecure by default, as Docker's own docs put it.

The safe habit is a short rule:

  1. Publish on 127.0.0.1 only. The port then answers only on the server itself.
  2. Better: publish nothing. Put the app on the same Docker network as a reverse proxy, or tunnel agent, and let that one container reach it.

In a Compose file, the first option looks like this:

services:
  web:
    image: nginx
    ports:
      - "127.0.0.1:8080:80"

Docker's docs confirm that with the localhost address only the Docker host can reach the published port. Releases before Docker 28 had a gap on the local network, so run a current Engine. Install it from Docker's official instructions for Ubuntu or for Debian, not from the distro's own package. Docker Compose basics covers the file format.

Check what you did. After docker compose up -d, run sudo docker ps. The PORTS column should read 127.0.0.1:8080->80/tcp. If it reads 0.0.0.0:8080->80/tcp, the app is open to everyone.

To reach a private app from your own computer, use an SSH tunnel. It carries your browser traffic through the SSH login you already secured:

ssh -L 8080:127.0.0.1:8080 alice@203.0.113.10

Leave it running, then open http://127.0.0.1:8080 in your browser. For something longer lasting, see Choose your front door, What is Tailscale? or Cloudflare Tunnel explained. They keep the ports closed too. Why you should not open ports explains the stakes.

Automatic security updates

Most break-ins use a flaw that already has a fix. unattended-upgrades installs fixes for you each day. Ubuntu installs it by default and applies security updates automatically. Debian's wiki page covers the same package. Install and check it:

sudo apt install -y unattended-upgrades
cat /etc/apt/apt.conf.d/20auto-upgrades
sudo unattended-upgrade --dry-run --debug

The file should hold two lines, APT::Periodic::Update-Package-Lists "1"; and APT::Periodic::Unattended-Upgrade "1";. The number is days between runs. If the file is missing, run sudo dpkg-reconfigure unattended-upgrades and answer yes. The dry run changes nothing. It shows what the tool would do. Logs go to /var/log/unattended-upgrades/.

Two limits. First, reboots are off by default (Automatic-Reboot "false"), so a kernel update waits until you restart. The file /var/run/reboot-required appears when a restart is due. The package adds a kernel hook that creates it, on Debian too. Reboot at a quiet time. Second, the tool only tracks the system's own repositories. It does not update your containers, and it does not update Docker itself unless you apt-upgrade. Update without breaking covers that.

Optional: ban repeat offenders

Fail2Ban reads log files and blocks addresses with repeated failed logins. CrowdSec does similar work, and it can also use a shared list of bad addresses from other users. With key-only SSH neither is urgent, since a password guess cannot succeed. They matter more once you run a login page on the open internet.

A second layer at your provider

Most VPS providers offer a firewall outside your server. Hetzner's Cloud Firewall, for example, blocks all inbound traffic that no rule allows. You set it in the provider's dashboard, not on the server, so Docker's rules on the server cannot get around it. Allow TCP 22 for SSH, plus 80 and 443 if you run a web server, and nothing else. Provider names and menus differ, so look for "Firewall" in the dashboard.

Checklist

  • You log in as a normal user with sudo, not as root.
  • ssh -o PubkeyAuthentication=no alice@203.0.113.10 answers Permission denied (publickey).
  • sudo sshd -T shows permitrootlogin no and passwordauthentication no.
  • sudo ufw status verbose shows Status: active and allows only the ports you chose.
  • sudo docker ps shows no 0.0.0.0: ports you did not mean to publish.
  • cat /etc/apt/apt.conf.d/20auto-upgrades shows both settings at "1".
  • The provider firewall is on and allows only the same ports.

Next: Docker Compose basics.

selfhostedworld.com, tracking self-hosted open-source software