{"id":62,"date":"2026-08-01T12:24:24","date_gmt":"2026-08-01T09:24:24","guid":{"rendered":"https:\/\/john-nessime.com\/blog\/?p=62"},"modified":"2026-08-01T12:24:32","modified_gmt":"2026-08-01T09:24:32","slug":"vps-hardening-checklist","status":"publish","type":"post","link":"https:\/\/john-nessime.com\/blog\/linux\/vps-hardening-checklist\/","title":{"rendered":"You Probably Didn&#8217;t Harden That Server: A VPS Setup and Hardening Checklist That Verifies Itself"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">You spun up a new box, worked through a hardening guide, set <code>PasswordAuthentication no<\/code>, restarted SSH, and moved on. Six months later you are reading auth logs for an unrelated reason and there it is: successful password logins. Not attempts. Logins.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The line you wrote was real. The file was saved. What you did not know is that your distro puts an <code>Include \/etc\/ssh\/sshd_config.d\/*.conf<\/code> near the top of <code>sshd_config<\/code>, that sshd uses the <em>first<\/em> value it finds for any given keyword, and that your cloud provider&#8217;s image dropped a <code>50-cloud-init.conf<\/code> in that directory setting <code>PasswordAuthentication yes<\/code>. It sorts earlier. It wins. Your line at the bottom of the main file was never reached.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">You never noticed because you log in with a key anyway. Nothing broke. The control just was not there.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That is the thing most guides get wrong, and it is the idea this <strong>VPS hardening checklist<\/strong> is built around: a hardening step is not done when you have edited the file. It is done when you have checked the effective configuration and seen the value you expected. Everything below comes with the command that proves it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This covers the order to do things in so you do not lock yourself out, SSH keys and config, the firewall, automatic updates, fail2ban and whether it is worth it, the unglamorous things that matter more than any of it, and what I would skip.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Before you touch anything<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Two minutes of preparation that saves an evening.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Find your provider&#8217;s console before you need it.<\/strong> Whether you are on InterServer, Hetzner, DigitalOcean or anything else, there is a web-based serial or VNC console that works when SSH does not. Open it now, log in as root, and confirm it works. This is your parachute. If you are going to lock yourself out, you want to already know where it is.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Never close the session you are working in.<\/strong> Every SSH change gets tested from a second terminal while the first one stays open. If the new settings are broken, the old session is still authenticated and you can undo them. Close it too early and your only route back in is the console.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Then update the system, because everything else assumes current packages:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo apt update &amp;&amp; sudo apt upgrade -y\nsudo reboot   # if a kernel update landed<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">1. A real user, and keys instead of passwords<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Generate the key on your own machine, not on the server. A private key that has been on a server is not a private key any more.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># On your laptop. ed25519 is the sane default: short keys,\n# fast verification, no parameter choices to get wrong.\nssh-keygen -t ed25519 -C \"deploy@my-laptop\"<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code># On the server, as root.\nadduser deploy\nusermod -aG sudo deploy        # 'wheel' instead of 'sudo' on RHEL-family\n\n# Back on your laptop. ssh-copy-id appends to authorized_keys\n# and sets the permissions correctly, which is the part people\n# get wrong when they paste the key by hand.\nssh-copy-id deploy@203.0.113.10<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Now open a second terminal and confirm <code>ssh deploy@203.0.113.10<\/code> works, and that <code>sudo -v<\/code> works once you are in. Only then move on. If either fails, fix it before you disable anything, because in a minute you will be removing the fallback.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">2. SSH config, and why your edit might do nothing<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This is the section that actually matters. Two mechanisms silently override what you write.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Drop-in files and first-match-wins<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Modern OpenSSH packages ship an <code>Include<\/code> line near the top of <code>\/etc\/ssh\/sshd_config<\/code>, which pulls in every <code>.conf<\/code> file from <code>\/etc\/ssh\/sshd_config.d\/<\/code> in alphabetical order, before the rest of the main file is read. Combine that with sshd&#8217;s rule that the first value found for a keyword wins, and the consequence is specific: <strong>your drop-in has to sort earlier than the vendor&#8217;s, not later.<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Most guides tell you to name it <code>99-something.conf<\/code>, which is the convention almost everywhere else in Linux and exactly backwards here.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># \/etc\/ssh\/sshd_config.d\/00-hardening.conf\n# Sorts before 50-cloud-init.conf, so these values are seen first.\n\nPermitRootLogin no\nPasswordAuthentication no\nKbdInteractiveAuthentication no\nPubkeyAuthentication yes\n\n# Optional: restrict to the accounts that should have shell access.\nAllowUsers deploy<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A note on <code>KbdInteractiveAuthentication<\/code>: older guides and older OpenSSH call this <code>ChallengeResponseAuthentication<\/code>. Setting only <code>PasswordAuthentication no<\/code> can still leave a keyboard-interactive path open on some configurations, which is why both are here.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Now the part nobody does. Validate the syntax, then read the effective config:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># Syntax check. Prints nothing if the config is valid.\n# Run this BEFORE restarting, always.\nsudo sshd -t\n\n# Dump the effective configuration with every include resolved.\n# This is the only output that tells you the truth.\nsudo sshd -T | grep -Ei 'permitrootlogin|passwordauthentication|kbdinteractive|pubkeyauth'<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">If <code>passwordauthentication yes<\/code> comes back, your file lost to something else in that directory. Look at what is in there with <code>ls \/etc\/ssh\/sshd_config.d\/<\/code> and either rename yours to sort earlier or edit the offending file directly.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Socket activation, and the port that will not change<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The second trap. On several current distro releases, SSH is started by systemd socket activation rather than as a persistent service: <code>ssh.socket<\/code> holds the listening port and starts <code>sshd<\/code> only when a connection arrives. On those systems the <code>Port<\/code> and <code>ListenAddress<\/code> directives in <code>sshd_config<\/code> may be ignored entirely, because systemd owns the socket.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">People change the port, restart the service, see no error, and assume it worked. Check what is actually bound:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># -t TCP, -l listening, -n numeric, -p show the owning process\nsudo ss -tlnp<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">If you genuinely want a different port on a socket-activated system, override the socket unit with a proper drop-in rather than editing files under <code>\/lib\/systemd\/system\/<\/code>, which a package update will overwrite:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo systemctl edit ssh.socket<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code># In the editor that opens:\n[Socket]\n# The empty assignment clears the inherited value. Without it\n# you end up listening on both the old port and the new one.\nListenStream=\nListenStream=2222<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo systemctl daemon-reload\nsudo systemctl restart ssh.socket\nsudo ss -tlnp        # confirm before you trust it<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Honest assessment of port changes: this is not security. Anyone scanning your address finds an SSH daemon on a non-standard port in seconds. What it genuinely does is cut the volume of automated login noise in your logs by a large margin, which makes real events easier to see. That is a maintenance benefit, not a defence, and it is worth doing for that reason alone. Just do not count it as one of your controls.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">3. Firewall, in the right order<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The ordering here is the whole trick. Set the default policy and add your rules <em>before<\/em> you enable the firewall. Enable first and you will drop your own connection with the policy that denies everything.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo ufw default deny incoming\nsudo ufw default allow outgoing\n\n# Allow SSH BEFORE enabling. If you changed the port,\n# allow that port instead of the OpenSSH profile.\nsudo ufw allow OpenSSH\n# sudo ufw allow 2222\/tcp\n\nsudo ufw allow 80,443\/tcp\n\nsudo ufw enable\nsudo ufw status verbose<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Read that <code>status verbose<\/code> output rather than assuming. It shows the default policies and every rule, and it is the proof that this step is done.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One thing worth knowing if you run containers: Docker writes its own rules directly into netfilter and bypasses ufw. A container published with <code>-p 5432:5432<\/code> is reachable from the internet even though ufw says that port is denied. Bind container ports to <code>127.0.0.1<\/code> explicitly, or put the service behind a reverse proxy, and verify from outside the box with a port scan rather than trusting the firewall status.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A stronger option worth considering: do not expose SSH to the internet at all. Put the server on a private overlay network with something like Tailscale or a WireGuard tunnel, bind sshd to that interface, and close port 22 to the world entirely. More setup, and it removes an entire class of problem rather than filtering it.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">4. Automatic security updates<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Unpatched packages are a far more realistic route into your server than an SSH brute force. This is the highest-value item on the list and it takes two commands.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo apt install unattended-upgrades\nsudo dpkg-reconfigure --priority=low unattended-upgrades\n\n# Verify. Note the binary is singular, the package is plural.\n# --dry-run makes no changes.\nsudo unattended-upgrade --dry-run --debug<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">By default this applies security updates only, which is the right scope. Enabling automatic reboots is a real trade-off: it closes kernel vulnerabilities without you doing anything, and it will restart your server at whatever hour you configure. On a single web server with a proper service startup order, I turn it on. On anything holding state that does not recover cleanly from a hard restart, I leave it off and patch by hand.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">5. fail2ban, and what it is actually for<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Install it, but be clear about the role. Once password authentication is off and root login is disabled, a brute force against sshd cannot succeed. There is no password to guess. fail2ban is not what is keeping those attackers out; your key-only config is.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">What it does earn its place for is reducing log noise and CPU spent on connection handling, and protecting the services where credentials <em>do<\/em> exist: a mail server, a web application login form, an FTP daemon.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo apt install fail2ban\n\n# Do not edit jail.conf; it gets replaced on upgrade.\nsudo cp \/etc\/fail2ban\/jail.conf \/etc\/fail2ban\/jail.local\n\nsudo systemctl enable --now fail2ban\nsudo systemctl status fail2ban\nsudo fail2ban-client status sshd<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Check that <code>status<\/code> output properly. fail2ban has shipped in states where the service fails to start on a given distro release because the packaged version does not match the system Python, and it fails loudly in the journal and silently everywhere else. A stopped fail2ban looks exactly like a working one from the outside. If it is not running, <code>journalctl -u fail2ban<\/code> will tell you why.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Set <code>ignoreip<\/code> in <code>jail.local<\/code> to include your office or home address. Banning yourself is a rite of passage but it is avoidable.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">6. The boring items that matter more than the exciting ones<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Nobody writes blog posts about these, and they cause more real outages than any of the above.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Provider account security<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Turn on two-factor authentication in your hosting control panel and your domain registrar. Someone with your provider login does not need to defeat sshd; they can reset the root password from the console or attach your disk to a machine they control. This is the weakest link on most setups and it takes two minutes.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Time and timezone<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo timedatectl set-timezone UTC\ntimedatectl status       # look for \"System clock synchronized: yes\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">UTC on servers, always. Correlating logs across machines in different local times, one of which observes daylight saving, is a genuinely miserable way to spend an incident.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Swap on small instances<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code># A little swap stops the OOM killer choosing your database\n# during a traffic spike. It is not a substitute for RAM.\nsudo fallocate -l 2G \/swapfile\nsudo chmod 600 \/swapfile\nsudo mkswap \/swapfile\nsudo swapon \/swapfile\necho '\/swapfile none swap sw 0 0' | sudo tee -a \/etc\/fstab\nfree -h<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Backups that leave the machine<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Your provider&#8217;s snapshots live in the same account as the server. If that account is compromised, or you delete the wrong thing, they go with it. A snapshot is a rollback mechanism, not a backup.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Get database dumps and application data onto storage under a different set of credentials, on a schedule, and then do the part everyone skips: restore one. A backup you have never restored is a hypothesis. Point a dead man&#8217;s switch service at the backup job so you find out when it stops running, rather than the day you need it.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Knowing the box is alive<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">At minimum, an external uptime check that alerts you. Better, node metrics into Prometheus with Grafana dashboards, or a hosted equivalent. Disk filling up is the single most common way a small VPS falls over, and it is completely predictable if anything is watching the graph.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What I would skip<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Not everything in the long hardening guides earns its cost on a single web server.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Large sysctl hardening blocks pasted from a gist.<\/strong> Most of the values are already the default on a current kernel, several are cargo-culted from a decade ago, and one of them will break something in a way that takes hours to trace. Change kernel parameters when you understand what each one does.<\/li>\n<li><strong>AIDE, auditd and file-integrity monitoring<\/strong> on a small server nobody is watching. These produce a large volume of output that needs a human to review. If nobody reads it, it is not a control, it is just disk usage.<\/li>\n<li><strong>Rebuilding OpenSSH&#8217;s cipher list.<\/strong> Current defaults are good. Guides that hand you a <code>Ciphers<\/code> line are usually copying advice written for a version that shipped years ago, and you can pin yourself to something weaker than the default without realising.<\/li>\n<li><strong>Disabling ping.<\/strong> It breaks your own monitoring and hides nothing.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Troubleshooting<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Locked out after an SSH change<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Provider console, log in as root, and revert. If you cannot remember what you changed, <code>ls -lt \/etc\/ssh\/sshd_config.d\/<\/code> shows the most recently modified file. Run <code>sshd -t<\/code> to see whether the config is even valid, since a syntax error stops sshd starting at all.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Key auth rejected, password prompt appears instead<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Almost always permissions. The home directory must not be group-writable, <code>~\/.ssh<\/code> needs 700 and <code>authorized_keys<\/code> 600, both owned by the user. sshd refuses keys from world-writable locations and does not explain itself on the client side. Run <code>ssh -v<\/code> to see how far it gets, and check <code>journalctl -u ssh<\/code> on the server for the actual reason.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The setting I applied is not in effect<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><code>sudo sshd -T<\/code> and search for the keyword. If the effective value differs from your file, something in <code>sshd_config.d\/<\/code> sorts earlier, or a <code>Match<\/code> block later in the config is overriding it for your connection.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">A service is reachable that the firewall says is blocked<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Docker, or another daemon writing netfilter rules directly. Check with <code>sudo iptables -S<\/code> or <code>sudo nft list ruleset<\/code>, and always verify from a machine outside the server rather than trusting <code>ufw status<\/code>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Nothing obviously wrong, but who has been logging in?<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>last              # successful logins\nsudo lastb        # failed attempts, from \/var\/log\/btmp\njournalctl -u ssh --since \"24 hours ago\"<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Common mistakes<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Closing your only SSH session before testing the new configuration from a second one.<\/li>\n<li>Enabling the firewall before adding the SSH rule.<\/li>\n<li>Naming your sshd drop-in <code>99-<\/code>, so a vendor file that sorts earlier wins.<\/li>\n<li>Trusting the file you edited instead of running <code>sshd -T<\/code>.<\/li>\n<li>Generating the SSH key on the server.<\/li>\n<li>Changing the SSH port and counting it as a security control.<\/li>\n<li>Disabling password auth for SSH while leaving a web control panel with a weak password exposed.<\/li>\n<li>No two-factor authentication on the hosting account that can reset root.<\/li>\n<li>Treating provider snapshots as backups.<\/li>\n<li>Installing fail2ban and never checking that it started.<\/li>\n<li>Publishing container ports without binding them to localhost, then assuming ufw covers it.<\/li>\n<li>Pasting a sysctl block you cannot explain line by line.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Best practices<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Verify every control after applying it, with a command that reads the running state.<\/li>\n<li>Keep a second session open and the provider console tested before making SSH changes.<\/li>\n<li>Key-only authentication, root login disabled, and an explicit <code>AllowUsers<\/code> list.<\/li>\n<li>Default-deny inbound firewall, with rules added before the firewall is enabled.<\/li>\n<li>Automatic security updates on, with a deliberate decision about automatic reboots.<\/li>\n<li>Two-factor authentication on the hosting account and the registrar.<\/li>\n<li>Backups off the machine, on separate credentials, and restore-tested at least once.<\/li>\n<li>UTC everywhere, with clock sync confirmed.<\/li>\n<li>Monitoring that pages you on disk usage before it pages you on downtime.<\/li>\n<li>Script the whole build, so the next server is identical and you are not repeating this from memory.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">FAQ<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">What is the single most important step?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Key-only SSH with root login disabled, verified with <code>sshd -T<\/code>. It removes the entire category of credential guessing. Automatic security updates is a close second, because unpatched software is the more realistic way in once passwords are off the table.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Should I change the SSH port?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">For quieter logs, yes. As a security measure, no. A scanner finds the daemon regardless. The practical benefit is that failed-login noise drops enough that a genuine anomaly becomes visible, which has real value for anyone actually reading their logs.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Do I need fail2ban if password authentication is off?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Not for SSH specifically. Install it for the services that still accept credentials, and for the reduction in log volume and connection churn. Just do not let it occupy the slot in your head reserved for an actual defence.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Why did my sshd_config change get ignored?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Two likely causes. A file in <code>\/etc\/ssh\/sshd_config.d\/<\/code> that sorts alphabetically earlier set the same keyword first, and sshd keeps the first value it sees. Or the setting is <code>Port<\/code> or <code>ListenAddress<\/code> on a socket-activated system, where systemd owns the listener. <code>sshd -T<\/code> answers the first case and <code>ss -tlnp<\/code> answers the second.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Is ufw enough, or should I use nftables directly?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">ufw is enough for a single server with a handful of open ports, and it is a front end to the same machinery underneath. Write nftables rules directly when you need something ufw cannot express cleanly, such as rate limiting or complex source-based rules. Do not switch tools for its own sake; a firewall you understand beats an elegant one you do not.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How do I harden a server I did not build?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Audit before you change anything. <code>sshd -T<\/code>, <code>ufw status verbose<\/code>, <code>ss -tlnp<\/code> for unexpected listeners, <code>systemctl list-units --type=service --state=running<\/code>, and the contents of every <code>authorized_keys<\/code> on the box. Old keys belonging to people who left are extremely common and nobody ever removes them.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Should this be automated?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Yes, once you have done it manually enough times to understand each step. An Ansible playbook or a cloud-init script makes the next server identical and turns your hardening into something reviewable in version control. Automating a process you do not yet understand just makes the mistakes reproducible.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">The one thing to remember<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Editing a config file is not the same as changing a system&#8217;s behaviour. Between your file and the running daemon sit include directives, drop-in ordering, systemd units and packages that ship their own opinions, and any one of them can quietly discard what you wrote without an error message.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So treat this VPS hardening checklist as a set of claims to be tested rather than a list to be ticked. <code>sshd -T<\/code> for SSH, <code>ufw status verbose<\/code> for the firewall, <code>ss -tlnp<\/code> for what is listening, <code>systemctl status<\/code> for anything you installed. If you cannot show the control from the running system, you have not applied it. You have only intended to.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Want a second pair of eyes on your server?<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Most of the servers I get handed were hardened once, by someone who has since moved on, and nobody has verified anything since. Work I take on:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Auditing an existing VPS and reporting which controls are genuinely in effect versus configured but overridden.<\/li>\n<li>Building a new server from scratch: users, SSH, firewall, automatic updates, monitoring, backups, all verified.<\/li>\n<li>Turning a manual build into an Ansible playbook or cloud-init script so the next server matches this one.<\/li>\n<li>Moving SSH off the public internet onto a private overlay network or bastion.<\/li>\n<li>Backup strategy that survives the hosting account being compromised, including a tested restore.<\/li>\n<li>Firewall and container networking, especially where published Docker ports are bypassing the firewall.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Send me the output of <code>sshd -T<\/code>, <code>ufw status verbose<\/code> and <code>ss -tlnp<\/code>, and I will tell you what stands out.<\/p>\n\n\n\n<div class=\"wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex\">\n<div class=\"wp-block-button\"><a class=\"wp-block-button__link wp-element-button\" href=\"https:\/\/www.upwork.com\/freelancers\/~01f15a912ad84a6620\" target=\"_blank\" rel=\"noreferrer noopener\">Work with me on Upwork<\/a><\/div>\n<\/div>\n","protected":false},"excerpt":{"rendered":"<p>You set PasswordAuthentication no, restarted SSH, and moved on. Months later the logs show successful password logins, because a vendor drop-in file sorted earlier and won. A VPS setup and hardening checklist where every step comes with the command that proves it worked.<\/p>\n","protected":false},"author":1,"featured_media":63,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[26,63,30],"tags":[120,144,142,21,6,141,10,22,139,140,72,15,97,4,143,138,112],"class_list":["post-62","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-linux","category-system-administration","category-web-security","tag-bash","tag-fail2ban","tag-firewall","tag-infrastructure","tag-linux","tag-openssh","tag-production","tag-self-hosting","tag-server-hardening","tag-ssh","tag-sysadmin","tag-system-administration","tag-systemd","tag-troubleshooting","tag-ufw","tag-vps","tag-web-security","entry","has-media"],"yoast_head":"<!-- This site is optimized with the Yoast SEO plugin v28.2 - https:\/\/yoast.com\/product\/yoast-seo-wordpress\/ -->\n<title>VPS Hardening Checklist: Secure a New Server Properly<\/title>\n<meta name=\"description\" content=\"A VPS hardening checklist that verifies itself: SSH keys, config drop-ins that silently override you, firewall order, updates and what to skip.\" \/>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/john-nessime.com\/blog\/linux\/vps-hardening-checklist\/\" \/>\n<meta property=\"og:locale\" content=\"en_US\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"VPS Hardening Checklist: Secure a New Server Properly\" \/>\n<meta property=\"og:description\" content=\"A VPS hardening checklist that verifies itself: SSH keys, config drop-ins that silently override you, firewall order, updates and what to skip.\" \/>\n<meta property=\"og:url\" content=\"https:\/\/john-nessime.com\/blog\/linux\/vps-hardening-checklist\/\" \/>\n<meta property=\"og:site_name\" content=\"John Nessime\" \/>\n<meta property=\"article:published_time\" content=\"2026-08-01T09:24:24+00:00\" \/>\n<meta property=\"article:modified_time\" content=\"2026-08-01T09:24:32+00:00\" \/>\n<meta property=\"og:image\" content=\"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/08\/vps-hardening-checklist.png\" \/>\n\t<meta property=\"og:image:width\" content=\"1200\" \/>\n\t<meta property=\"og:image:height\" content=\"800\" \/>\n\t<meta property=\"og:image:type\" content=\"image\/png\" \/>\n<meta name=\"author\" content=\"John Nessime\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<meta name=\"twitter:label1\" content=\"Written by\" \/>\n\t<meta name=\"twitter:data1\" content=\"John Nessime\" \/>\n\t<meta name=\"twitter:label2\" content=\"Est. reading time\" \/>\n\t<meta name=\"twitter:data2\" content=\"13 minutes\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\\\/\\\/schema.org\",\"@graph\":[{\"@type\":\"Article\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/linux\\\/vps-hardening-checklist\\\/#article\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/linux\\\/vps-hardening-checklist\\\/\"},\"author\":{\"name\":\"John Nessime\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#\\\/schema\\\/person\\\/ede0b56d0c808f123f57d5d796902105\"},\"headline\":\"You Probably Didn&#8217;t Harden That Server: A VPS Setup and Hardening Checklist That Verifies Itself\",\"datePublished\":\"2026-08-01T09:24:24+00:00\",\"dateModified\":\"2026-08-01T09:24:32+00:00\",\"mainEntityOfPage\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/linux\\\/vps-hardening-checklist\\\/\"},\"wordCount\":2751,\"commentCount\":0,\"publisher\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#\\\/schema\\\/person\\\/ede0b56d0c808f123f57d5d796902105\"},\"image\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/linux\\\/vps-hardening-checklist\\\/#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/08\\\/vps-hardening-checklist.png\",\"keywords\":[\"Bash\",\"Fail2ban\",\"Firewall\",\"Infrastructure\",\"Linux\",\"OpenSSH\",\"Production\",\"Self Hosting\",\"Server Hardening\",\"SSH\",\"Sysadmin\",\"System Administration\",\"Systemd\",\"Troubleshooting\",\"UFW\",\"VPS\",\"Web Security\"],\"articleSection\":[\"Linux\",\"System Administration\",\"Web Security\"],\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"CommentAction\",\"name\":\"Comment\",\"target\":[\"https:\\\/\\\/john-nessime.com\\\/blog\\\/linux\\\/vps-hardening-checklist\\\/#respond\"]}]},{\"@type\":\"WebPage\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/linux\\\/vps-hardening-checklist\\\/\",\"url\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/linux\\\/vps-hardening-checklist\\\/\",\"name\":\"VPS Hardening Checklist: Secure a New Server Properly\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#website\"},\"primaryImageOfPage\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/linux\\\/vps-hardening-checklist\\\/#primaryimage\"},\"image\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/linux\\\/vps-hardening-checklist\\\/#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/08\\\/vps-hardening-checklist.png\",\"datePublished\":\"2026-08-01T09:24:24+00:00\",\"dateModified\":\"2026-08-01T09:24:32+00:00\",\"description\":\"A VPS hardening checklist that verifies itself: SSH keys, config drop-ins that silently override you, firewall order, updates and what to skip.\",\"breadcrumb\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/linux\\\/vps-hardening-checklist\\\/#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\\\/\\\/john-nessime.com\\\/blog\\\/linux\\\/vps-hardening-checklist\\\/\"]}]},{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/linux\\\/vps-hardening-checklist\\\/#primaryimage\",\"url\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/08\\\/vps-hardening-checklist.png\",\"contentUrl\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/08\\\/vps-hardening-checklist.png\",\"width\":1200,\"height\":800,\"caption\":\"Diagram contrasting an SSH hardening config file with the effective output of sshd -T, showing password authentication still enabled because a vendor drop-in file was read first.\"},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/linux\\\/vps-hardening-checklist\\\/#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"You Probably Didn&#8217;t Harden That Server: A VPS Setup and Hardening Checklist That Verifies Itself\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#website\",\"url\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/\",\"name\":\"John Nessime\",\"description\":\"Cloud, DevOps, Data &amp; AI \u2014 Built, Tested, Explained\",\"publisher\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#\\\/schema\\\/person\\\/ede0b56d0c808f123f57d5d796902105\"},\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"en-US\"},{\"@type\":[\"Person\",\"Organization\"],\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#\\\/schema\\\/person\\\/ede0b56d0c808f123f57d5d796902105\",\"name\":\"John Nessime\",\"image\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/cropped-jn.png\",\"url\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/cropped-jn.png\",\"contentUrl\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/cropped-jn.png\",\"width\":512,\"height\":512,\"caption\":\"John Nessime\"},\"logo\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/cropped-jn.png\"},\"description\":\"AWS Certified Solutions Architect helping businesses build reliable cloud, data, reporting, and automation solutions. I help startups, agencies, and growing businesses replace manual processes and disconnected data with practical AWS architectures, clean data pipelines, useful dashboards, and maintainable automation.\",\"sameAs\":[\"https:\\\/\\\/john-nessime.com\\\/blog\",\"https:\\\/\\\/www.linkedin.com\\\/in\\\/john-m-nessime\"],\"url\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/author\\\/johnnessime\\\/\"}]}<\/script>\n<!-- \/ Yoast SEO plugin. -->","yoast_head_json":{"title":"VPS Hardening Checklist: Secure a New Server Properly","description":"A VPS hardening checklist that verifies itself: SSH keys, config drop-ins that silently override you, firewall order, updates and what to skip.","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/john-nessime.com\/blog\/linux\/vps-hardening-checklist\/","og_locale":"en_US","og_type":"article","og_title":"VPS Hardening Checklist: Secure a New Server Properly","og_description":"A VPS hardening checklist that verifies itself: SSH keys, config drop-ins that silently override you, firewall order, updates and what to skip.","og_url":"https:\/\/john-nessime.com\/blog\/linux\/vps-hardening-checklist\/","og_site_name":"John Nessime","article_published_time":"2026-08-01T09:24:24+00:00","article_modified_time":"2026-08-01T09:24:32+00:00","og_image":[{"width":1200,"height":800,"url":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/08\/vps-hardening-checklist.png","type":"image\/png"}],"author":"John Nessime","twitter_card":"summary_large_image","twitter_misc":{"Written by":"John Nessime","Est. reading time":"13 minutes"},"schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"Article","@id":"https:\/\/john-nessime.com\/blog\/linux\/vps-hardening-checklist\/#article","isPartOf":{"@id":"https:\/\/john-nessime.com\/blog\/linux\/vps-hardening-checklist\/"},"author":{"name":"John Nessime","@id":"https:\/\/john-nessime.com\/blog\/#\/schema\/person\/ede0b56d0c808f123f57d5d796902105"},"headline":"You Probably Didn&#8217;t Harden That Server: A VPS Setup and Hardening Checklist That Verifies Itself","datePublished":"2026-08-01T09:24:24+00:00","dateModified":"2026-08-01T09:24:32+00:00","mainEntityOfPage":{"@id":"https:\/\/john-nessime.com\/blog\/linux\/vps-hardening-checklist\/"},"wordCount":2751,"commentCount":0,"publisher":{"@id":"https:\/\/john-nessime.com\/blog\/#\/schema\/person\/ede0b56d0c808f123f57d5d796902105"},"image":{"@id":"https:\/\/john-nessime.com\/blog\/linux\/vps-hardening-checklist\/#primaryimage"},"thumbnailUrl":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/08\/vps-hardening-checklist.png","keywords":["Bash","Fail2ban","Firewall","Infrastructure","Linux","OpenSSH","Production","Self Hosting","Server Hardening","SSH","Sysadmin","System Administration","Systemd","Troubleshooting","UFW","VPS","Web Security"],"articleSection":["Linux","System Administration","Web Security"],"inLanguage":"en-US","potentialAction":[{"@type":"CommentAction","name":"Comment","target":["https:\/\/john-nessime.com\/blog\/linux\/vps-hardening-checklist\/#respond"]}]},{"@type":"WebPage","@id":"https:\/\/john-nessime.com\/blog\/linux\/vps-hardening-checklist\/","url":"https:\/\/john-nessime.com\/blog\/linux\/vps-hardening-checklist\/","name":"VPS Hardening Checklist: Secure a New Server Properly","isPartOf":{"@id":"https:\/\/john-nessime.com\/blog\/#website"},"primaryImageOfPage":{"@id":"https:\/\/john-nessime.com\/blog\/linux\/vps-hardening-checklist\/#primaryimage"},"image":{"@id":"https:\/\/john-nessime.com\/blog\/linux\/vps-hardening-checklist\/#primaryimage"},"thumbnailUrl":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/08\/vps-hardening-checklist.png","datePublished":"2026-08-01T09:24:24+00:00","dateModified":"2026-08-01T09:24:32+00:00","description":"A VPS hardening checklist that verifies itself: SSH keys, config drop-ins that silently override you, firewall order, updates and what to skip.","breadcrumb":{"@id":"https:\/\/john-nessime.com\/blog\/linux\/vps-hardening-checklist\/#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/john-nessime.com\/blog\/linux\/vps-hardening-checklist\/"]}]},{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/john-nessime.com\/blog\/linux\/vps-hardening-checklist\/#primaryimage","url":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/08\/vps-hardening-checklist.png","contentUrl":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/08\/vps-hardening-checklist.png","width":1200,"height":800,"caption":"Diagram contrasting an SSH hardening config file with the effective output of sshd -T, showing password authentication still enabled because a vendor drop-in file was read first."},{"@type":"BreadcrumbList","@id":"https:\/\/john-nessime.com\/blog\/linux\/vps-hardening-checklist\/#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/john-nessime.com\/blog\/"},{"@type":"ListItem","position":2,"name":"You Probably Didn&#8217;t Harden That Server: A VPS Setup and Hardening Checklist That Verifies Itself"}]},{"@type":"WebSite","@id":"https:\/\/john-nessime.com\/blog\/#website","url":"https:\/\/john-nessime.com\/blog\/","name":"John Nessime","description":"Cloud, DevOps, Data &amp; AI \u2014 Built, Tested, Explained","publisher":{"@id":"https:\/\/john-nessime.com\/blog\/#\/schema\/person\/ede0b56d0c808f123f57d5d796902105"},"potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/john-nessime.com\/blog\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"},{"@type":["Person","Organization"],"@id":"https:\/\/john-nessime.com\/blog\/#\/schema\/person\/ede0b56d0c808f123f57d5d796902105","name":"John Nessime","image":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/07\/cropped-jn.png","url":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/07\/cropped-jn.png","contentUrl":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/07\/cropped-jn.png","width":512,"height":512,"caption":"John Nessime"},"logo":{"@id":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/07\/cropped-jn.png"},"description":"AWS Certified Solutions Architect helping businesses build reliable cloud, data, reporting, and automation solutions. I help startups, agencies, and growing businesses replace manual processes and disconnected data with practical AWS architectures, clean data pipelines, useful dashboards, and maintainable automation.","sameAs":["https:\/\/john-nessime.com\/blog","https:\/\/www.linkedin.com\/in\/john-m-nessime"],"url":"https:\/\/john-nessime.com\/blog\/author\/johnnessime\/"}]}},"_links":{"self":[{"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/posts\/62","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/comments?post=62"}],"version-history":[{"count":1,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/posts\/62\/revisions"}],"predecessor-version":[{"id":73,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/posts\/62\/revisions\/73"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/media\/63"}],"wp:attachment":[{"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/media?parent=62"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/categories?post=62"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/tags?post=62"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}