How I Secure a Fresh VPS Before Deploying Anything
Cloud

How I Secure a Fresh VPS Before Deploying Anything

A brand-new VPS gets scanned within minutes of getting an IP address. Here's the exact order I run through before a single container touches the box — and which of these steps are real protection versus which ones are just theatre.

Published August 21, 202612 min readUpdated Sep 6, 2026

Written by · Full-Stack Agentic AI Software Engineer — AI Agents, Automation & Revenue Systems for GTM/RevOps teams

In brief

What should I actually do to secure a brand-new VPS before I deploy anything to it?

Before any app touches the box: create a non-root sudo user, install an SSH key and confirm it works, disable password authentication and root SSH login, set up a default-deny firewall, turn on unattended security upgrades, install fail2ban for the SSH jail, and fix the Docker/UFW interaction that silently reopens ports Docker publishes. The order matters — skipping ahead is how people lock themselves out or leave a window open they didn't know existed.

  • A fresh VPS gets port-scanned within minutes of getting a public IP — this isn't paranoia, it's the baseline
  • Disabling SSH password auth is the single highest-leverage step; most of the rest is defense in depth
  • Docker writes its own iptables rules and ignores ufw entirely — a published port is exposed no matter what the firewall says
  • fail2ban is mostly redundant for HTTP traffic once you're behind Cloudflare, but SSH isn't proxied, so it still earns its place
  • Some of this checklist is genuine protection and some of it is comfort — the post says which is which

Evidence notes

Docker/UFW interaction

Docker inserts DNAT rules into the nat table's PREROUTING chain and forwards traffic through the FORWARD chain, both of which sit ahead of ufw's INPUT chain rules — a published container port bypasses ufw's deny rule entirely. This is documented Docker behavior, not a bug.

fail2ban behind a proxy

Once traffic is proxied, the server only sees Cloudflare's edge IPs, not the real client — iptables-level bans on the web server's own log become mostly useless for HTTP unless the real IP is restored via a trusted header first. SSH isn't proxied, so it's unaffected.

Contabo Cloud VPS 4

4 vCPU, 8 GB RAM, 100 GB SSD, traffic advertised as unlimited, €5.50/month including VAT for the first 24 months (checked 7 September 2026) — the box this checklist runs on.

Continue with purpose

Bots scan the entire IPv4 range for open port 22 constantly — it costs a scanner nothing to check every address, and the moment a hosting provider hands you one, you're in the rotation, whether a domain points at it yet or not. Nobody is targeting you specifically. Nobody even knows the box exists. That's what stopped me treating server security as optional homework: the attack surface exists from boot, not from launch day. That's the box this checklist runs on: a Contabo Cloud VPS 4 — 4 vCPU, 8 GB RAM, 100 GB SSD, traffic advertised as unlimited, €5.50/month for the first 24 months (checked September 2026). Nothing about it is special, which is exactly the point — this is what every cheap VPS looks like to the internet within minutes of boot.

So before Coolify goes on, before Docker pulls a single image, before a domain even resolves to the IP, I run through the same eight steps in the same order. Not because the order is sacred in some abstract sense, but because getting it wrong in practice means locking yourself out of a server with no console access, or leaving a hole open for the twenty minutes between "firewall configured" and "firewall actually enforced." This post is that order, with the reasoning attached — and an honest answer on which of these steps are doing real work and which ones are mostly there to make you feel better. the XenGrowth practice writes about VPS security as an operating problem rather than a build problem.

Why does a server get attacked before anyone even knows it exists?

Nobody is targeting your app in the first hour. Nobody knows it's there. What's hitting you is mass, automated, and indifferent — botnets that sweep every routable IPv4 address looking for open SSH, exposed databases, and default credentials, because it costs them nothing to check and every VPS provider recycles IP ranges constantly. Your new box inherits whatever reputation the last tenant left behind, plus a fresh slot in every scanner's queue. This is why "I'll secure it after I get the app running" is the wrong order of operations — the attack surface exists from the moment the box has a public IP, not from the moment you decide to care about it.

What's the actual order, and why does it matter that it's an order?

