Foreword
I’ve been running a NAS and Proxmox nodes at home for years. Two hypervisors, a bunch of VMs, plenty of idle compute just sitting there. Meanwhile I was paying monthly for shared hosting to run a couple WordPress sites. At some point it hit me: why am I paying for hosting when I have a literal datacenter in my closet?
The usual answer is “because self-hosting from home is a pain in the ass.” You have to open ports on your router, deal with dynamic IPs, figure out SSL certs, and hope your ISP doesn’t block inbound traffic. I’ve spent years building a nicely locked-down pfSense firewall setup, and poking holes in it for web traffic felt wrong.
Then I found Cloudflare Tunnels and the whole problem went away.
The idea
Instead of opening inbound ports, you run a little daemon called cloudflared that creates an outbound connection from your server to Cloudflare’s edge. Traffic flows: Internet -> Cloudflare -> tunnel -> your server. No inbound ports needed. No static IP needed. No dynamic DNS needed. And it’s free.
Your firewall stays completely closed to inbound traffic. The server reaches out to Cloudflare, not the other way around. That’s the whole trick.
My setup
The request path looks like this:
Internet -> Cloudflare -> cloudflared tunnel -> Nginx Proxy Manager -> WordPress container
Each piece:
Docker on Proxmox. I run Docker in a dedicated VM (not an LXC container) for security isolation. If a container gets compromised, the attacker hits the VM boundary before they can touch the hypervisor. Each website gets its own Docker container running WordPress + MariaDB, managed through Portainer.
Nginx Proxy Manager (NPM) sits in front of all the containers as a reverse proxy. It routes requests by hostname to the right container and handles SSL termination. The containers themselves only serve HTTP internally; NPM handles the HTTPS part.
cloudflared runs as a systemd service, maintaining the outbound tunnel to Cloudflare. I point Cloudflare DNS at the tunnel, and traffic flows down to NPM, which routes it to the right container.
Locking it down
Here’s where the infra nerd in me gets excited. The Docker VM sits in its own DMZ VLAN on pfSense, completely isolated from the rest of my network.
Outbound rules are whitelist-only:
- Port 7844 (Cloudflare tunnel)
- Port 443 (HTTPS: tunnel connection, WordPress updates, Docker Hub pulls)
- Port 80 (HTTP: apt updates, Let’s Encrypt)
- Port 53 (DNS to pfSense only)
That’s it. Everything else is blocked.
Inbound rules hard-block the DMZ from talking to any other VLAN. If a container gets popped, the attacker can talk to Cloudflare and… that’s it. They can’t pivot to my storage VLAN, my management network, my IoT devices, nothing. Lateral movement is dead.
So the security stack is: no inbound ports + VLAN isolation + VM boundary between containers and hypervisor. I sleep pretty well.
The WordPress admin problem
WordPress login pages are bot magnets. Even behind Cloudflare, bots will hammer wp-login.php all day. Solution: Cloudflare Access (free on the Zero Trust tier).
I put an access policy in front of the admin paths. Before you even see the WordPress login page, Cloudflare asks for your email. If you’re on the whitelist, you get a one-time PIN. If you’re not, you get a 403. Bots never even reach WordPress.
This is genuinely one of my favorite security patterns. The login page doesn’t exist on the public internet anymore. You have to authenticate with Cloudflare before the request even reaches my server.
Stuff I learned the hard way
Because there’s always a “stuff I learned the hard way” section:
- WordPress redirect loops. If
siteurlandhomeinwp_optionsdon’t match your actual URL scheme, you get infinite redirect loops. This will make you question your life choices. - Cloudflare SSL mode matters. Use “Full (strict)” or you’ll get redirect loops. Cloudflare <-> NPM needs to be HTTPS; NPM <-> container is HTTP. If the SSL mode is wrong, the whole chain breaks.
- Cloudflare caches aggressively. Published a blog post and don’t see it? Purge the cache. I built a cache purge button into breadtools specifically because of this.
- Run cloudflared as a systemd service. If it’s just running in a terminal session and your SSH drops, so does your tunnel. And your websites. Ask me how I know.
Adding new sites
The beauty of this setup is that adding a new site is like two steps:
- Add a proxy host in NPM (hostname -> container IP:port)
- Add a tunnel route in Cloudflare (hostname -> NPM)
Cloudflare handles the DNS automatically via CNAME to the tunnel. SSL is automatic. No ports to open, no certs to manage, no DNS records to babysit. I actually built a tool in breadtools that automates the NPM + Pi-hole side of this too.
What I’m saving
The shared hosting I was paying for? Gone. Cloudflare provides SSL termination, DDoS protection, CDN caching, WAF, and bot protection, all free on their free tier. The tunnel is free. The only cost is electricity for containers that were going to be running anyway.
If you’re already running a homelab with spare compute, there’s really no reason to keep paying for hosting. The setup took an afternoon and it’s been rock solid since.