Layer zero: don't expose what you don't have to
This is the whole game, and most people skip straight past it. Before you harden a service, ask whether it should be reachable from the internet at all.
The canonical breach isn't exotic. You spin up a Postgres container to back a web app. The compose file has ports: - "5432:5432". You forget that Docker cheerfully punches that through the host firewall (more on that landmine below), and now your database is answering the entire internet with a password you set to postgres because it was "just for testing." Shodan indexes it within a day. A scanner finds it, tries the top 50 default credentials, and you're in a ransom note.
The fix costs nothing and it's the highest-leverage change on this page: bind services to where they're actually consumed.
- If a service is only used by other processes on the same box, bind it to
127.0.0.1, not0.0.0.0. For that Postgres container, the mapping should be127.0.0.1:5432:5432. Now nothing off-host can even open a socket to it. - If you need to reach an internal service from your laptop — a database GUI, an admin panel, Prometheus — don't expose it publicly with a password. Reach it over a WireGuard tunnel and bind it to the VPN interface. The service becomes invisible to everyone who isn't already inside your tunnel. There's no login page to brute-force because there's no login page reachable.
- The only things that genuinely belong on
0.0.0.0are the things whose entire job is to serve the public: your reverse proxy on 80/443, and SSH. That's usually the whole list.
Run sudo ss -tlnp on any server you already own and read the output honestly. Every line bound to 0.0.0.0 or * is a promise you're making to the internet. Most people find at least one they didn't mean to make.
The Docker firewall trap, specifically
This one deserves its own paragraph because it burns people who did everything else right. Docker manipulates iptables directly. When you publish a port with -p 5432:5432, Docker inserts a rule that bypasses ufw entirely. You can have ufw default deny incoming, run ufw status, see the port isn't allowed, and still have it wide open to the world. Your firewall says closed; reality says open.
Two ways out. The clean one is to never publish a port you don't want public — bind to 127.0.0.1:5432:5432 and Docker only listens locally. The other is to stop Docker editing iptables and manage the rules yourself, which is more control and more rope. For 95% of setups, "bind to localhost unless it's meant to be public" makes the whole problem disappear.
Least privilege: assume something gets popped
Layer zero shrinks what's reachable. This layer limits what a foothold is worth. The mindset shift is assuming a service will eventually be compromised and asking what the attacker inherits when it is.
- Don't run containers as root. A process that breaks out of an app running as UID 1000 has far less to work with than one that breaks out of root. Set a
USERin your images, oruser:in compose. Drop capabilities you don't need withcap_drop: [ALL]and add back only what breaks. - Don't run your app as your login user either. Give each service its own unprivileged system account. A compromised web app shouldn't be able to read your SSH keys or your other projects because it happened to run as
deploy. - Scope credentials tightly. The database user your app connects as does not need
SUPERUSER. The API token in your.envdoes not need account-admin scope because it was easier to click the "full access" box. When something leaks — and env files leak — the blast radius is exactly the permissions you were too lazy to narrow.
None of this stops the initial break-in. That's the point. Layers exist because you assume the layer above failed.
Keep it patched, automatically
Unglamorous and non-negotiable. The gap between a CVE going public and mass exploitation is now measured in hours, not weeks. You are not going to hand-patch fast enough, and you're going to forget, so take yourself out of the loop.
sudo apt install unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgradesBy default this only pulls security updates, which almost never break anything. For containers, the equivalent discipline is rebuilding on updated base images instead of pinning python:3.11-slim from last year and never touching it. A pinned base image feels stable right up until it's the reason a known hole sat open on your box for six months.
Segment so one fire isn't the whole house
Containers aren't a security boundary as strong as separate VMs, but used deliberately they're a real one. Put services on separate Docker networks so your public-facing app can't casually reach your internal database on some unrelated port — if the app is on frontend and the database is only on backend, a popped frontend container can't route to things it was never wired to. Give each stack its own network rather than dumping everything on the default bridge where every container can see every other.
The same logic scales down: separate users, separate networks, separate credentials per service. You're building bulkheads. When one compartment floods, the ship still floats.
And now, fail2ban
After all that, install fail2ban if you like. Here's the honest scope of what it does: it trims the volume of dumb automated login attempts so your logs are quieter and your CPU wastes fewer cycles on rejections. On a key-only SSH box, there are no successful password logins to ban anyway, so as SSH protection it's mostly redundant with the thing that actually protects you (disabling password auth). Where it earns its slot is in front of password-based web logins — a self-hosted app with a login form, a mail server — where it genuinely slows credential stuffing.
That's a fine tool for a narrow job. The mistake is treating it as the job. People install fail2ban, feel protected, and never run ss -tlnp to discover the database they left on 0.0.0.0. The banner said "secured." The reality was a wide-open door with a very attentive bouncer standing next to it, checking IDs of people using the window.
Do the layers in order. Reachability first, privilege second, patching always, segmentation to contain, and noise reduction last. If you only have time for one, it's not fail2ban — it's spending ten minutes reading what your server is actually listening on and closing the ports you never meant to open.