Linux Server Setup 2026: From Blank VPS to Hardened Production Machine

Linux Server Setup 2026: From Blank VPS to Hardened Production Machine

A Linux server setup sounds like a checklist you work through: harden SSH, enable the firewall, install fail2ban, done. That exact list appears in hundreds of guides — and it isn’t wrong. It’s just incomplete in the places where it hurts.

We run several servers: a main machine that hosts this site among other things, a trading server in Helsinki, and a shop server. For this article we didn’t paraphrase a tutorial. We measured our own machines: analysed the logs of 84,136 SSH events, recalculated fail2ban against actual attack patterns, and tested a firewall rule live from an third-party server.

Three results up front, because they change the usual order of advice:

1. Our main server logged 20,815 login attempts with invalid usernames from 1,272 distinct IP addresses in 16.5 days. The default fail2ban configuration would have missed 70.4 % of them entirely.

2. 10 % of the attacks used usernames derived from our own domain namesheynyx, exuvia, praxisfix. Those appear in public certificate logs. If you run a domain with HTTPS, you are publishing username candidates.

3. A Docker container with the default port mapping was reachable from the internet even though the firewall blocks that port. We proved it from a different server: HTTP 200 on a port ufw never opened.

Linux Server Setup: What Actually Happens in the First Minutes

There’s a common line in forums: “Who would bother attacking my little server?” The answer is unromantic: nobody personally. These are scanners walking the entire addressable internet, and they find a new IP address within minutes.

We didn’t estimate this. We counted it. Here are the numbers from our main server’s system journal, 5–22 August 2026:

MetricValue
Observation window394.9 hours (16.5 days)
Total SSH journal lines84,136
Login attempts with invalid username20,815
Attack attempts per day1,265
Distinct attacking IP addresses1,272
Distinct usernames tried3,408
Successful password logins by attackers0

That last row matters most, and it isn’t luck: password authentication is disabled on this machine. There were zero failed password attempts in 16.5 days — not because nobody tried, but because the server never offers passwords in the first place. Attackers get as far as “which user?” and are rejected there.

A server under constant automated attack from the internet, deflected by a protective shield

For comparison, we ran the same analysis on our Helsinki server. Password authentication is still enabled there, and root may log in with a password — the state many hosting providers ship by default:

Metric (normalised per day)Main server (key-only)Helsinki (passwords on)
Invalid usernames / day1,2652,271
Failed password attempts / day06,200
…of those against root / day3,603
Attacking IPs / day77127

6,200 password attempts per day, 3,603 of them aimed directly at root. That’s roughly 43 guesses per minute against one known username. As long as a guessable password sits behind it, that’s not a theoretical risk — it’s an ongoing lottery with a great many tickets.

The Finding We Didn’t Expect: Attackers Know Your Domain

Counting the attempted usernames, the usual suspects led the list: ubuntu (3,182 attempts), admin (1,679), centos, test, user. Expected.

Then, in fourth and fifth place: heynyx (947 attempts) and exuvia (783). Those are our domain names. Further down: praxisfix (256), shellgames, getmind, nyxvault, forkcart, suedpfote — all projects hosted on this machine.

Added up: 2,082 attempts = 10.0 % of all attacks used a username derived from our own domains.

How do they know? We checked the obvious source: Certificate Transparency. Every certificate issued via Let’s Encrypt lands in a public, searchable log. A query against the Certspotter API returned 86 distinct hostnames for our main domain — every subdomain we ever put behind HTTPS, including long-forgotten test instances.

That list is available to anyone. Attackers take the domain name, strip the suffix, and try it as a username. It’s cheap and it costs them nothing.

The practical consequence: the username on your server should have nothing to do with the project, company, or domain. deployer on a machine serving mycompany.com beats mycompany. This isn’t real security — usernames aren’t secrets — but it removes the hit rate from the cheapest attack pattern.

As a control, we ran the same check in Helsinki: heynyx, exuvia, and praxisfix appeared zero times. That server has no domain pointing at it and therefore receives only generic usernames. That confirms the mechanism: the names don’t come from nowhere, they come from DNS and certificate logs.

Step 1: Secure SSH — the Measure With the Largest Effect

If you take only one thing from this article, take this one. Every number above points the same way: the attack arrives over SSH, and it arrives via passwords.

Generate and Install a Key

On your own machine, not the server:

ssh-keygen -t ed25519 -C "work-laptop"

