Ubuntu 24.04 Server Quick Start: Sudo User, SSH Hardening, fail2ban, Docker and Dockge

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:

  • ChallengeResponseAuthentication is the deprecated name for KbdInteractiveAuthentication. 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-USER iptables chain, or use a helper such as ufw-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 with sshd -T
  • daemon-reload + restart ssh.socket, new port verified with ss
  • 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

Leave a comment