Each of these steps closes off something the previous one left open, or sets up something the next one depends on. Do them out of sequence and you either get locked out or you leave a gap you didn't know you had — I've done both, on the same server, in the same afternoon, which is a large part of why this list exists in writing now. The XenGrowth resource library covers the the operations side of this side of this.

  1. Create a non-root user with sudo, and switch to it immediately. Root logging in directly has no accountability — every command runs as the single most powerful account on the box, with no 'are you sure' and no separate audit trail. I add the user, add it to the sudo group, and test that sudo actually works in a second terminal before I do anything else. This step buys nothing on its own against an outside attacker, but it's the foundation everything after it assumes exists.

  2. Copy an SSH key to the new user and confirm key-based login works — in a second terminal, without closing the first. ssh-copy-id, then a fresh connection attempt with the key only. The rule is: never close the session you're editing sshd_config from until a second, independent session proves the new config works. This is the step people skip when they're in a hurry, and it's the one that turns a five-minute setup into a support ticket asking Contabo to mount a rescue console.

  3. Disable password authentication and root SSH login in /etc/ssh/sshd_config — PasswordAuthentication no, PermitRootLogin no — then restart sshd. This is the single highest-leverage change on this entire list. Password brute force against SSH is the most common thing a fresh box sees in its first hour on the internet, and it doesn't matter how long or random your password is if the attack surface for guessing it is removed entirely. A disabled password login doesn't get 'harder to crack' — it stops being an attack surface.

  4. Set up ufw and default to deny incoming, allow outgoing, then explicitly allow SSH, 80, and 443 and nothing else. This is where most 'firewall' setups stop, and for a bare server with no containers, it would be enough. It is not enough once Docker enters the picture — more on that below, because this is the step where the order actually deceives you.

  5. Turn on unattended-upgrades for security patches. This is the least exciting step on the list and also one of the two or three that actually matter long-term: a huge share of real-world server compromises trace back to a known, patched vulnerability in a package nobody updated for months. Automatic security patching doesn't need you to remember anything, which is exactly why it works — the steps that depend on you remembering to do them later are the ones that quietly stop happening.

  6. Install fail2ban and enable the sshd jail. Set a ban time in hours, not minutes, and a low retry threshold. This adds a real, if narrow, layer on top of step 3: even with password auth off, fail2ban rate-limits and bans IPs hammering the SSH port with malformed handshakes or key-guessing attempts, which reduces log noise and connection overhead even when it can't reduce actual risk to zero.

  7. Fix the Docker and ufw interaction before running a single container — this is the step that undoes step 4 if you skip it, and it's covered in full below because it deserves more than a bullet point.

  8. Reboot, then reconnect from a completely fresh terminal — not the one you've had open this whole time — before you consider the box done. If the new user, the key, and the firewall rules all survive a clean reboot and a clean login, you're actually finished. If they don't, you want to find out now, not the next time the VPS restarts on its own.

Which of these steps are real protection and which are theatre?

I'd rather say this plainly than let the list imply every item carries equal weight, because it doesn't. Disabling password SSH auth is not theatre — it's the one change that removes an entire attack class. The firewall, done correctly (which, again, Docker actively works against), is real. Unattended upgrades are unglamorous and real. Fail2ban is genuinely useful for SSH specifically, and genuinely close to pointless for anything sitting behind a proxy that hides the real client IP — which is most of what a self-hosted web app actually serves. Changing the SSH port away from 22 is the one piece of folk wisdom I'll call out directly as theatre: it cuts down log noise from the dumbest scanners, and does nothing against anything that actually enumerates open ports, which takes a scanner seconds either way.

Step

What it actually stops

Real protection or comfort

Non-root user + sudo

Accidental damage and blast radius of a compromised session

Foundation — enables everything else

SSH key, password auth off

Brute-force and credential-stuffing against SSH

Real — the highest-leverage step here

ufw default-deny

Any service you didn't explicitly mean to expose

Real, but see the Docker caveat below

unattended-upgrades

Known CVEs in packages you forgot to patch

Real, and it's the one nobody remembers to do manually

fail2ban (sshd jail)

Repeated SSH connection attempts and key-guessing noise

Real for SSH; mostly theatre for proxied HTTP traffic

Non-standard SSH port