Use ed25519 rather than RSA: shorter keys, a modern curve, standard for years. Don’t skip the passphrase prompt — it protects the key if the laptop goes missing.

Then copy the public half to the server:

ssh-copy-id -i ~/.ssh/id_ed25519.pub user@server-ip

Now the step almost every guide mentions too casually: open a second terminal session and log in with the key — before you disable password authentication. If something goes wrong, you still have the working connection. Skip this and you lock yourself out and need the provider’s rescue console.

Harden the Configuration

In /etc/ssh/sshd_config, or a dedicated file under /etc/ssh/sshd_config.d/:

PermitRootLogin prohibit-password
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
MaxAuthTries 3
PermitEmptyPasswords no
X11Forwarding no

A note on PermitRootLogin. The common advice is no. Our machine runs prohibit-password (equivalent to without-password): root may log in, but only with a key. Both are defensible. no plus a regular user with sudo is the stricter option and the correct one when several people have access — then the logs show who did what. On a single-operator machine, prohibit-password is an acceptable compromise as long as password authentication is globally off.

Check what actually applies — not what the file says. Config files contain commented lines, Include directives, and blocks that override each other. The server will tell you what it’s doing:

sshd -T | grep -E "^(port|permitrootlogin|passwordauthentication|pubkeyauthentication|maxauthtries)"

On our machine:

port 22
permitrootlogin without-password
passwordauthentication no
pubkeyauthentication yes
maxauthtries 6

That’s the real state. Grepping the config file could have told a different story — MaxAuthTries appears there in more than one place.

Validate the syntax before restarting. It costs nothing and prevents a service that won’t come back up:

sshd -t && systemctl reload ssh

Comparison between weak password login and cryptographic key authentication

Is Changing the SSH Port Worth It?

Forums argue about this endlessly. One camp says “security by obscurity, pointless”; the other swears by it. We could measure it, because one of our servers runs on an unusual port.

ServerSSH portObservation windowInvalid usernames / day
Main server2216.5 days1,265
Helsinki229.7 hours2,271
Shop server5220030.8 days6.0

A factor of 211. The server on the high port saw fewer attempts in a month than the main server saw in a single morning.

But the interpretation matters, and here we disagree with the obvious conclusion: this is not a security measure, it’s noise reduction. A targeted attacker scans all 65,535 ports and finds SSH in seconds. What the port change actually buys you is readable logs. When 1,265 failures scroll past daily, you won’t spot the one real incident. At six per day, you will.

And it has a cost: every script, cron job, and colleague needs the port. We changed it on exactly one server and deliberately left the others alone.

Step 2: The Firewall — and Why It Protects Less Than You Think

The standard advice is: deny everything, open only what’s needed. That’s correct. On our main server:

ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp comment 'SSH'
ufw allow 80/tcp comment 'HTTP'
ufw allow 443/tcp comment 'HTTPS'
ufw enable

Before running ufw enable, make absolutely sure the SSH port is allowed. Otherwise the command severs your own connection, immediately.

Rules can be restricted to a source IP, which is sensible for admin access:

ufw allow from 203.0.113.5 to any port 22 proto tcp

You can display the state — but the display is not the proof:

ufw status verbose

Layered defensive rings around a server with a single controlled open path

The Test That Dismantles the Firewall Illusion

This is where it gets uncomfortable, and it’s the reason we wrote this section at all.

Docker bypasses ufw. This is known, but usually mentioned as a footnote. We wanted to know whether it really happens on our specific machine — with the firewall active and a default policy of “deny incoming”.

The setup: a throwaway container on port 39999, bound to all interfaces. That port is allowed nowhere in ufw; the default deny rule applies.

docker run -d --rm --name ufwtest -p 0.0.0.0:39999:80 nginx:alpine

The crucial detail: we did not test from the server itself. A curl to your own public IP travels over the loopback interface and never traverses the firewall chain — the test would always have reported “reachable” and proven nothing. Instead we logged into our Helsinki server, a different machine at a different provider in a different country, and queried from there:

--- third-party test, Docker port 39999 (ufw: default deny) ---
HTTP:200
--- control: port 631, ufw DENY ---
(timeout — correctly blocked)
--- control: port 443, ufw ALLOW ---
Connection to 46.225.123.163 443 port [tcp/https] succeeded!

HTTP 200. The container was reachable from the internet despite the firewall blocking that port. The two control measurements show the firewall works in general: port 631 blocked, port 443 open. It simply has no reach over Docker.

