Nothing is port forwarded
The tunnel daemon holds outbound connections to Cloudflare, so traffic arrives without anything listening on the router and the home IP is never published.
/ Case study · Self-hosted infrastructure
A second-hand mini PC in my living room running eight services in Docker, reachable from the internet through an outbound Cloudflare Tunnel and from my own devices through Tailscale, with not one port open on the router. It hosts devilfruittcg.com. It is also where I learned this layer, which mostly meant breaking it and writing down why.
What is not on this page: addresses, tunnel and device identifiers, subdomains of private panels and anything token-shaped. The architecture and the decisions are the interesting part; the coordinates are not, and publishing them would be the opposite of the point.
/ In short
The tunnel daemon holds outbound connections to Cloudflare, so traffic arrives without anything listening on the router and the home IP is never published.
The tunnel is for visitors and carries only the public sites. Tailscale is for me and carries SSH and the admin panels. Neither one can do the other's job by accident.
Every proxy and tunnel route points at a container name. Container IPs change the moment you recreate something; names do not. Two of the nine incidents were exactly this.
The data disk is mounted by filesystem UUID rather than by device path, because adding a second disk can reorder what /dev/sda means.
Every service is a Docker Compose file, and the reasoning lives in a runbook on the machine. A rebuild is a checkout and a compose up, not an archaeology project.
Symptom, cause, fix, lesson. Written at the time, not reconstructed afterwards, because the lesson is the part that survives.
/ 01 · Access
The obvious way to reach a home server from outside is to forward ports on the router. I did not, for three reasons: the fibre line may be behind CGNAT, so it might not even be possible; an open port is a permanent invitation; and splitting public traffic from my own access means a mistake on one side cannot expose the other.
| Cloudflare Tunnel | Tailscale | |
|---|---|---|
| Who it is for | Anyone visiting the public sites | Only my own devices |
| What it carries | The WordPress site and a rank tracker | SSH and every admin panel |
| How it connects | Outbound connections from the server to Cloudflare | A private mesh between authorised devices |
| What it adds | CDN, WAF and DDoS protection in front | Nothing is published at all |
| Router config | None | None |
The rule that made it work: the tunnel container has to be attached to the Docker network of every service it exposes. Miss that and the service answers perfectly on the machine while the public URL returns 502, which is how two of the nine incidents below started.
/ 02 · What runs on it
All of it in Docker, behind a reverse proxy that handles routing and certificates. Public and private are separated at the network level, not by hoping nobody guesses a URL.
| Service | What it does | Reachable from |
|---|---|---|
| WordPress | Runs devilfruittcg.com, the niche site the SEO case study is about. | Public |
| MySQL | Its database. Never exposed, reachable only inside the Docker network. | Internal only |
| Reverse proxy | Routing and TLS certificates for everything behind it. | Private panel |
| Rank tracker | Self-hosted keyword tracking for the niche site, on its own subdomain. | Public |
| Photo server | Personal photo and video library on the 1 TB data disk. | Private |
| Dashboard | One page listing every service and its state. | Private |
| DNS filter | Network-wide DNS and ad filtering for the house. | Private |
| Uptime monitor | Checks each service and sends an alert when one stops answering. | Private |
/ 03 · Incidents
This is the part I would want to read about someone else. Anyone can follow a tutorial until it works. What is worth showing is what happened when it did not, and whether the cause was actually understood or just worked around.
| Symptom | Cause | Lesson |
|---|---|---|
| Server shut down on boot | A SATA connector was not seated properly after fitting the 1 TB data disk. | Instant shutdown seconds after power-on is a connector or a short. Leave the case open and watch the first seconds. |
| 502 on the public site | WordPress and the reverse proxy were on different Docker networks and could not see each other. | Containers from separate compose files are invisible to each other. Declare the other network as external and join it. |
| 502 on the rank tracker | The tunnel daemon was not attached to that service's Docker network, so it could not resolve the container name. | The tunnel container has to sit on the network of every service it exposes. |
| Too many redirects in Safari | Cloudflare was set to Flexible SSL while the proxy forced HTTPS, which loops forever. | Flexible plus forced SSL is a loop. Full plus forced SSL is correct. |
| Certificate request failed | The Cloudflare proxy blocks the Let's Encrypt HTTP challenge. | With the orange cloud on, always use the DNS challenge, with a scoped API token. |
| Database connection error | A placeholder password was left in the compose file, and MySQL had already stored the first one it booted with. | Changing the password in compose does not change it in the volume. Recreate the volume, and only while it is safe to. |
| Admin panel vanished | The DNS filter switches its internal port once its setup wizard finishes. | A panel that disappears exactly when setup ends usually moved port. Read the logs before re-creating anything. |
| Host validation failed | The dashboard now requires its allowed hosts to be declared explicitly. | A host or origin error on first access is a security check, not a bug. Declare the addresses you actually use. |
| Database file unopenable | A bind-mounted folder was created by root, so the container could not write to it. | A container that cannot write to its volume is almost always a permissions problem on the host folder. |
/ 04 · What it proves
| Claim | Evidence |
|---|---|
| The layer under the CMS | Operating system, disks and fstab, Docker networking, reverse proxy, TLS, DNS and remote access. Not a managed panel pretending to be infrastructure. |
| Security by architecture | No forwarded ports, public and private traffic on separate paths, admin panels never published, database never exposed. |
| Diagnosis, not workarounds | Nine incidents with the cause identified in each. The 502s were Docker networking, the redirect loop was an SSL mode, the write failure was host permissions. |
| It is documented | A runbook on the machine and a linked vault of notes, which is why this page could be written from records rather than memory. |
| It is real | devilfruittcg.com is served from it, in public, with its own case study. |
Still open: confirming whether the line is behind CGNAT, moving an old photo copy off the system SSD, and finishing the search console connection on the rank tracker. A homelab with no open items is a homelab nobody is using.
/ Next
This is the layer I went looking for when design stopped being enough, and the one I want to keep working in. If you need someone who owns the machine as well as the site, say hi.