Security Checklist for a Self-Hosted Stack on One VPS
A practical hardening guide for a self-hosted stack on one VPS: the Docker and UFW trap, SSH keys, automatic updates, Fail2ban, Caddy TLS, and a one-afternoon checklist.

If your self-hosted stack runs in Docker on a VPS, your firewall is probably not doing what you think it is. Docker routes traffic to published container ports around UFW before your rules ever see it, so a Postgres or Redis port you meant to keep private can be open to the whole internet. The fix is not one tool but a short list of layered controls, and this guide walks through them in the order that matters.
This is written for the common setup: one VPS running Docker Compose with apps like Twenty, Chatwoot, Cal.com, Plausible, Listmonk, or Formbricks behind a reverse proxy. Everything here uses free, open-source tools.
Find out what the internet can actually reach
Before changing anything, audit your real exposure. On the server:
sudo ss -tlnp
docker ps --format "table {{.Names}}\t{{.Ports}}"
Then, from a different machine, scan the server from outside:
nmap -Pn -p- --min-rate 2000 your.server.ip
The ideal result is short: ports 22 (SSH), 80, and 443. If you see 5432 (Postgres), 6379 (Redis), 9000, 3000, or 5678 answering from the internet, you have the most common self-hosting security bug: container ports published to the world. The next section explains why your firewall did not stop it.
The Docker and UFW trap
Docker manages its own iptables rules, and they take a different path through the kernel than the path UFW controls. Docker's own documentation says it plainly: "Docker and ufw use firewall rules in ways that make them incompatible with each other." Traffic to published ports is routed in the nat table, diverted before it reaches the INPUT chain where UFW's deny rules live (Docker docs on packet filtering and firewalls). The OWASP Docker Security Cheat Sheet calls out the same problem: publishing with -p 8000:8000 opens the port on all interfaces to all source addresses, regardless of UFW.
You have three workable fixes:
- Publish nothing except the proxy. In your Compose files, only the reverse proxy gets a
ports:section (80 and 443). Every other container usesexpose:or nothing, and is reachable only on the internal Docker network. This is the cleanest fix and costs nothing. - Bind published ports to localhost. Write
"127.0.0.1:8000:8000"instead of"8000:8000"for anything you need on the host but not on the internet, then reach it through the proxy or an SSH tunnel. - Install ufw-docker. The ufw-docker project (6,800+ GitHub stars) appends rules to
/etc/ufw/after.rulesthat hook UFW into Docker's DOCKER-USER chain. Aftersudo ufw-docker install, commands likeufw route allow proto tcp from any to any port 80work as expected. Note the syntax uses the container port, not the host port.
One fix to avoid: setting "iptables": false in /etc/docker/daemon.json. Docker's documentation warns this breaks container networking, including masquerading that lets containers reach the internet at all. Patch the interaction; do not amputate it.
SSH: keys only, no root, no passwords
SSH is how you administer the box and how bots try to own it. Ubuntu's OpenSSH server documentation recommends putting your overrides in /etc/ssh/sshd_config.d/ as snippets rather than editing the main file, because the first-set value wins and snippets load first. Create /etc/ssh/sshd_config.d/60-hardening.conf:
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
MaxAuthTries 3
Before reloading, run sudo sshd -t to validate the config, and keep your current session open in a second terminal until you have confirmed a new key-based login works. Locking yourself out of a remote VPS means a provider console session or a rebuild.
Changing the SSH port away from 22 cuts log noise but is not a security control. Skip it unless the noise bothers you.
Patch the host automatically
Containers share the host kernel, so a vulnerable host kernel means vulnerable containers. The OWASP cheat sheet's first rule is to keep both the host and Docker Engine updated, pointing at container-escape bugs like Dirty COW where a kernel exploit from inside a container still yields host root.
On Ubuntu, enable automatic security updates with unattended-upgrades:
sudo apt install unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades
This applies security patches on a timer. Kernel updates still need a reboot to take effect; schedule a short maintenance window monthly rather than letting the uptime counter become a source of pride. A server with 400 days of uptime is a server running a year-old kernel.
Slow down brute force: Fail2ban, and when CrowdSec is worth it
Fail2ban (18,800+ GitHub stars) scans log files such as /var/log/auth.log and temporarily bans IP addresses that pile up failed logins, by inserting firewall rules. A minimal jail for SSH:
[sshd]
enabled = true
maxretry = 3
findtime = 10m
bantime = 1h
Be honest about what this buys. Fail2ban's own README states it reduces the rate of failed logins but cannot eliminate the risk of weak authentication. With password authentication already disabled, Fail2ban is a log-hygiene and resource-saving measure, not your perimeter. It is still worth the ten minutes.
CrowdSec is the step up: an open-source engine that detects attacks from logs and shares a community blocklist of IPs currently attacking other servers, so you block known-bad actors before they knock. The engine and community blocklist are free; premium blocklists and console features are paid. For a single VPS running a business stack, Fail2ban is enough. If you operate several servers, or want to push the same blocklist into your firewall and reverse proxy, CrowdSec earns its keep.
Put one reverse proxy in front of everything
Every public app should sit behind a single reverse proxy that terminates TLS. Caddy is the low-maintenance choice: it provisions and renews certificates from Let's Encrypt or ZeroSSL automatically and redirects HTTP to HTTPS with no extra configuration. A Caddyfile for two apps looks like this:
crm.example.com {
reverse_proxy twenty:3000
}
support.example.com {
reverse_proxy chatwoot:3000
}
Two rules make this architecture work:
- Only the Caddy container publishes ports (80 and 443). Nothing else gets a
ports:entry. - Apps listen only on the internal Docker network. Verify with the nmap scan from the first section after every change.
This pattern also shrinks your patching surface: the internet-facing TLS stack is one well-maintained component instead of five app servers each doing HTTPS their own way.
Container hygiene that actually matters
Four rules from the OWASP cheat sheet cover most of the real-world damage:
- Never mount the Docker socket into a container.
/var/run/docker.sockis root on the host. OWASP is explicit that mounting it read-only changes nothing, because the API itself grants full control. The common offender here is auto-update tooling such as Watchtower; if you run it, understand you have handed it host root, and scope it accordingly. - Never run with
--privileged. It grants the container every Linux kernel capability. - Drop capabilities by default. Start from
--cap-drop alland add back only what an app needs. Most web apps need nothing beyond the defaults, and many need less. - Set
no-new-privileges.--security-opt=no-new-privilegesstops setuid binaries inside the container from escalating.
Also keep secrets in a .env file with mode 600, referenced by Compose, not committed to git and not hardcoded in docker-compose.yml. Check with ls -la .env.
Scan images for CVEs and audit the host
Two free tools close the loop on the question every checklist avoids: how do you know what is actually vulnerable right now?
- Trivy scans container images for known vulnerabilities, misconfigurations, and accidentally committed secrets. Point it at the exact images you run:
trivy image --severity HIGH,CRITICAL chatwoot/chatwoot:latest. A weekly cron run over your pinned images turns "are we exposed to that CVE in the news" from a guess into a lookup. - Lynis (16,500+ GitHub stars, GPL-3.0) is an agentless host auditor:
sudo lynis audit systemchecks the OS configuration and produces a hardening index plus a list of specific suggestions, and it is built to support compliance testing for frameworks like ISO 27001 and PCI DSS. Run it after your first hardening pass and again quarterly.
Expect noise. Scanners flag CVEs in code paths your deployment never executes, and a hardening auditor will always find one more thing. Act on critical findings in components that are actually reachable, and let the rest go into a backlog rather than a panic.
Keep admin surfaces off the public internet
Some things should never be reachable from the internet at all: your monitoring dashboard, database admin tools, workflow editors like the n8n UI, and ideally SSH itself. The practical way to do this without babysitting VPN configs is an overlay network. Tailscale's hardening guidance centers on exactly this: put services on the tailnet, enforce least-privilege access control policies per user and device, and require MFA through your identity provider.
Once SSH only accepts connections on the Tailscale interface, you can close port 22 to the public internet entirely, and your brute-force problem mostly evaporates. For browser-based admin tools used by a wider team, layer single sign-on in front instead; we covered that pattern in Single Sign-On for a Self-Hosted Stack With authentik.
A worked example: one VPS, four apps, one afternoon
Take a representative setup for this guide: a four-app deployment with Twenty (CRM), Chatwoot (support inbox), Plausible (analytics), and Listmonk (newsletter) on a single 4 vCPU VPS, roughly $24 a month, all in Docker Compose, installed over a year by following each project's quickstart. A quickstart-driven install like this typically has the same handful of exposures, so treat the audit below as a composite of the usual findings.
The audit finds:
- Port 3000 open to the internet (Twenty, published directly "temporarily" during setup)
- Port 8025 open (Listmonk's Mailpit test SMTP server, shipped in the example Compose file)
- Password authentication still enabled on SSH
- No automatic updates configured; kernel 11 months old
- A Watchtower container holding the Docker socket, updating images nightly
The fixes, in order:
- Remove every
ports:entry except Caddy's; route the four apps through Caddy subdomains. Ports open to the internet drop from six to three (22, 80, 443). - Add the SSH hardening snippet, confirm key login, disable passwords.
- Enable unattended-upgrades and reboot into the current kernel.
- Install Fail2ban for the SSH jail; skip CrowdSec for a single box.
- Replace Watchtower with a weekly
docker compose pull && docker compose up -dfrom a cron job, removing the socket mount entirely. Nightly auto-updates were also the source of two past outages; pin versions and upgrade deliberately, following a tested procedure like the one in How to Upgrade a Self-Hosted Stack Without Breaking Production. - Move SSH onto Tailscale and close port 22 publicly. Ports open: 80 and 443 only.
Total software cost: $0. Elapsed time: about three hours including the before-and-after nmap scans. The remaining work is ongoing: a weekly Trivy run over the pinned images, a quarterly Lynis audit, and keeping backups that you have proved restore, because hardening reduces the chance of a bad day and backups decide how bad the bad day gets. If Listmonk carries your newsletter, deliverability is its own discipline; SPF, DKIM, DMARC, and IP warm-up are covered in a separate guide.
Common mistakes
- Trusting
ufw status. It reports your rules faithfully; it does not tell you Docker is routing around them. Only an external port scan tells the truth. - Publishing database ports "just for a migration." Temporary port openings have a way of becoming permanent. Use an SSH tunnel:
ssh -L 5432:localhost:5432 user@server. - Mounting docker.sock for convenience. Auto-updaters, dashboards, and backup tools all ask for it. Each one is root on your host with a network connection.
- Leaving example Compose files untouched. Quickstart files ship with admin panels, debug ports, and test SMTP servers published. Read every
ports:line before first boot. - Relying on Fail2ban with password auth still on. The tool itself tells you this does not work. Keys first, then Fail2ban.
- Never rebooting. Unattended-upgrades cannot load a new kernel into a running system. Schedule the reboot.
- Hardening once and walking away. Hardening is a state, not an event. Re-run the external scan after every Compose change.
When DIY hardening is the wrong choice
This checklist is the right scope for a small team running its own tools. It is not the right scope when:
- You handle regulated data. Banks, insurers, and law firms usually need documented controls, audit trails, and something closer to a CIS Benchmark level of prescription than a blog checklist. The CIS Benchmarks are the consensus reference for this, and they are far more exhaustive.
- Nobody owns the server. Hardening without an owner decays. If no one on your team will re-run the audit quarterly and read the security bulletins, the honest answer is a managed service, not a better checklist.
- Downtime or breach costs dwarf the management fee. If the CRM and support inbox going dark for a day costs more than a year of management, you are self-insuring a risk you should transfer.
If any of those describe you, our managed open-source stack service exists precisely for this: we deploy, harden, patch, monitor, and back up the stack, and you keep the data ownership that made self-hosting attractive.
The one-afternoon checklist
Do these in order. Each step takes 15 to 30 minutes.
- Audit. Run
ss -tlnpanddocker pson the server, then nmap from outside. Write down every open port. - Fix the firewall trap. Remove
ports:from everything except your reverse proxy, or bind to127.0.0.1, or install ufw-docker. Re-scan from outside. - Harden SSH. Add the
sshd_config.dsnippet,sshd -t, reload, confirm a new key login works from a second terminal. - Enable unattended-upgrades. Then reboot into a current kernel.
- Install Fail2ban with an SSH jail. Check
fail2ban-client status sshd. - Put Caddy in front. All public apps behind it; TLS automatic; only 80 and 443 published.
- Audit containers. Remove
--privileged, drop capabilities where possible, addno-new-privileges, and unmount any docker.sock you cannot justify. - Lock secrets.
.envat mode 600, out of git. - Move admin surfaces onto Tailscale or behind SSO; close port 22 publicly if you can.
- Scan. Run Trivy against your pinned images and
sudo lynis audit system; fix the reachable criticals. - Set the calendar. Quarterly external re-scan and Lynis run, weekly Trivy scan, monthly reboot window, weekly image updates on your schedule, not a bot's.
Frequently asked questions
Does Docker really ignore UFW rules?
Yes. Docker routes published-port traffic through the nat table before packets reach the INPUT chain that UFW manages, so ufw deny rules do not apply to container ports. Both Docker's official documentation and the OWASP Docker Security Cheat Sheet describe this behaviour. The fixes are to stop publishing ports, bind them to localhost, or install ufw-docker.
Is Fail2ban enough to secure SSH?
No, and its own documentation says so. Fail2ban slows down password guessing; it does not make passwords safe. Disable password authentication and root login first, then use Fail2ban to keep logs clean and block repeat offenders.
Is changing the SSH port worth doing?
It reduces log noise from bots scanning port 22, but any port scanner finds the real SSH port in minutes, so it is not a security boundary. Key-only authentication is the control that matters. Moving SSH behind an overlay network like Tailscale is strictly better than moving the port.
How much does it cost to secure a self-hosted stack?
The tools in this guide are all free and open source: UFW, Fail2ban, Caddy, unattended-upgrades, and Tailscale's free tier for small teams. The real cost is time: roughly half a day for the initial hardening of a single VPS, plus a few hours a month for updates and audits. Managed alternatives typically start at a few hundred dollars a month.
Should I use rootless Docker or Podman instead?
Both reduce the impact of a container escape because the daemon and containers do not run as host root. They are genuinely more secure defaults, but they add operational friction, and some self-hosted apps document only rootful Docker. If you are starting fresh and comfortable troubleshooting, rootless is worth it; if you have a running stack, the controls in this guide get you most of the benefit without a rebuild.
How often should I re-audit the server?
Quarterly at minimum, and after any change to your Compose files. Every new service, migration, or debugging session is a chance for a port to get published again. The external nmap scan takes five minutes and is the only check that sees what an attacker sees.
Where to go from here
Run the external port scan today; it is the single highest-value step on the list and takes five minutes. If the audit turns into a bigger job than your team wants to own, our managed open-source stack covers deployment, hardening, patching, monitoring, and backups for the exact apps in this guide, and if your workflows also depend on automation tooling, our business automation work keeps n8n and friends behind the same controls. Either way, your firewall should mean what you think it means.
About AI Agents Plus Editorial
AI automation expert and thought leader in business transformation through artificial intelligence.