The reason is ordering: Docker writes its rules directly into the iptables FORWARD chain, ahead of the rules ufw manages. On our machine the DOCKER-USER chain is empty and FORWARD jumps straight into it — ufw is never consulted for forwarded container traffic.

The test container was stopped immediately afterwards and the port verified closed; the six production containers were left untouched.

The fix is unspectacular, and it’s been in our internal rules since Germany’s federal security office contacted us about an exposed PostgreSQL instance: always bind container ports explicitly to loopback.

# wrong — lands on 0.0.0.0 and is reachable from the internet
ports:
  - '5432:5432'

# right
ports:
  - '127.0.0.1:5432:5432'

On our main server that’s now true of every container. Measured at the time of writing: 43 services listen on loopback only, 29 are publicly reachable — and those 29 are web servers, mail ports, and SSH, all intentional. Not a single database port among them.

If you need a service reachable third-partyly, put a reverse proxy in front of it.

Step 3: fail2ban — Useful, but Less Useful Than You’d Hope

fail2ban reads logs, detects repeated failures, and bans the IP address. Installation and basic setup:

apt install fail2ban

Settings belong in /etc/fail2ban/jail.local, not jail.conf — the latter is overwritten on updates:

[DEFAULT]
bantime  = 1h
findtime = 10m
maxretry = 5
banaction = ufw

[sshd]
enabled = true
backend = systemd

That’s the configuration recommended everywhere, and it’s also ours. Then:

systemctl enable --now fail2ban
fail2ban-client status sshd

On our server it looked like this:

Status for the jail: sshd
|- Filter
|  |- Currently failed:  29
|  |- Total failed:      15241
|  `- Journal matches:   _SYSTEMD_UNIT=sshd.service
`- Actions
   |- Currently banned:  3
   |- Total banned:      198

198 bans. That looks like work being done. But in the same period, 1,272 distinct IP addresses attacked us. We wanted to understand the gap.

Doing the Maths

First, the obvious question: does the filter even recognise these lines? That’s checkable rather than guessable:

fail2ban-regex /tmp/f2btest.log /etc/fail2ban/filter.d/sshd.conf

Result against 2,000 real log lines: 1,153 matched, 847 ignored, 0 missed. So the filter works. The ignored lines are Connection closed messages, deliberately not counted as failures.

Then we calculated the real question: how many attacking IPs ever reach the trigger threshold of 5 attempts within 10 minutes? We took all 20,678 events with timestamp and source IP and evaluated a sliding 10-minute window per address.

ResultValue
Attacking IPs with failed attempts1,026
Of those, trigger the default rule228 (22.2 %)
Of those, do not trigger798 (77.8 %)
…despite having ≥ 5 attempts in total488
Attack attempts from never-banned IPs14,550 (70.4 %)

70.4 % of the attack traffic stays below the radar of the default configuration.

As a sanity check: our calculation predicts 171 triggering IPs since the service started, and fail2ban itself reports 198 bans. The order of magnitude matches; the difference is explained by repeat bans of the same address.

Why That Happens — and What It Reveals About Attackers

We looked at the timing of IPs that never trigger despite making five or more attempts. Across 13,399 measured intervals:

  • Median: 904 seconds — a good 15 minutes between attempts
  • Lower quartile: 256 seconds
  • Upper quartile: 4,344 seconds (over an hour)

That’s not accidental. A bot that hammers blindly gets banned. A bot making one attempt every 15 minutes runs forever — and because there are thousands of bots, the aggregate is still roughly one attempt per minute. Attackers adapted to the defence, specifically to the default value printed in every guide.

You can tune it — findtime = 1d with maxretry = 3 catches considerably more:

[sshd]
enabled  = true
backend  = systemd
maxretry = 3
findtime = 1d
bantime  = 1w

But that also raises the risk of locking yourself out after fumbling a key. So an exemption becomes mandatory:

ignoreip = 127.0.0.1/8 ::1 YOUR.STATIC.IP.ADDRESS

Our honest assessment: fail2ban is worthwhile, but it’s the third line of defence, not the first. It keeps logs cleaner and slows the loudest attackers. What actually prevents entry is disabled password authentication. The number that counts is further up: 0 failed password attempts in 16.5 days, because there was nothing to guess.

Treating fail2ban as your primary protection means relying on a tool that demonstrably doesn’t see 70 % of the traffic.

Step 4: Automatic Updates — and the Trap Behind Them

