Any server with a public IP address gets scanned within minutes of going online. Most of that traffic is automated, and it looks for the same weak points: password-based SSH logins, forgotten services, and unpatched packages. This guide is for self-hosters and home-lab operators who have a fresh Debian or Ubuntu machine and want a sensible baseline before running anything important on it. You do not need to be a security specialist. You need sudo access, a second terminal window, and about an afternoon.
The end state is a server that rejects password logins, accepts connections only on the ports you intend, installs security updates on its own, and bans IP addresses that repeatedly fail to log in. You will finish with a checklist you can rerun any time.
Before you start
- A fresh Debian (12 or later) or Ubuntu (22.04 LTS or later) server with a reachable address.
- Root or a user with sudo privileges, and a working login right now. Keep that session open for the whole process.
- An SSH client on your workstation, and the ability to generate an Ed25519 key pair.
- Console access through your hosting provider or hypervisor, in case you lock yourself out.
- A written list of the services that must be reachable from the internet. Decide this before you open any ports.
Step 1: Create a non-root admin user and install your SSH key
Logging in directly as root over SSH is a poor habit, and you will disable root login in a later step. Create an administrative user first. If sudo is not installed, run sudo apt install -y sudo as root before continuing. Replace YOUR_USER with the name you want.
sudo adduser YOUR_USER
sudo usermod -aG sudo YOUR_USEROn your workstation, generate a key pair. Protect the private key with a passphrase, and load it into an agent if you use one.
ssh-keygen -t ed25519 -C "YOUR_EMAIL"
ssh-copy-id -i ~/.ssh/id_ed25519.pub YOUR_USER@YOUR_SERVER_IPOpen a new terminal and confirm that key-based login works before you go further:
ssh -i ~/.ssh/id_ed25519 YOUR_USER@YOUR_SERVER_IPDo not continue until this succeeds. Your current session is your safety net.
Step 2: Harden sshd with a drop-in configuration file
Current Debian and Ubuntu releases include /etc/ssh/sshd_config.d/*.conf near the top of the main sshd_config file. For most keywords, sshd uses the first value it reads, and drop-in files are read in alphabetical order. Cloud images sometimes ship a drop-in that re-enables password logins, so check for conflicts first:
grep -ri passwordauthentication /etc/ssh/sshd_config.d/ /etc/ssh/sshd_configName your file so it sorts early, such as 01-hardening.conf, so its settings win. Replace YOUR_USER with your admin account name.
sudo tee /etc/ssh/sshd_config.d/01-hardening.conf > /dev/null <<'EOF'
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
PermitEmptyPasswords no
X11Forwarding no
MaxAuthTries 3
LoginGraceTime 30
AllowUsers YOUR_USER
EOFValidate the syntax before reloading. A failed test means sshd will not reload, which protects you from a broken config. On Debian and Ubuntu the service is named ssh.
sudo sshd -t && sudo systemctl reload sshThen confirm what sshd actually uses, not what you think you wrote:
sudo sshd -T | grep -Ei '^(permitrootlogin|passwordauthentication|kbdinteractiveauthentication|pubkeyauthentication|allowusers)'Open a third terminal and test a fresh login before closing anything. Only close your original session after the new login works.
Step 3: Set up a default-deny firewall with ufw
The principle is simple: block all inbound traffic, then allow only what you listed in your prerequisites. Install ufw and set the defaults first. Add the SSH rule before enabling the firewall, or you will cut off your own access.
sudo apt update && sudo apt install -y ufw
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp comment 'SSH'If you change the SSH port in a later exercise, update this rule to match. You can also use sudo ufw limit 22/tcp to rate-limit new connections on that port. Next, open only the services you planned. For example, a web server needs HTTPS:
sudo ufw allow 443/tcp comment 'HTTPS'Enable the firewall and review the result:
sudo ufw enable
sudo ufw status verboseThe output should show Default: deny (incoming), allow (outgoing) and only the rules you added. Be aware that Docker publishes container ports by writing its own iptables rules, which can bypass ufw. If you run Docker, bind published ports to 127.0.0.1 where possible, and do not assume that a closed ufw port means a container port is closed.
Step 4: Enable unattended security updates
Patching is the single most effective ongoing task on a server, and it is easy to forget. The unattended-upgrades package installs security updates automatically from the distribution's security repositories.
sudo apt install -y unattended-upgrades
sudo tee /etc/apt/apt.conf.d/20auto-upgrades > /dev/null <<'EOF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
EOFCheck the origins that will be patched. Open /etc/apt/apt.conf.d/50unattended-upgrades and confirm that the security origin line for your release is not commented out. On Ubuntu this is typically the ${distro_codename}-security pocket. On Debian it is the security origin for your codename. Leave the defaults alone unless you have a specific reason to change them.
Automatic reboots are disabled by default, which is a sensible choice for a server you have not yet tuned. Kernel and libc updates take effect only after a reboot, so you will need to schedule one yourself, or enable Unattended-Upgrade::Automatic-Reboot with a quiet window once you trust the setup. Run a dry run to confirm the pipeline works:
sudo unattended-upgrade --dry-run --debug 2>&1 | tail -n 20
systemctl list-timers apt-daily-upgrade.timerStep 5: Ban brute-force attackers with fail2ban
Key-only SSH already makes password guessing useless, but fail2ban reduces log noise and wasted CPU by blocking sources that keep hammering the port. On Debian and Ubuntu systems that log to the systemd journal, the systemd backend requires the Python bindings, so install both packages.
sudo apt install -y fail2ban python3-systemdNever edit jail.conf, because package updates overwrite it. Put your changes in jail.local, which takes precedence. Replace YOUR_HOME_IP with a static address you control, or remove that entry if you do not have one. Do not add a dynamic IP you cannot rely on.
sudo tee /etc/fail2ban/jail.local > /dev/null <<'EOF'
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 4
ignoreip = 127.0.0.1/8 ::1 YOUR_HOME_IP
backend = systemd
bantime.increment = true
bantime.maxtime = 1w
[sshd]
enabled = true
port = ssh
EOFThe bantime.increment setting makes repeat offenders receive progressively longer bans, capped at one week. Enable the service and check the jail:
sudo systemctl enable --now fail2ban
sudo fail2ban-client status sshdIf you ever ban yourself, you can remove the ban from the server without waiting it out:
sudo fail2ban-client set sshd unbanip YOUR_IPVerify it worked
Do not trust the configuration files alone. Test each control from the outside, the way an attacker would.
- Effective SSH settings. Run
sudo sshd -T | grep -Ei '^(permitrootlogin|passwordauthentication|pubkeyauthentication)'. Expectpermitrootlogin no,passwordauthentication no, andpubkeyauthentication yes. - Password login is refused. From another machine, run
ssh -o PubkeyAuthentication=no -o PreferredAuthentications=password YOUR_USER@YOUR_SERVER_IP. ExpectPermission denied (publickey)with no password prompt. - Root login is refused. Try
ssh root@YOUR_SERVER_IP. Expect a permission denial. - Listening services match your plan. On the server, run
sudo ss -tulpnand confirm every listening socket is one you intended. From a different network, runnmap -Pn YOUR_SERVER_IPand confirm that only the ports you opened appear as open. - Firewall state. Run
sudo ufw status verbose. Expect the default deny policy for incoming traffic and only your explicit rules. - Updates are wired up. The dry run from Step 4 should complete without errors, and the apt-daily-upgrade timer should be scheduled.
- fail2ban is active. Run
sudo fail2ban-client status sshd. Expect the jail to show as running. After a few deliberate failed logins from a test machine, the banned count should rise. - Optional audit. Install Lynis with
sudo apt install -y lynis, then runsudo lynis audit system. Review the warnings and suggestions it reports. Treat it as a checklist that points you at things to examine, not as a score to maximize.
Common mistakes
- Closing your only session before testing a new login. Always keep one working session open while you change SSH settings.
- Editing the main sshd_config and expecting it to win. Drop-in files can override it. Use
sshd -Tto see the effective values. - Enabling ufw before allowing SSH. Add the SSH rule first. If you change the SSH port, update the rule at the same time.
- Assuming ufw covers Docker. Published container ports can bypass ufw rules. Check exposure with an external scan.
- Banning yourself after typos. Use key-only authentication, keep a console session available, and know the
unbanipcommand. - Believing unattended updates finish the job. Kernel updates need a reboot. Check
/var/run/reboot-requiredperiodically. - Leaving stale keys in authorized_keys. Review
~/.ssh/authorized_keysand remove any key you cannot account for. - Editing jail.conf directly. Package upgrades overwrite it. Keep your changes in jail.local.
Takeaway
A hardened server is not a complicated one. Key-only SSH removes password guessing as a practical attack, a default-deny firewall limits exposure to the services you chose, automatic security updates close known holes without waiting for you, and fail2ban keeps persistent scanners from wearing down the box. Write down the configuration, rerun the verification steps after every major change, and revisit the checklist whenever you add a new service.
