Ubuntu Server Initial Setup Checklist (2026 Edition)
The first 20 minutes on a fresh Ubuntu 24.04 server: updates, sudo user, SSH keys, sshd drop-ins, UFW, fail2ban, auto-updates, swap, journald. In order.
Every fresh box I get — VPS, Proxmox clone, whatever — gets the same treatment before anything else touches it. Not because it's fancy, but because the order matters: do SSH before the firewall and you lock yourself out, do the firewall before the user and you're stuck on root.
TL;DR: update, create a sudo user with your key, kill password + root login via an sshd drop-in, UFW with SSH allowed first, fail2ban, unattended-upgrades, a swap file, cap the journal, reboot. Copy-paste version:
apt update && apt full-upgrade -y
adduser lukas && usermod -aG sudo lukas
rsync --archive --chown=lukas:lukas ~/.ssh /home/lukas
printf 'PermitRootLogin no\nPasswordAuthentication no\nKbdInteractiveAuthentication no\n' > /etc/ssh/sshd_config.d/00-hardening.conf
sshd -t && systemctl reload ssh
ufw allow OpenSSH && ufw --force enable
apt install -y fail2ban unattended-upgrades
reboot
Below is each step with the stuff that actually goes wrong. This is written against Ubuntu 24.04 LTS. 26.04 LTS is due in April — I'll check what changes when it lands, but nothing below should break.
1. Update everything first
apt update && apt full-upgrade -y
apt autoremove --purge -y
full-upgrade, not upgrade — plain upgrade holds back packages that need new dependencies, which is often exactly the kernel. If /var/run/reboot-required exists afterwards, reboot now, not "later". Later never comes.
2. Hostname and time
hostnamectl set-hostname web01
timedatectl set-timezone UTC
timedatectl status
I keep servers on UTC. Logs from five boxes in five timezones are miserable to correlate. timedatectl status should say System clock synchronized: yes and NTP service: active — that's systemd-timesyncd, which is fine for anything that isn't a time server.
On cloud images, cloud-init might reset the hostname on reboot. If it does, set preserve_hostname: true in /etc/cloud/cloud.cfg.
3. A real user with sudo and your SSH key
adduser lukas
usermod -aG sudo lukas
rsync --archive --chown=lukas:lukas ~/.ssh /home/lukas
The rsync line copies root's authorized_keys (the one your provider injected) to the new user with the right ownership. If root has no key, do it from your workstation instead:
ssh-copy-id -i ~/.ssh/id_ed25519.pub lukas@your-server
Now open a second terminal and log in as the new user. Run sudo -v. Only if that works do you move on. Full background on users, groups and keys: Linux user management.
4. SSH: drop-in, not sshd_config
cat > /etc/ssh/sshd_config.d/00-hardening.conf <<'EOF'
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
MaxAuthTries 3
EOF
sshd -t && systemctl reload ssh
sshd -T | grep -Ei 'permitrootlogin|passwordauthentication'
sshd -t checks syntax. sshd -T prints the effective config — that's the one you trust, not the file you just wrote. On Ubuntu the service is ssh, not sshd.
Gotcha: the cloud-init drop-in wins
OpenSSH uses the first value it reads for most options. sshd_config includes sshd_config.d/*.conf at the very top, in alphabetical order. Lots of cloud images ship 50-cloud-init.conf containing PasswordAuthentication yes. If you name your file 99-hardening.conf, the cloud-init one is read first and your setting is silently ignored.
That's why mine is 00-hardening.conf. And that's why you check with sshd -T instead of trusting the file.
Gotcha: changing the SSH port on 24.04
Ubuntu 22.10+ uses socket activation for SSH. Port 2222 in a drop-in does nothing until you do:
systemctl daemon-reload
systemctl restart ssh.socket
ss -tlnp | grep ssh
Personally I don't move the port anymore. It cuts log noise, it doesn't stop anyone who's actually looking. Keys-only plus fail2ban does the real work. The longer list is in my SSH hardening checklist.
5. UFW — SSH first, then enable
ufw default deny incoming
ufw default allow outgoing
ufw allow OpenSSH
ufw --force enable
ufw status verbose
If you ufw enable before allowing SSH over a remote session, you're done for and you get to find out how good your provider's web console is. Open ports for actual services only when they exist. More rules and patterns: UFW firewall rules.
Gotcha: Docker ignores UFW
If this box will run Docker, know this up front: docker run -p 8080:80 writes its own iptables rules that bypass UFW entirely. ufw status will show nothing open and the port is reachable from the internet anyway. Bind to localhost (-p 127.0.0.1:8080:80) and put a reverse proxy in front. Covered in the Docker install post.
6. fail2ban
apt install -y fail2ban
cat > /etc/fail2ban/jail.local <<'EOF'
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
backend = systemd
[sshd]
enabled = true
EOF
systemctl enable --now fail2ban
fail2ban-client status sshd
backend = systemd matters — on 24.04 sshd logs go to the journal, and depending on your rsyslog setup the default file-based backend can end up watching a log nothing reads. Add your home IP to ignoreip if it's static. Nginx jails etc: fail2ban for SSH and nginx.
7. Automatic security updates
Ubuntu Server usually has unattended-upgrades installed and on. Verify instead of assuming:
apt install -y unattended-upgrades
cat /etc/apt/apt.conf.d/20auto-upgrades
You want both values at "1". Whether to allow automatic reboots is a real decision, not a default — I wrote it up separately: automatic security updates on Ubuntu and Debian.
8. Swap file
Small VPS images often come with zero swap. One runaway process and the OOM killer picks something important.
fallocate -l 2G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
echo 'vm.swappiness=10' > /etc/sysctl.d/99-swap.conf
sysctl --system
free -h
Swap is not extra RAM. It's a buffer so the box degrades instead of killing things. Low swappiness keeps the kernel from swapping eagerly. On ZFS root, don't put a swapfile on ZFS — use a separate partition or zram.
9. Cap the journal
journald defaults to up to 10% of the filesystem, capped at 4G. On a 20 GB disk that's a lot of space for logs nobody reads.
mkdir -p /etc/systemd/journald.conf.d
printf '[Journal]\nSystemMaxUse=500M\n' > /etc/systemd/journald.conf.d/size.conf
systemctl restart systemd-journald
journalctl --disk-usage
Plain log files in /var/log are handled separately by logrotate, which is already set up for everything installed via apt.
10. Look at what's listening
ss -tulpn
On a fresh 24.04 you should see sshd, systemd-resolved on 127.0.0.53, maybe chrony or timesyncd. Anything bound to 0.0.0.0 that you didn't install, find out what it is now.
11. The optional stuff I do anyway
apt install -y htop ncdu curl git rsync tmux
- Snap: I leave it alone on servers. Ripping it out saves a bit of RAM and causes weird breakage the next time something depends on it. Not worth it.
- Unused services:
systemctl list-unit-files --state=enabledand disable what you don't need. On cloud images that's often nothing. - Backups: before there's data on it. Not after the first incident.
12. Reboot and re-verify
reboot
After it comes back, log in as your user (not root), and run:
sudo sshd -T | grep -Ei 'permitrootlogin|passwordauthentication'
sudo ufw status
sudo fail2ban-client status sshd
swapon --show
uname -r
If root login is no, password auth is no, UFW is active, fail2ban has a jail and swap shows up — the box is ready for its actual job. The point of the reboot is to prove all of this survives one. Finding out during an outage is worse.
Gotcha: locking yourself out
Three ways I see people lock themselves out, in order of frequency:
- Password auth disabled before the key login was tested. Always test in a second session.
ufw enablebeforeufw allow OpenSSH.AllowUsersset with a typo in the username.
Keep the original root session open until you've logged in fresh as the new user, with sudo working. Close it only after that.