Most successful break-ins don’t exploit a new vulnerability but an old one with a patch already available. Automatic security updates are therefore mandatory:

apt install unattended-upgrades
dpkg-reconfigure -plow unattended-upgrades

The configuration in /etc/apt/apt.conf.d/20auto-upgrades should read:

APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";

This has run reliably on our server for months. The log shows genuine activity:

2026-08-20 06:17:03 INFO Packages that will be upgraded: linux-image-virtual linux-libc-dev
2026-08-21 06:30:47 INFO Packages that will be upgraded: bind9-dnsutils curl libcurl4t64 nginx nginx-common
2026-08-22 06:41:17 INFO Packages that will be upgraded: libpq5 postgresql-16 vim wget xxd

Looks fine. And this is exactly where, while writing this article, we found a fault on our own server that we hadn’t expected.

The Finding: Updates Installed, but Not Effective

Comparing the running kernel with the installed one:

uname -r
# 6.8.0-124-generic

dpkg -l 'linux-image-*generic' | awk '/^ii/{print $2}' | sort -V | tail -1
# linux-image-6.8.0-138-generic

The server is running kernel 6.8.0-124 while 6.8.0-138 is installed. Fourteen ABI versions apart. The file /var/run/reboot-required has existed since 20 August.

The system did everything right: downloaded, installed, flagged. But a new kernel only takes effect after a reboot — and this machine has been up since 30 June 2026, more than seven weeks without one.

For independent confirmation, because a self-built check can itself be wrong:

needrestart -b
# NEEDRESTART-KCUR: 6.8.0-124-generic
# NEEDRESTART-KEXP: 6.8.0-138-generic
# NEEDRESTART-KSTA: 3

KSTA: 3 in that tool’s vocabulary means: an obsolete kernel is running. Two independent sources, same verdict. On top of that, 23 running processes hold libraries in memory that have long been replaced on disk — they keep running the old, potentially vulnerable version until each service restarts. needrestart names dbus.service, docker.service, systemd-logind.service and others specifically.

This is the most uncomfortable insight in this article, and it’s about us: “automatic updates are enabled” and “this system runs current security patches” are two different statements. We treated the first as the second for months. A glance at systemctl is-active unattended-upgrades would have kept cheerfully reporting active.

Automatic updates install packages that only take effect after a reboot

The remedy is simple once set up. Either allow automatic reboots in /etc/apt/apt.conf.d/50unattended-upgrades:

Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "03:00";

Or — if an unattended reboot is too risky because services don’t come back cleanly — at least get notified. A check script running daily that reports when /var/run/reboot-required exists takes three lines:

#!/bin/bash
[ -f /var/run/reboot-required ] || exit 0
echo "Reboot pending since $(stat -c %y /var/run/reboot-required | cut -d' ' -f1)"
echo "Running kernel: $(uname -r)"
exit 1

The last part is what matters: an alert needs a reader. A script writing to a log file nobody opens is a comment. It has to land in a channel you actually look at — mail, messenger, monitoring.

Step 5: The Rest of the Baseline

The following points are less dramatic, but they belong to the setup.

A User Instead of Root

adduser deployer
usermod -aG sudo deployer

As described above: the name shouldn’t reveal anything about the project.

Time Zone, Time Sync, and Locale

Sounds cosmetic, but it’s the foundation of every investigation. If logs on different machines show different times, you cannot reconstruct an incident.

timedatectl set-timezone Europe/Berlin
timedatectl status

Our recommendation from experience: leave servers on UTC when machines sit in different countries. We run servers in Germany and Finland; uniform UTC in the logs has saved time more than once. Local time zones belong in the display layer, not in stored data.

Web Server and TLS

For most applications, nginx or Caddy is the choice. The practical difference: Caddy obtains and renews certificates by itself, nginx needs Certbot for that. We run both and compared them in Apache vs nginx; for nginx specifically we wrote a detailed reverse proxy guide.

A minimal Caddy example — that’s all it takes for a TLS-secured site:

your-domain.com {
    reverse_proxy 127.0.0.1:3000
}

Keep an Eye on the Logs

Having logs isn’t enough — somebody has to read them. A few commands we use regularly:

# Who logged in successfully?
journalctl -u ssh | grep "Accepted"

# Which method did they use?
journalctl -u ssh | grep -oE "Accepted [a-z-]+ for [^ ]+" | sort | uniq -c

On our server:

     81 Accepted publickey for root
      1 Accepted password for tunnelfabi

