/ Case study · Self-hosted infrastructure

hacheserver

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.

Machine
HP EliteDesk 800 G4 Mini, bought used
CPU and RAM
Intel i5-8500T, 6 cores, 16 GB
OS
Ubuntu Server 26.04 LTS
Draw
65 W
Public access
Cloudflare Tunnel
Private access
Tailscale

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.

How it is put together

8services running
0ports open on the router
2separate access paths
65 Wtotal draw
9incidents written up

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.

Two doors, different jobs

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.

Container names, not IPs

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.

Mounted by UUID

The data disk is mounted by filesystem UUID rather than by device path, because adding a second disk can reorder what /dev/sda means.

Rebuildable from files

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.

Nine incidents, written up

Symptom, cause, fix, lesson. Written at the time, not reconstructed afterwards, because the lesson is the part that survives.

Two doors instead of one hole

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 TunnelTailscale
Who it is forAnyone visiting the public sitesOnly my own devices
What it carriesThe WordPress site and a rank trackerSSH and every admin panel
How it connectsOutbound connections from the server to CloudflareA private mesh between authorised devices
What it addsCDN, WAF and DDoS protection in frontNothing is published at all
Router configNoneNone

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.

Eight services, one mini PC

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.

ServiceWhat it doesReachable from
WordPressRuns devilfruittcg.com, the niche site the SEO case study is about.Public
MySQLIts database. Never exposed, reachable only inside the Docker network.Internal only
Reverse proxyRouting and TLS certificates for everything behind it.Private panel
Rank trackerSelf-hosted keyword tracking for the niche site, on its own subdomain.Public
Photo serverPersonal photo and video library on the 1 TB data disk.Private
DashboardOne page listing every service and its state.Private
DNS filterNetwork-wide DNS and ad filtering for the house.Private
Uptime monitorChecks each service and sends an alert when one stops answering.Private

Nine things that broke

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.

SymptomCauseLesson
Server shut down on bootA 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 siteWordPress 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 trackerThe 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 SafariCloudflare 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 failedThe 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 errorA 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 vanishedThe 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 failedThe 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 unopenableA 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.

Why a home server belongs in a portfolio

ClaimEvidence
The layer under the CMSOperating system, disks and fstab, Docker networking, reverse proxy, TLS, DNS and remote access. Not a managed panel pretending to be infrastructure.
Security by architectureNo forwarded ports, public and private traffic on separate paths, admin panels never published, database never exposed.
Diagnosis, not workaroundsNine 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 documentedA 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 realdevilfruittcg.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

Everything connects.

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.