The moment a VPS gets a public IP, it's under attack: scanners hit SSH and common paths within minutes. Here's the layered defense I run, none of it exotic.

1. The front door

SSH is key-only, root login disabled. Obvious, but the single highest-value change. PasswordAuthentication no and PermitRootLogin no, and check with sshd -T rather than trusting the config file, because an Include in a drop-in directory may be overriding what you just edited.

2. CrowdSec at the edge

CrowdSec parses my reverse-proxy logs, detects patterns (credential stuffing, path scanning, bad bots), and feeds a Traefik bouncer that drops offenders, plus a community blocklist, so I'm pre-protected against IPs already attacking others.

It splits into two halves and the split is the useful part. The engine reads logs, runs parsers to turn them into structured events, and evaluates scenarios that describe an attack as a rate of something over a window. When a scenario fires it writes a decision. The bouncer is a separate thing entirely that only reads decisions, which means the detection can be wrong or slow without taking your site down. Run the bouncer in stream mode so it holds the decision list in memory and refreshes on a timer; in live mode every single request becomes a lookup, and you will feel it.

The failure mode to plan for is what the bouncer does when it cannot reach the engine. Fail closed and a crashed CrowdSec container becomes a site-wide outage that looks exactly like a firewall problem.

3. A WAF in front of the apps

ModSecurity runs in detection mode against the OWASP core ruleset, so I get visibility into suspicious requests before flipping anything to blocking. The core ruleset scores each request rather than matching a single rule, and you set a threshold at which the total becomes a block. Its paranoia levels dial how aggressive the rules are, and every step up catches more attacks and more of your own users. Detection first, for weeks, reading what it would have blocked, is the only sane way in.

4. Egress, not just ingress

The piece people forget: outbound rules. Untrusted workloads get a policy that blocks them from reaching my other services, the cloud metadata endpoint, and SMTP. If something gets popped, it can't pivot or phone home.

There is a specific trap here if you are running Docker. Docker writes its own iptables rules and inserts them ahead of the chains a friendly front-end like ufw manages, so a ufw deny on a port that a container publishes does not block anything at all. Your firewall will report the rule and the port will be open. The chain Docker leaves alone for you to use is DOCKER-USER, and it is evaluated before Docker's own forwarding rules, so that is where container traffic policy has to live. Verify with iptables -L DOCKER-USER -n -v and an actual connection attempt, never by reading the ufw status.

The mindset

Assume breach. Each layer is cheap; together they turn a soft target into a boring one, and boring is exactly what a public box should be.