Exactly one password login — a deliberately configured tunnel account. Everything else runs on keys. Summaries like this are the fastest way to notice that something changed.

Backups

A server without backups isn’t a server, it’s a risk with uptime. The critical point isn’t the software but the verification: a backup never restored is a hypothesis. We measured this during our Nextcloud test — maintenance mode on, database dump, maintenance mode off, 1.1 seconds of downtime in total. The effort is almost always smaller than feared; what’s missing is the habit.

What Happens If You Skip All This

We’re not writing from theory. One of our servers ran somebody else’s crypto miner for five days, having been entered through a known vulnerability in an application dependency — not through SSH. Five days during which nobody noticed.

We documented the incident in detail, and wrote up in IT security vulnerabilities how dependencies became the largest entry point. Two lessons that shape this article:

First: hardening SSH protects the front door. The break-in came through the application window. A well-configured SSH service is necessary, but it says nothing about the applications running on the machine.

Second: it took five days because nobody looked. Every tool was installed. What was missing was attention — or rather, an alert arriving somewhere a human reads.

Completed checklist for hardening a Linux server

The Checklist

Summarised in the order we’d actually do it — sorted by effect, not by effort:

#StepEffect
1Set up SSH keys, keep a second session openFoundation
2PasswordAuthentication noHighest — removes the most common attack
3Create a user unrelated to the project; root key-only or offHigh
4Verify with sshd -T, not the config fileVerification
5Firewall: deny all, open 22/80/443 onlyHigh
6Bind Docker ports to 127.0.0.1High — the firewall doesn’t reach here
7Enable unattended-upgradesHigh
8Set up reboot notificationsHigh — otherwise kernel updates never apply
9Install fail2ban, tune the valuesMedium
10Time zone/NTP, uniform across serversMedium
11Web server + TLSOperations
12Backups and a restore testExistential
13Change the SSH portLow — noise reduction

The ordering deliberately differs from the usual guides. Points 6 and 8 don’t appear in most lists at all — and both were the places where something was wrong on our own servers.

Which Distribution for a Server?

Briefly, since we’ve covered this at length in Debian vs Ubuntu and Fedora vs Ubuntu: for servers we default to Debian stable, and use Ubuntu LTS where software needs newer packages. Debian 13 is supported until August 2028, with extended support to mid-2030; Ubuntu 26.04 LTS until April 2031, with paid extension to 2036.

The difference that matters in practice isn’t the length of support but its breadth: Ubuntu’s free five-year commitment covers only the main and restricted components — roughly 10 % of the catalogue. Many typical server packages live in universe. We counted this ourselves for our distribution comparison.

What matters more than the distribution: that it still receives security updates at all. A server on an expired release gets no patches, however well SSH is configured.

Conclusion: Two Numbers That Say It All

After 16.5 days of measurement and a series of uncomfortable findings on our own machines, two numbers stick:

1,265 attack attempts per day, 0 successful. The difference between those two numbers is essentially one configuration line: PasswordAuthentication no. Not fail2ban, not the port change, not an elaborate firewall. One line.

70.4 % of attack traffic is invisible to fail2ban in its default configuration. Tools you install and then consider handled produce a sense of security that no measurement supports. We ran the numbers because we wanted to know — and the result changed the order of our recommendations.

And the finding we expected least: our own server ran for seven weeks on a kernel fourteen versions out of date, while updates were dutifully installed every night. “Automatic” does not mean “effective”.

If you’re doing a Linux server setup: start at point 2 of the list. The rest matters, but the rest is supplementary.

What We Did Not Measure

For honesty’s sake, because an estimated number looks identical to a measured one in a table:

  • We did not simulate a real intrusion. All attack figures come from genuine, unsolicited traffic against our servers — but we ran no penetration test.
  • The fail2ban analysis is a recalculation, not a live experiment with banning disabled. We evaluated the measured timestamps against the rule and sanity-checked the result against fail2ban’s own ban count — the magnitude matches, but they aren’t exactly congruent.
  • The Helsinki window is short at 9.7 hours. Its daily figures are extrapolated and therefore more volatile than the main server’s 16.5-day numbers.
  • The port-change comparison is not a clean experiment. The three servers differ not only in port but in age, provider, and how well-known their IP addresses are. The factor of 211 shows a clear tendency, but the cause isn’t isolated.
  • No findings on SELinux/AppArmor, intrusion detection, or two-factor authentication for SSH. All sensible, none part of this measurement.

Frequently Asked Questions