Log noise from unsophisticated scanners

Theatre — a real scan enumerates ports in seconds

Why does Docker quietly undo the firewall I just set up?

This is the step that catches people who did everything else right. You run ufw default deny, allow 22/80/443, feel good about it, then start a container with docker run -p 5432:5432 postgres — and Postgres is now reachable from the entire internet, ufw rule or no ufw rule. This isn't a misconfiguration on your part and it isn't a Docker bug either; it's documented, intentional behavior that most tutorials never mention because most tutorials never actually publish a database port to the internet to test it.

Here's the actual mechanism. When Docker publishes a port with -p, it writes a DNAT rule directly into the nat table's PREROUTING chain, rewriting the destination address of matching packets to the container's internal IP before the kernel has even decided how to route them. That rewritten packet then gets forwarded to the container through the FORWARD chain — a chain Docker also manages and inserts rules into ahead of most default configurations. ufw's rules live in the INPUT chain. A packet destined for a container's published port never reaches INPUT at all, so ufw denying that port changes nothing. Your firewall isn't broken. It's just never being consulted. On AI agents and marketing automation specifically, XenGrowth on governed AI marketing workflows is worth reading.

The fix I actually use is the boring one: bind container ports to localhost by default — -p 127.0.0.1:5432:5432 instead of -p 5432:5432 — and let a reverse proxy (Traefik, in Coolify's case) be the only thing with a genuinely public listener on 80 and 443. Anything that doesn't need to be reachable from outside the box, including the database, doesn't get a public bind in the first place, which makes the ufw-versus-Docker argument moot for that service entirely. For containers that do need broader rules than 'localhost only,' the DOCKER-USER chain is the documented place to add filtering that Docker's own rules won't silently override — it's evaluated before Docker's own FORWARD rules, which the INPUT chain never gets the chance to be. Either approach works; picking neither and assuming ufw has you covered is the actual footgun.

Is fail2ban even worth running once Cloudflare is in front of the app?

Mostly no, for the part of the box that Cloudflare actually sees — and yes, for the part it doesn't. Once Cloudflare's free tier is proxying the app, every HTTP request your server logs arrives from a Cloudflare edge IP, not the real visitor. fail2ban watching nginx or Traefik's access log and banning an IP by source address is banning Cloudflare, repeatedly, which does nothing useful and can eventually make Cloudflare itself look hostile to your server if you're not restoring the real client IP from the CF-Connecting-IP header first. Most self-hosted setups never bother with that header, which means the HTTP-facing fail2ban jail is running on a fiction.

SSH is the exception, and it's the reason I still run fail2ban at all. SSH isn't proxied through Cloudflare — you're not about to route your own terminal session through an orange-clouded DNS record — so port 22 sees real source IPs, real brute-force attempts, and real benefit from a jail that bans them. The honest version of this section is: keep the sshd jail, drop or ignore the web-facing one unless you've done the extra work to un-mask the real client IP first, and don't let 'I have fail2ban' stand in as a reason to skip disabling password auth. The jail is a rate limiter, not a replacement for removing the attack surface. There is a longer treatment of AI search, GEO and discovery in XenGrowth on building one SEO and GEO content system.

fail2ban jail

What it sees

Worth running?

sshd (port 22)

Real source IPs, unproxied

Yes — keep it

nginx/Traefik auth (behind Cloudflare)

Cloudflare edge IPs, not real clients

No, unless you restore CF-Connecting-IP first

recidive (repeat-offender jail)

Whatever the underlying jails see

Only as useful as the jail feeding it

What do I actually do differently once this checklist is done?

Only after step 8 — the reboot-and-reconnect check — does Coolify go on the box, and only after Coolify is up does anything from the deployment workflow start running. I've stopped treating this as a chore that delays the fun part. It's roughly twenty minutes on a box I've already sized using the same rule I cover in how I decide between vCPU and RAM, and the alternative — deploying first, hardening later, on the theory that nothing will find the box in the meantime — bets against a scanner sweep that's already running before you've finished reading this sentence.

None of this makes the box unbreakable, and I'd be lying if I framed it that way. What it does is remove the categories of compromise that come from doing nothing: guessable passwords, unpatched known CVEs, a database port nobody meant to expose, and a firewall that looks configured but isn't actually being consulted. That's the realistic bar for a €5.50-a-month server run by one person, and it's a bar most fresh VPS instances never clear in their first day online.

Further reading from XenGrowth

Where this work meets go-to-market

the team at XenGrowth writes for the teams who have to run VPS security day to day.

Order of operations

Five questions on why this checklist runs in the order it does, and which steps are doing real work. Answers and reasoning at the end.

1 / 5
You've just edited sshd_config to disable password authentication. What should happen before you close your current session?

Apply this article

How to turn insights into execution

A practical sequence for teams turning concepts into production outcomes.

VPS SecuritySSH HardeningFirewallDockerFail2banSelf-HostingLinuxcloud

Audit your current state

Map the bottlenecks and constraints connected to the article’s core problem.

Choose one bounded change

Test the most useful recommendation on one workflow before widening the scope.

Measure what changed

Keep the parts that improve the work, document what failed, and make the next decision from evidence.

Next step

Need help applying this in your stack?

I can translate these patterns into a concrete implementation plan for your team.

Discuss implementationBack to blog

Replies usually within 24 hours.

Next Steps

Continue reading

SSH Hardening: Keys, Brute-Force Protection and What Fail2ban Still Buys You

Password auth off, root login off — that's most of the story, and I've already told it. This is the rest: which key type actually matters, the PAM gotcha that quietly undoes 'disable password auth,' and an honest answer on what fail2ban is even for once there's no password left to brute-force.

Navigate

Don't Self-Host Until You Understand These 7 Things

This isn't a gate to keep you out. It's a readiness check — seven things worth being honest with yourself about before you're the one holding the pager, because a managed platform is still the right call for a lot of people right now.

Navigate

How I Self-Host PostgreSQL for My SaaS (and When I Wouldn't)

Running Postgres in a container is easy. Running it in a way that survives a redeploy, a full disk, and an eventual major-version upgrade is the actual job. Here's the setup, tuned against Postgres's own defaults, and the honest list of where managed wins outright.

Navigate

10 Mistakes That Break a Self-Hosted SaaS

None of these ten show up as a single dramatic outage. They show up as a disk that quietly fills, a rollback that turns out to be impossible, a backup nobody ever restored. Here's the mechanism behind each one, and the fix.

Navigate

My Free Monitoring Stack for Self-Hosted Apps

Vercel gives you monitoring whether you ask for it or not. A VPS gives you a blank terminal and the assumption you'll figure it out. Here's what to actually watch on a self-hosted box, with tools that cost nothing, and why watching from the box itself is the one setup that will lie to you.

Navigate

The Security Mistakes I See New Self-Hosters Make

These aren't rare. They're the same seven patterns, documented in breach reports, CVE databases, and botnet postmortems, showing up on new self-hosted boxes on a loop — because the defaults that make setup fast are the same defaults that make a box exploitable.

Navigate

Setting Up a Firewall for Self-Hosted Apps (and Docker's Nasty UFW Surprise)

ufw status can say everything's locked down while a container you published with -p 5432:5432 sits wide open to the internet. This isn't a misconfiguration — it's Docker rewriting your firewall's decisions before ufw ever gets a vote, and the only way to know for sure is to check from a machine that isn't the one you're worried about.

Navigate

Cloudflare Tunnel vs Reverse Proxy: Which One Should You Use?

One of these opens no inbound ports and works behind CGNAT. The other is simpler, portable, and doesn't ask you to trust a daemon or a vendor's uptime with every request. Neither one is the obviously correct default — the right answer depends on which failure you'd rather own.

Navigate

Zero-Downtime Deploys and Instant Rollbacks on a Cheap VPS

docker compose up -d looks like a deploy and behaves like an outage — there's a gap between the old container stopping and the new one answering requests, and on a small box that gap is exactly where a real user lands.

Navigate

What Cloudflare's Free Tier Actually Does for a Self-Hosted App

Cloudflare's free plan gets recommended for every self-hosted setup, usually without anyone saying what it doesn't cover. Here's what $0 genuinely buys a one-person VPS, checked against 2026 pricing, and exactly where it stops.

Navigate