Every time I spin up a new Ubuntu server I go through the same handful of steps before anything else touches it. This post is that checklist, written down so I (and hopefully you) don’t have to piece it together again. It assumes a fresh Ubuntu 24.04 LTS install where your SSH public key is already in place for the initial login.
Throughout the post I use youruser as the new account name and 2222 as the new SSH port. Replace both with your own values.
Golden rule: keep your current SSH session open the whole time and test every change from a second terminal. That way a typo never locks you out.
1. Update the system
sudo apt update && sudo apt full-upgrade -y
2. Create a sudo user
sudo adduser youruser
sudo usermod -aG sudo youruser
3. Copy your SSH key to the new user
Run this as the account that currently holds your key (usually root):
sudo mkdir -p /home/youruser/.ssh
sudo cp ~/.ssh/authorized_keys /home/youruser/.ssh/
sudo chown -R youruser:youruser /home/youruser/.ssh
sudo chmod 700 /home/youruser/.ssh
sudo chmod 600 /home/youruser/.ssh/authorized_keys
Now open a new terminal and confirm that ssh youruser@your-server works and that sudo -v succeeds. Don’t continue until it does.
4. Harden the SSH configuration
Rather than editing /etc/ssh/sshd_config directly, I put my settings in a drop-in file. Ubuntu’s main config includes /etc/ssh/sshd_config.d/*.conf at the very top, and sshd uses the first value it finds for each option. That matters, because some images (cloud images in particular) ship a 50-cloud-init.conf that sets PasswordAuthentication yes and would silently override whatever you change further down in the main file. Check what’s already there:
ls -l /etc/ssh/sshd_config.d/
Then create a file whose name sorts first, so it wins:
sudo nano /etc/ssh/sshd_config.d/00-hardening.conf
Port 2222
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
# Optional: only allow specific accounts to log in
AllowUsers youruser
Two notes on things you’ll still find in a lot of older guides:
ChallengeResponseAuthenticationis the deprecated name forKbdInteractiveAuthentication. Use the new one.- Don’t set
UsePAM no. On Ubuntu, PAM handles session setup, limits and account checks. With password and keyboard-interactive auth disabled, PAM can’t be used to log in with a password anyway, so leave it at the default (yes).
Validate the syntax and look at the effective configuration, which shows exactly what sshd will use after all includes are applied:
sudo sshd -t
sudo sshd -T | grep -Ei '^(port|permitrootlogin|passwordauthentication|kbdinteractiveauthentication|pubkeyauthentication)'
5. Open the firewall (before applying the SSH change)
Allow both the old and the new port for now, so you can’t cut off your own session:
sudo ufw allow OpenSSH # port 22, temporary
sudo ufw allow 2222/tcp
sudo ufw enable
sudo ufw status verbose
6. Apply the SSH change: the Ubuntu 24.04 gotcha
Two things catch people out here. First, on Ubuntu the service is called ssh, not sshd, so systemctl restart sshd just fails with “Unit sshd.service not found”. Second, since 22.10 Ubuntu uses systemd socket activation for SSH: ssh.socket owns the listening port, not the daemon. A systemd generator reads Port from the sshd config and builds the socket from it, but only when systemd reloads. Restarting ssh alone leaves you listening on port 22.
sudo systemctl daemon-reload
sudo systemctl restart ssh.socket
Verify what’s actually listening:
sudo ss -tlnp | grep -E ':(22|2222)\b'
From a new terminal, test the new port, and test that root and password logins are refused:
ssh -p 2222 youruser@your-server # should work
ssh -p 2222 root@your-server # should be refused
ssh -p 2222 -o PubkeyAuthentication=no youruser@your-server # should be refused
Once that all checks out, close port 22:
sudo ufw delete allow OpenSSH
sudo ufw status
Tip: add the host to ~/.ssh/config on your workstation so you don’t have to type -p 2222 every time.
7. fail2ban
sudo apt install fail2ban -y
Many guides tell you to copy the whole jail.conf to jail.local. I don’t. jail.local only needs to contain your overrides; everything else is inherited from jail.conf, which then keeps getting updated with the package. Create a short file:
sudo nano /etc/fail2ban/jail.local
[sshd]
enabled = true
port = 2222
maxretry = 5
findtime = 10m
bantime = 1h
enabled = true is required: jails are disabled by default. Don’t touch [dropbear] (a different, lightweight SSH server) or [selinux-ssh] (for SELinux distros; Ubuntu uses AppArmor). Only enable jails for services you actually run.
sudo systemctl enable --now fail2ban
sudo systemctl restart fail2ban
sudo fail2ban-client status
sudo fail2ban-client status sshd
8. Lock the root password (optional)
sudo passwd -l root
This disables password authentication for root (on many installs it never had one). sudo keeps working.
9. Install Docker Engine and Docker Compose
Use Docker’s own apt repository, not Ubuntu’s docker.io package. First remove anything that could conflict (apt may report that none of it is installed, which is fine):
sudo apt remove $(dpkg --get-selections docker.io docker-compose docker-compose-v2 docker-doc docker-buildx podman-docker containerd runc | cut -f1)
Add the repository. Docker’s docs now use an .asc key and a deb822-style .sources file instead of the old gpg --dearmor + docker.list combination:
sudo apt update
sudo apt install ca-certificates curl -y
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt update
Install the engine, CLI, Buildx and the Compose plugin:
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin -y
sudo docker run hello-world
To run Docker without sudo, add your user to the docker group, then log out and back in (or run newgrp docker):
sudo usermod -aG docker youruser
docker --version
docker compose version
Be aware that membership in the docker group is effectively root access to the host. Only add accounts you’d trust with sudo.
10. Important: Docker bypasses UFW
This one surprises a lot of people. When you publish a container port (ports: - 5001:5001), Docker inserts its own iptables rules that are evaluated before UFW’s. The port is reachable from everywhere, whatever ufw status says, and ufw deny won’t block it. Docker’s own install docs warn about this.
Practical ways to deal with it:
- Bind to localhost for anything that sits behind a reverse proxy or that you only reach via SSH tunnel:
127.0.0.1:5001:5001. - Bind to a specific interface IP (e.g. a LAN or VPN address) instead of
0.0.0.0. - Put your own filtering rules in the
DOCKER-USERiptables chain, or use a helper such asufw-docker.
11. Install Dockge
Dockge is a lightweight web UI for managing compose.yaml stacks. Unlike Portainer, it doesn’t take ownership of your compose files: they stay as normal files on disk, and you can keep using docker compose on the command line alongside it.
sudo mkdir -p /opt/stacks /opt/dockge
cd /opt/dockge
sudo nano compose.yaml
services:
dockge:
image: louislam/dockge:1
restart: unless-stopped
ports:
# Use "127.0.0.1:5001:5001" if you only access it via SSH tunnel or reverse proxy
- 5001:5001
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./data:/app/data
# Host path and container path MUST be identical
- /opt/stacks:/opt/stacks
environment:
- DOCKGE_STACKS_DIR=/opt/stacks
# Optional: own stack files as your user instead of root (check with: id youruser)
# - PUID=1000
# - PGID=1000
The stacks volume is the detail most likely to bite you: Dockge runs docker compose against the host’s Docker daemon, so the stacks directory has to have the same path inside the container as on the host. Mapping something like ./stacks:/opt/stacks looks harmless but breaks relative paths and can write data to the wrong place. Also note that the top-level version: key is obsolete in Compose v2 and can be left out.
docker compose up -d
Open http://your-server-ip:5001 and create the admin account immediately: until you do, anyone who reaches the page can claim it. If you bound it to localhost, use an SSH tunnel instead:
ssh -p 2222 -L 5001:127.0.0.1:5001 youruser@your-server
# then browse to http://localhost:5001
To bring existing stacks under Dockge, move each one to /opt/stacks/<name>/compose.yaml and use “Scan Stacks Folder” in the menu. Updating Dockge itself:
cd /opt/dockge
docker compose pull && docker compose up -d
Quick checklist
- System updated
- Sudo user created, key login tested
- SSH settings in
/etc/ssh/sshd_config.d/00-hardening.conf, verified withsshd -T daemon-reload+restart ssh.socket, new port verified withss- UFW enabled, port 22 removed after testing
- fail2ban
[sshd]jail enabled on the new port - Docker from the official repo; published ports bound deliberately, keeping in mind that they bypass UFW
- Dockge running with
/opt/stacks:/opt/stacks, admin account created