How do I set up a Linux server securely?

In this order: set up SSH keys and test them with a second session still open, then disable password authentication (PasswordAuthentication no), create a user unrelated to your project, set the firewall to deny everything and open only SSH, HTTP and HTTPS, bind Docker ports to 127.0.0.1, enable automatic security updates, and configure a notification for pending reboots. By far the most effective single measure is disabling password authentication: on our server, 1,265 attack attempts per day produced zero successful logins.

How many attacks does an ordinary server receive?

We counted this on our own machines: 1,265 login attempts with invalid usernames per day from an average of 77 distinct IP addresses, across a 16.5-day window. On a server with password authentication still enabled, a further 6,200 password attempts per day arrived, 3,603 of them aimed directly at the root account. These aren’t targeted attacks but automated scanners working through the entire address space of the internet.

Is fail2ban enough to protect SSH?

No. We recalculated 20,678 attack events against the default rule (5 attempts in 10 minutes): only 22.2 % of attacking IP addresses trigger it at all, and 70.4 % of total attack traffic goes unnoticed. The reason is that many bots deliberately go slow — the median interval between attempts among the unnoticed addresses was 15 minutes. fail2ban is worthwhile for keeping logs clean and slowing the loudest attackers, but it is the third line of defence, not the first.

Should I change the SSH port?

It reduces noise dramatically but is not real security. Our server on port 52200 recorded 6 attack attempts per day, while our servers on port 22 saw between 1,265 and 2,271 — a factor of roughly 211. A targeted attacker still finds the service in seconds, because a port scan covers all 65,535 ports. The genuine benefit is log readability: among a thousand daily failures the one real incident disappears, among six it doesn’t. The cost is every script and account that must know the non-standard port.

Why are usernames matching my domain name being attacked?

Because they’re public. Every TLS certificate is recorded in a public Certificate Transparency log that anyone can query. For our main domain we found 86 distinct hostnames there, including forgotten test instances. Attackers strip the suffix from those names and try them as usernames. In our logs, 10 % of all attacks used usernames derived directly from our domains. On a server with no domain pointing at it, those same names appeared zero times. So: choose usernames unrelated to the project.

Does my firewall protect my Docker containers?

No, not in the default configuration. We proved this live: a container mapped with -p 0.0.0.0:39999:80 was reachable from an third-party server with HTTP 200, even though ufw was set to deny everything and that port was never opened. Two control measurements in the same test confirmed the firewall otherwise works. The reason: Docker inserts its rules ahead of the ufw-managed ones in the FORWARD chain. The fix is to bind container ports explicitly to loopback: 127.0.0.1:5432:5432 instead of 5432:5432.

Are automatic updates enough to keep a server current?

Not on their own. On our own server, automatic updates ran reliably for months — yet it was running kernel 6.8.0-124 while 6.8.0-138 was installed. The reason: a new kernel only takes effect after a reboot, and the machine had been up for over seven weeks. On top of that, 23 processes still held replaced libraries in memory. You can check this with needrestart -b or the /var/run/reboot-required file. Either enable automatic reboots or set up a notification that arrives somewhere actually read.

Should I disable root login completely?

PermitRootLogin no combined with a regular user and sudo is the stricter choice, and the correct one when several people have access, because the logs then show who did what. prohibit-password permits root login by key only and is a defensible compromise on a single-operator machine — provided PasswordAuthentication is globally disabled. What matters isn’t the choice between them but that no passwords can be guessed against root: on our server with password authentication enabled, 3,603 guesses per day targeted that one account.

Which Linux distribution is best for a server?

We use Debian stable as our default and Ubuntu LTS where newer packages are required. Debian 13 has support until August 2028 (extended to mid-2030), Ubuntu 26.04 LTS until April 2031 (extendable commercially to 2036). The more important practical difference isn’t duration but scope: Ubuntu’s free support commitment covers only main and restricted, while many typical server packages sit in universe. For security, what counts most is that your release still receives updates at all.

How would I notice that my server has been compromised?

Usually you wouldn’t — that’s the actual problem. One of our servers ran someone else’s crypto miner for five days, entered through a known vulnerability in an application dependency rather than through SSH. Every security tool was installed; what was missing was an alert somebody reads. Practical starting points: review successful logins regularly (journalctl -u ssh | grep Accepted), watch for unusual CPU load, check for unexpected open ports with ss -tlnp, and keep an eye on outbound connections. An alert that only writes to a log file doesn’t help.