VPS Setup — Ubuntu 26.04
First hour on a fresh Ubuntu 26.04 LTS (Resolute Raccoon) server: patch, create an admin user, lock down SSH, turn on the firewall, join the tailnet, enable automatic security updates, then install the everyday tools. The Arch equivalent is VPS Setup - Arch Linux.
Do not lock yourself out
Keep the original root session open until a second terminal has logged in as the new user and run
sudo -v. Repeat that check after every SSH or firewall change. Know where the provider’s web console is before you start; it is the only way back in if SSH breaks.
1. Patch and set the basics
Run as root on first login.
apt update && apt full-upgrade -y
timedatectl set-timezone UTC
hostnamectl set-hostname web-1
[ -f /var/run/reboot-required ] && rebootTime sync needs nothing: 26.04 ships chrony enabled by default.
2. Admin user
Create the user and copy root’s key across. The commands are explained in Linux Setup.
adduser alice
usermod -aG sudo alice
rsync --archive --chown=alice:alice ~/.ssh /home/alice/From a second terminal, confirm the new account works before going further:
ssh alice@203.0.113.10
sudo -vsudo on 26.04 is sudo-rs, the Rust rewrite. Ordinary sudoers rules and visudo behave the same; a few rarely used Defaults options are unsupported. The original is still installed as sudo.ws if a tool needs it.
3. Harden SSH
Put the settings in a drop-in instead of editing /etc/ssh/sshd_config. Two details decide whether the file works at all:
sshdkeeps the first value it reads for each option, and drop-ins load in name order. Cloud images ship50-cloud-init.conf, which can setPasswordAuthentication yes. The file below is named10-…so it is read first and wins.- Replace
aliceinAllowUserswith your user. Every account not listed there is refused, includingroot.
sudo tee /etc/ssh/sshd_config.d/10-hardening.conf << 'CONF'
# Keys only, no root, named users only
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitEmptyPasswords no
AuthenticationMethods publickey
AllowUsers alice
# Fewer guesses, shorter window
MaxAuthTries 3
LoginGraceTime 20
# Authentication methods a single-user server does not need
HostbasedAuthentication no
KerberosAuthentication no
GSSAPIAuthentication no
# Features that are rarely needed
X11Forwarding no
PermitUserEnvironment no
AllowAgentForwarding no
PermitTunnel no
# Drop dead connections; hide the distribution string
ClientAliveInterval 300
ClientAliveCountMax 2
DebianBanner no
CONFTest the syntax, apply, then read back what sshd actually resolved:
sudo sshd -t && sudo systemctl reload ssh
sudo sshd -T | grep -iE '^(permitrootlogin|passwordauthentication|allowusers|x11forwarding) 'Log in from a new terminal before closing the old one.
- Port forwarding stays on. DigitalOcean’s guide also sets
AllowTcpForwarding no. Add it on a server that only serves traffic; leave it out if you usessh -Ltunnels (SSH - Snippets) or an editor’s remote mode. - Changing the port. Ubuntu starts
sshdthrough socket activation, so aPortorListenAddresschange needssudo systemctl daemon-reload && sudo systemctl restart ssh.socket; a plain reload does not move the listener. Every firewall command in the sections below then needs the new port (for example2222/tcp) in place ofOpenSSH, including the rules that remove and restore public SSH in the Tailscale section; theOpenSSHprofile only covers port 22, so enabling the firewall would otherwise block the new port. Moving off port 22 only reduces log noise. - Allowlisting by address.
AllowUsers alice@203.0.113.0/24restricts a user to a source range. Use it only with a static address, otherwise prefer the Tailscale step below. - Per-key limits. Prefix a line in
~/.ssh/authorized_keyswithrestrict(orrestrict,pty) to strip forwarding and other features from that one key, which suits deploy and backup keys.
4. Firewall (UFW)
Deny everything inbound, allow outbound, and open SSH before enabling.
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw limit OpenSSH # allow, but block an address after 6 attempts in 30 seconds
sudo ufw show added # review the rules before they go live
sudo ufw enable
sudo ufw status verboseOpen more ports only when a service needs them:
sudo ufw allow 80,443/tcp
sudo ufw allow from 203.0.113.0/24 to any port 5432 proto tcp
sudo ufw status numbered # then: sudo ufw delete <number>IPv6 is covered when IPV6=yes is set in /etc/default/ufw, which is the default.
Docker bypasses UFW
Docker writes its own packet-filter rules, so a published port (
-p 5432:5432) is reachable from the internet even when UFW denies it. Bind to loopback or the tailnet address instead (-p 127.0.0.1:5432:5432), or put a provider-level cloud firewall in front.
5. Tailscale
Tailscale puts the server on a private WireGuard network (a “tailnet”), so SSH and admin ports can be closed to the public internet.
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
tailscale ip -4 # the server's 100.x.y.z addressThe order of the next steps matters, because the last one removes public SSH access:
-
From your own machine, which must also be on the tailnet, open a new session to the tailnet address and keep it open:
ssh alice@100.x.y.z. -
In the Tailscale admin console, open Machines, choose the server, and select Disable key expiry. Node keys otherwise expire after 180 days by default, and an expired key takes the server off the tailnet.
-
Allow tailnet traffic through the firewall:
sudo ufw allow in on tailscale0 sudo ufw allow 41641/udp # lets peers connect directly instead of through a relay -
Only after step 1 works, close SSH on the public interface:
sudo ufw delete limit OpenSSH sudo ufw reload -
Confirm from outside:
ssh alice@<public-ip>should time out andssh alice@100.x.y.zshould connect. Sessions that were already open over the public address stay up until they close.
If the tailnet is ever unreachable, the provider’s web console is the way back in; re-add the rule with sudo ufw limit OpenSSH.
sudo tailscale up --ssh is the alternative: Tailscale authenticates SSH sessions itself using the tailnet’s access rules, and no keys need to be distributed.
6. Automatic security updates
unattended-upgrades and needrestart are installed by default on 26.04 server. Confirm that unattended upgrades are switched on, then allow the server to reboot itself when a kernel or library update requires it:
sudo dpkg-reconfigure -plow unattended-upgrades
sudo tee /etc/apt/apt.conf.d/52unattended-upgrades-local << 'CONF'
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "09:00";
CONF
sudo unattended-upgrade --dry-run --debugThe reboot time is in the server’s timezone, UTC here. Leave automatic reboot off on a server that must not restart unannounced, and watch for /var/run/reboot-required instead.
7. Recommended programs
Every package name below exists in the 26.04 archive.
# Essentials
sudo apt install -y git curl wget ca-certificates gnupg build-essential unzip zip rsync tmux zsh neovim
# Search, navigation, inspection
sudo apt install -y ripgrep fd-find bat fzf jq eza zoxide git-delta tree ncdu btop
# Debugging and network
sudo apt install -y strace lsof bind9-dnsutils mtr-tiny tcpdump
# Development
sudo apt install -y gh shellcheck shfmt just direnv stow sqlite3 python3-venv pipx
# Security audit
sudo apt install -y lynis ssh-auditThings that differ from other distributions:
-
Renamed binaries.
fd-findinstallsfdfindandbatinstallsbatcat. Link the usual names, then log in again so~/.local/binis onPATH:mkdir -p ~/.local/bin ln -s "$(command -v fdfind)" ~/.local/bin/fd ln -s "$(command -v batcat)" ~/.local/bin/bat -
uvis not packaged. Install it per user:curl -LsSf https://astral.sh/uv/install.sh | sh. -
yqis a different tool. Theyqpackage is the Python wrapper aroundjq. For the Goyqthat most scripts expect, usesudo snap install yq. -
Docker.
sudo apt install -y docker.io docker-buildx docker-compose-v2. Membership of thedockergroup is equivalent to root, so add users deliberately. Read the Docker warning in the firewall section first. -
AWS CLI.
sudo apt install -y awscliinstalls version 2.
Language runtimes: nvm and pyenv
Install Node.js and Python per user through version managers, so projects can pin a version and the system copies stay untouched. nvm is not in the 26.04 archive and the packaged pyenv is too old to track current releases, so both come from their upstream installers. Run these as your own user, not root.
# Node.js through nvm; the installer adds its loader to your shell profile
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.8/install.sh | bash
exec "$SHELL"
nvm install --lts
# Libraries pyenv needs to compile Python
sudo apt install -y build-essential libssl-dev zlib1g-dev libbz2-dev libreadline-dev \
libsqlite3-dev libncurses-dev xz-utils tk-dev libxml2-dev libxmlsec1-dev libffi-dev \
liblzma-dev libzstd-dev
# Python 3.14 through pyenv
curl -fsSL https://pyenv.run | bashAdd the pyenv loader to ~/.zshrc, or to ~/.bashrc with bash in place of zsh on the last line:
export PYENV_ROOT="$HOME/.pyenv"
[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"
eval "$(pyenv init - zsh)"Then open a new shell and build the interpreter:
pyenv install 3.14 # newest 3.14.x; compiles from source, takes a few minutes
pyenv global 3.14
python --version && node --version8. Verify
sudo ss -tlnp # every listening TCP socket and the process behind it
sudo ufw status verbose
ssh-audit localhost # grades key exchange, ciphers and host keys
sudo lynis audit system # broad hardening review with a prioritized list
tailscale statusOnly sshd and the services you deliberately opened should appear in the ss output on a public address.
Sources
Adapted from DigitalOcean’s Initial Server Setup with Ubuntu, How To Harden OpenSSH on Ubuntu 20.04 and How To Set Up a Firewall with UFW on Ubuntu, and from Tailscale’s Use UFW to lock down an Ubuntu server. Updated for 26.04: drop-in configuration, KbdInteractiveAuthentication in place of the deprecated ChallengeResponseAuthentication, socket-activated sshd, and sudo-rs.