What actually attacks a new server — and what actually takes one away
Point a fresh IPv4 address at the internet and unsolicited traffic arrives almost immediately. Not because anyone noticed you: the whole address space is swept continuously, and a host that answers on port 22 simply joins a queue that was already running. Within the first day a typical new server logs somewhere between a few hundred and a few tens of thousands of SSH authentication attempts, nearly all of them against root, admin, ubuntu, test and a dozen other predictable names, with passwords from a list that has barely changed in a decade. It reads like an attack. It is closer to weather.
The distinction matters, because it decides which controls deserve your fifteen minutes. Password guessing at that volume is not reduced by turning password authentication off — it is eliminated. There is no residual risk to manage, no tuning, no rate to monitor. Meanwhile the two things that genuinely take small servers away from their owners are unglamorous: an internet-facing application running a version with a published, months-old vulnerability, and a credential that leaked somewhere else and still worked here. Neither is exotic, and neither has anything to do with which port SSH listens on.
There is a third failure mode, and on a host that never asked who you are it is the most common of all. You lock yourself out. On a mainstream provider that is an annoying support ticket: you answer some questions, prove you are the person on the billing record, and someone resets access for you. Here that path does not exist — not as a policy choice made grudgingly, but because the identity it would check was never collected. What exists instead is access tied to the account rather than to a person: an emergency KVM-over-VNC console in the panel, hourly snapshots with seven-day retention, and the ability to boot something else entirely. Those are enough. They are only enough if you have used them once before you need them.
So the checklist below is ordered by consequence rather than by fashion. The controls that eliminate a whole class of attack come first, the ones that reduce noise come last, and the step that costs nothing and saves the evening — proving you can still get in — comes before all of them.
| What people worry about | How it actually arrives | What stops it | What does not |
|---|---|---|---|
| SSH password guessing | Automated sweeps, thousands per day, generic username lists | PasswordAuthentication no — completely | A non-standard port; a longer password |
| Known-vulnerable service | A scanner matches your version banner weeks after the patch shipped | Automatic security updates plus a reboot policy | A firewall, if the port has to be open anyway |
| Reused or leaked credential | Valid login on the first try, from an address you have never seen | Key-only auth; unique credentials per service | Rate limiting — one attempt is not a burst |
| Exposed internal service | Database, cache or metrics endpoint bound to the wildcard address | Binding to loopback; default-deny inbound on both families | Assuming it is private because you never linked to it |
| Losing your own access | You harden, log out, and the change you made was wrong | A second open session, a snapshot, a tested console | Confidence |
Build the way out before you close the door
Here is the sequence that strands people, and every step in it is correct in isolation: edit the SSH config, move the port, disable password login, enable a firewall, reload the daemon, close the terminal. The mistake is not in any individual change. It is that the session you are typing in was the only evidence that any of them worked, and you threw it away before checking.
The rule that makes the whole checklist safe is one sentence: keep the working session open and prove the change from a second one. Open a new terminal, connect fresh, run id, escalate with sudo. If that succeeds, the change is real and the first session becomes disposable. If it fails, you still have a shell with which to undo it. This applies to the firewall, to sshd, to a new user's key, and to anything else that can refuse you at the door.
Before any of that, establish the out-of-band route. In the BitVPS panel each server row carries a Console button that opens a KVM-over-VNC session against the virtual machine's display. It is not a shell over the network — it is the screen and keyboard of the machine — so it keeps working when sshd will not start, when the firewall drops every inbound packet, and when the network configuration is wrong. Open it once, right now, on a server you have not broken. Confirm you get a login prompt. That takes thirty seconds and it is the difference between a mistake and an outage.
Take a snapshot too. They are hourly with seven-day retention on every plan and restore in about thirty seconds, which reduces "I pasted the wrong nft rule" from an evening of recovery to a rollback. And for changes you are genuinely unsure about, a dead-man switch costs one line: systemd-run --on-active=10min /usr/sbin/ufw --force disable arms a timer that undoes the firewall in ten minutes unless you cancel it. If the new rules work, you cancel. If they lock you out, the machine lets you back in without you having to be there.
SSH: the four directives that do the work — and the file that overrides them
Generate the key on your own machine, never on the server: ssh-keygen -t ed25519 -C "laptop-2026". Ed25519 is the right default in 2026 — short keys, fast verification, no parameter choices to get wrong, and supported by every OpenSSH built in the last decade. RSA at 4096 bits is not insecure, it is simply larger and slower for no gain. Set a passphrase on the private key; ssh-agent means you type it once per session, and it is the only thing standing between a stolen laptop and every server you own.
Copy the public half up with ssh-copy-id and confirm you can log in with it before you disable anything. Permissions matter and OpenSSH is strict about them by design: ~/.ssh at 700, authorized_keys at 600, both owned by the user. A key that silently fails to work is nearly always a permissions problem, and sshd -e -d in the foreground will tell you so in one line.
Then four directives in /etc/ssh/sshd_config do essentially all of the work. Everything else in that file is preference.
| Directive | Set it to | What it does | What breaks if you get it wrong |
|---|---|---|---|
PasswordAuthentication | no | Removes passwords as a login method entirely — the whole brute-force flood becomes impossible rather than merely slow | You are locked out if your key was never actually installed. Test first, in a second session |
KbdInteractiveAuthentication | no | Closes the second door to the same room — PAM's keyboard-interactive path can still accept a password when the first directive is set | Breaks legitimate two-factor setups that rely on PAM. Leave it on only if you use one |
PermitRootLogin | prohibit-password | Root may still log in with a key (useful for automation) but never with a password | no is stricter and fine — provided your sudo user works. Verify that first |
PubkeyAuthentication | yes | The method you are keeping. Explicit is better than default here | Nothing, but it is worth stating so a future edit cannot silently drop it |
AllowUsers / AllowGroups | your user or group | Optional, and the highest-value optional line — the daemon refuses every other account before authentication | A typo denies everyone. Set it last, and from a second session |
Now the part that catches experienced people. On Debian 12, Ubuntu 22.04 and everything newer, /etc/ssh/sshd_config begins with Include /etc/ssh/sshd_config.d/*.conf, and OpenSSH uses the first value it obtains for each keyword. Cloud images ship drop-in files in that directory — commonly one that sets PasswordAuthentication yes — and because the include sits at the top, that drop-in beats the line you carefully edited two hundred lines further down. The file says one thing and the daemon does another.
The fix is to never trust the file. Ask the daemon what it resolved: sshd -T | grep -Ei 'passwordauth|permitrootlogin|kbdinteractive|pubkeyauth' prints the effective configuration after every include, match block and default has been applied. That single command is worth more than the rest of this section. Pair it with sshd -t, which validates syntax and exits non-zero on a mistake, and only then systemctl reload ssh — a reload does not disturb existing sessions, so even a bad reload leaves your current shell alive.
As for moving SSH off port 22: it removes perhaps 95% of your log volume and zero percent of your risk. Every scanner worth worrying about sweeps all 65,535 ports, and the ones that do not were never going to get past key-only authentication anyway. Move it if the noise bothers you or if it keeps a monitoring alert quiet, but do not record it as a security control — and if you do move it, keep it below 1024, because only privileged processes may bind those, so nothing unprivileged can squat on the port if the daemon ever stops.
The firewall: default-deny inbound, and the half everyone forgets
A firewall on a single-purpose server has one job: make the set of reachable ports equal to the set of ports you can name a reason for. Default-deny inbound, allow outbound, allow established and related connections back in, then open the two or three things you actually run. If you are on Debian or Ubuntu, ufw is four commands and correct; if you would rather own one readable rule file, nftables is the modern in-kernel answer and the thing ufw is writing to underneath.
Order matters in exactly one place: allow your SSH port before you enable the policy. ufw allow 22/tcp then ufw default deny incoming then ufw enable. Reversed, the enable step drops the session you are typing in — which is survivable if you did the previous section and still have a console, and is otherwise the classic way to end an evening early.
The forgotten half is IPv6. Every server here ships with a routed /64, and services bind to both families by default. A rule set written with iptables alone governs IPv4 and says nothing whatsoever about IPv6, so the database you carefully firewalled remains reachable from the entire internet over the other protocol. ufw handles both when IPV6=yes is set in /etc/default/ufw (the default on current releases — check rather than assume), and nftables sidesteps the problem structurally: a table inet rule set covers both families with one set of rules. If you write one line of firewall configuration by hand, make it that one.
Then there is Docker, which does not so much bypass your firewall as arrive underneath it. Publishing a port with -p 5432:5432 inserts DNAT rules into the nat table, and those are evaluated before the filter chains ufw manages. The result is a container reachable from the internet while ufw status shows the port as closed, and every year this surprises people running a database in a container behind what they believed was a deny-all policy. Publish to loopback instead — -p 127.0.0.1:5432:5432 — or put the container on an internal network and reach it from another container by name.
| What you believe | What is actually true | How to check in one command |
|---|---|---|
| "The firewall denies everything I did not allow" | True for IPv4 only, if you wrote IPv4-only rules | ip6tables -L -n or nft list ruleset |
| "The database is not exposed, it is in a container" | A published port is reachable regardless of the filter policy | docker ps --format '{{.Ports}}' |
| "Nothing is listening on that port" | Something restarted and re-bound it after you last looked | ss -tulpn |
| "The port is closed, my scan says so" | A scan from the machine itself tests loopback, not the internet | Scan from a second host, or from your laptop |
What is actually listening — the command that ends the guessing
One command answers the single most useful question about a server's exposure: ss -tulpn. It lists every listening TCP and UDP socket with the process that owns it, and the column that matters is the local address. 127.0.0.1:5432 means only this machine can connect. 0.0.0.0:5432 means any address that reaches the machine can, subject only to the firewall. [::]:5432 means the same thing over IPv6, and is the line people skim past.
The recurring offenders are always the same handful. Redis, which for most of its history bound broadly and shipped without authentication. PostgreSQL and MySQL, when a configuration file was edited to accept connections "from the app" and the app moved. MongoDB and Elasticsearch, which have between them produced a decade of data-exposure headlines for exactly this reason. Memcached, whose UDP listener became a favourite bandwidth amplifier. Prometheus node_exporter, which will happily narrate your host's internals to anyone who asks. The Docker API on port 2375, which is remote code execution wearing a REST interface. And a development server somebody started "just for a minute" in a tmux pane eleven months ago.
The rule is simple and it does not require judgement: if a service does not need to be reachable from the internet, bind it to loopback. Everything you personally need to reach it for still works — ssh -L 5432:127.0.0.1:5432 user@server gives you a local port that tunnels over the connection you already trust, and if several machines need access, a private WireGuard interface gives them an address range that never touches the public internet. Neither costs anything, and both remove the service from the attack surface entirely rather than defending it.
Verify from outside, not inside. A port your own machine reports as filtered is a claim about your loopback stack; a port a second machine cannot reach is evidence. Connect from your laptop, or from another server, and check the ports you expect to be open and one you expect to be closed — a test that can only pass is not a test.
| Service | Typical default | What an exposed instance hands over | The fix |
|---|---|---|---|
| Redis | Broad bind, historically no password | Full read/write, and a well-known path to writing files as the redis user | Bind loopback; set requirepass; keep protected-mode on |
| PostgreSQL / MySQL | Loopback, until someone edits it | Your entire dataset, if a credential is weak or reused | Bind loopback; reach it over SSH or WireGuard |
| Docker API | Off — unless a tutorial turned it on | Root-equivalent code execution on the host | Never expose it; use SSH context instead |
| node_exporter / metrics | All interfaces, port 9100 | Hostnames, mounts, versions, running processes | Bind loopback; scrape over the private interface |
| Mail submission, web admin panels | All interfaces by necessity | A password-guessing surface that keys cannot protect | This is where rate limiting genuinely belongs |
Fail2ban and CrowdSec: what they are worth once passwords are gone
This is the item most checklists put near the top and most operators overrate, so it is worth being precise. With PasswordAuthentication no, a password guess cannot succeed — not rarely, not with difficulty, but at all. Banning an address after five failures does not make an impossible authentication more impossible. What fail2ban buys you at the SSH layer is a quieter auth.log, marginally less CPU spent on connection setup, and monitoring graphs that are easier to read. Those are real benefits. They are operational hygiene, not a security control, and filing them under the wrong heading is how people end up with a hardened log file and an unpatched application.
Where this class of tool genuinely earns its place is every layer where a password still exists: a web application login form, a mail submission port, a Nextcloud or a WordPress admin, an IMAP server. Those cannot be switched to public-key authentication, so guessing remains possible and rate limiting is the actual defence. CrowdSec adds a shared reputation feed on top, which is meaningfully useful at the HTTP layer — you benefit from addresses other people have already seen misbehaving — and close to irrelevant at the SSH layer once keys are mandatory.
Two foot-guns are worth naming. The first is banning yourself, usually while testing something at three in the morning; set ignoreip to include your own ranges, and remember that a shared or carrier-grade-NAT address means banning "an attacker" can also ban a few hundred uninvolved people. The second is that the tool has to be able to read the logs it is filtering: on distributions that log only to the systemd journal, the default file backend silently watches a file that no longer receives anything, and the jail sits there looking healthy and doing nothing.
The honest recommendation: install it if the noise bothers you, configure it for your application layer where it actually matters, and do not count it twice. If you want the same quiet with nothing to maintain, MaxStartups and LoginGraceTime in the SSH daemon plus key-only authentication get most of the way there and cannot lock you out of your own machine.
Patching: the boring control that prevents most real compromises
Read any incident write-up involving a small internet-facing server and the pattern repeats: the vulnerability had a patch, the patch had been available for weeks or months, and nobody applied it. CISA's Known Exploited Vulnerabilities catalogue is the useful reading here, precisely because it is not a list of theoretical severity — it is a list of things being exploited in the wild right now, and it is dominated by issues with published fixes. Automatic security updates are consequently the highest-yield fifteen seconds on this entire page.
On Debian and Ubuntu, install unattended-upgrades, enable it with dpkg-reconfigure -plow unattended-upgrades, and confirm the security origin is the one selected in /etc/apt/apt.conf.d/50unattended-upgrades. Verify it does something with unattended-upgrades --dry-run --debug, which prints exactly which packages it would take and why — the Debian wiki page is the reference. On Rocky and Alma, dnf-automatic with apply_updates = yes and the timer enabled does the same job. Restricting to the security repository is deliberate: it is the configuration that almost never breaks anything, which is the only kind of automation people leave switched on.
Then decide about reboots, explicitly, because this is where the control usually leaks. Installing a new kernel package does not replace the kernel you are running, and replacing a shared library does not restart the daemons that already mapped the old copy into memory. needrestart on Debian and Ubuntu lists precisely which services are still holding deleted libraries; a kernel update needs a reboot, full stop, and live-patching is a paid feature that is not coming to an $8.50 instance. Either set Unattended-Upgrade::Automatic-Reboot with a quiet Automatic-Reboot-Time, or put a monthly reboot in your calendar and treat it as maintenance rather than an incident.
Finally, know what automatic updates do not cover, because the gap is where people get caught. Anything installed outside the package manager is invisible to it: a binary pulled from a GitHub release, a language-runtime dependency from pip, npm or cargo, a theme or plugin inside a web application, and above all container images. A host that reports zero pending updates can be running a base image built two years ago, and nothing on the machine will tell you. If you run containers, rebuilding and re-pulling on a schedule is part of patching, not a separate activity.
| System | Mechanism | What it covers | What it silently misses |
|---|---|---|---|
| Debian / Ubuntu | unattended-upgrades, security origin only | Distribution packages, applied nightly | Running kernel and mapped libraries until a reboot |
| Rocky / Alma | dnf-automatic with apply_updates = yes | Same, via a systemd timer | The same reboot gap; check dnf needs-restarting |
| Containers | Nothing, by default | Nothing | Everything — the image is frozen at build time |
| Language dependencies | Nothing, by default | Nothing | Your application's actual attack surface |
Users, sudo, and the doors that quietly re-open
Create an ordinary user, add it to sudo or wheel, install your key there, and stop logging in as root. The reason is not that root-with-a-key is dangerous in itself — it is a single-factor account with a universally known name and no record of which human used it. A named account plus sudo produces an audit trail, allows more than one operator without sharing a credential, and makes the eventual "who ran that" question answerable. Do not, however, hand it NOPASSWD for everything and then reuse the account for a web service.
Services should not run as root either, and systemd makes the cheap version of that almost free. A drop-in created with systemctl edit <unit> containing NoNewPrivileges=yes, ProtectSystem=strict, ProtectHome=yes and PrivateTmp=yes confines a compromised daemon to a read-only filesystem view with no path to escalate — four lines, described in the systemd.exec documentation, that turn many application vulnerabilities from "root on the host" into "an error in a log". systemd-analyze security scores every unit on the machine and tells you which ones are worth the five minutes.
Keep authorized_keys honest. One key per device, each with a comment naming the device, and delete anything you cannot identify. Re-read the file after any provisioning tool has run, because cloud-init, imaging pipelines and control panels all append their own entries, and a key you did not add is a key you cannot revoke. The same applies to accounts: a default user left behind by an image, with a password set by a script and never used since, is a login you are not watching.
The subtlest item on this list is agent forwarding. ForwardAgent yes lets a process on the remote host use your local private key for as long as you are connected — that is precisely what it is designed to do, and it is why root on a machine you do not fully control can borrow your key to reach every other server that trusts it. Use ProxyJump (ssh -J bastion target) instead: it gives you the same reachability by tunnelling the connection, and your key never becomes usable on the intermediate host. If you must forward an agent, do it per-host in ~/.ssh/config, never globally.
Backups: the control that turns a bad day into an afternoon
Everything above lowers the probability of a bad day. Backups decide what the bad day costs, and on a provider that deliberately cannot identify you they matter more than usual: there is no support path that reconstructs your machine from someone else's copy, no account-recovery flow that quietly hands you a restore, and no billing record with a phone number attached to it. What you have is what you took.
The shape that works is unexciting. restic or borg, encrypting on the server so the data is already unreadable before it crosses the network, pushed to a second machine — and ideally a second jurisdiction, which is a checkbox rather than a project when the same panel offers Iceland, the Netherlands, Romania and Switzerland. The hourly snapshots included with every plan are a genuinely good first line and cover the "I broke the configuration an hour ago" case perfectly. They do not cover a file you deleted five weeks ago, and they live on the same infrastructure as the thing they protect. Snapshots and backups solve different problems; run both.
Make the repository append-only. This is the detail that separates a backup from a copy: a compromised server holds the credentials to its own backup target, and anything that can delete will be told to delete. borg serve --append-only and restic's rest-server --append-only both enforce it on the receiving side, so the machine can write new snapshots and cannot remove old ones. Prune from somewhere else, on a schedule, with a credential the server has never seen.
Two last things, both of which people skip. The repository passphrase must not live only on the machine it protects — write it down, put it in a password manager somewhere else, treat it like the seed phrase it effectively is. And restore it once. A backup you have never restored is a hypothesis; the usual way to find out that an exclude rule quietly skipped the database dump is during the recovery you cannot afford to have go wrong. Spin up a Starter instance, restore into it, check the data, destroy it. That costs $8.50 and one hour, and it is the only way the rest of this page ever gets to be a rehearsal instead of a first attempt.
What hardening does not protect you against
It does not protect the disk from someone holding the disk. Every control on this page assumes the operating system is running and enforcing its own rules; none of them apply to a powered-down machine whose storage has been imaged. That is a different problem with a different answer — see full-disk encryption, remote unlock, and what a seizure actually recovers — and the honest summary is that hardening and encryption defend against disjoint threats. Doing one does not partially do the other.
It does not fix the application you run. A server can pass every check here and still be lost to an out-of-date forum, an admin endpoint with no authentication, a template engine evaluating user input, or a token committed to a public repository. Host hardening reduces the blast radius of an application compromise — which is exactly what the systemd confinement above is for — but it does not audit your code, and the most common way a small server is taken is still something the operator installed.
It does not change who knows you rented the machine. A perfectly hardened box paid for from an identity-verified exchange account, ordered with a personal email, is a perfectly hardened box with your name on it. The security layer and the identity layer are independent, and the second one is decided at checkout rather than at the shell — how traceable crypto hosting really is covers where the links actually form.
And it does nothing about traffic analysis. An observer positioned to watch your connections learns the shape of them — timing, volume, who talks to whom — regardless of how well the endpoints are configured. Encryption hides content, not conversation, and no amount of sshd tuning changes that.
The checklist, in the order to run it
Eight steps. The first four are the fifteen minutes in the title and eliminate whole categories of risk; the last four take longer and are what separates a server you can defend from one you merely configured. Each row includes how you know it worked, because a hardening step you did not verify is a hardening step you did not take.
| # | Step | How you know it worked |
|---|---|---|
| 1 | Open the panel console, confirm a login prompt, take a snapshot | You saw the prompt on a server that was not broken |
| 2 | Create a non-root user, install your ed25519 key, test sudo | A second terminal logs in as that user and escalates |
| 3 | Disable password and keyboard-interactive authentication | sshd -T reports passwordauthentication no |
| 4 | Default-deny inbound firewall covering IPv4 and IPv6 | nft list ruleset shows both families; a scan from another host agrees |
| 5 | Audit listening sockets, rebind internal services to loopback | ss -tulpn shows no wildcard bind you cannot justify |
| 6 | Enable automatic security updates and a reboot policy | unattended-upgrades --dry-run --debug selects the security origin |
| 7 | Confine services with systemd, tidy authorized_keys | systemd-analyze security scores improve; every key has a name |
| 8 | Encrypted append-only off-site backup, restored once | You restored into a throwaway instance and the data was there |
None of this is exotic, and that is the point. The controls that matter on a small server are the ones that eliminate a class of problem rather than manage it: no passwords, no unexpected listeners, no unpatched packages, no single copy of the data. Everything else is preference, and preference is cheap once the four load-bearing changes are in place and verified.
If you are provisioning the machine now rather than reading ahead, the sequence fits comfortably inside the first session: deploy an instance, open the console once to prove it works, and run the eight steps in order before you install anything at all. A server hardened before it has a service on it has no legacy to work around — which is the one advantage a new box has, and it lasts about a day.