Every so often, I stop and ask myself, "what the heck was I thinking?" This was one of those times. I spent several days debugging my self-hosted Ghost deployment (the very site you’re on right now) after discovering that parts of the subscribe and analytics flow were failing silently. After plenty of trial, error, and staring at logs at unreasonable hours, I finally found the culprit and got everything working again.
I’d built a tangled web of components that made perfect sense in theory, but in practice turned out to be the source of my issues. No matter how often you tell yourself, “my design is solid,” I’m reminded of an old mentor’s words "there’s always a better way to do it."
The original design was simple: self-host Ghost in Docker at home, use Cloudflare Tunnel as the secure public entry point, send tunnel traffic to Nginx Proxy Manager (NPM), and then forward requests from NPM to the Ghost container’s exposed port. The idea was to keep Ghost off direct internet exposure while still getting clean HTTPS access and centralized routing through Cloudflare and NPM.
Let me break it down for you
- Cloudflare: Public edge platform that handles your domain, TLS, and routes traffic into your private tunnel.
- Cloudflared: Tunnel connector daemon that maintains outbound-only links from your homelab to Cloudflare and forwards requests to internal services.
- Nginx Proxy Manager: GUI-driven reverse proxy layer for mapping hostnames/paths to backend containers with SSL management.
- Ghost: The publishing application (CMS/blog platform) serving your site content, admin portal, memberships, and stats APIs.
It wasn't inherently bad, but in the end I learned there were too many hops, split routing logic, path sensitive features (like Ghost analytics) needed exact path handling, multi proxy mismatches, local DNS overrides bypassing Cloudflare/tunnel behaviors...basically I'm a mess.
Remind me of the goal again?
- Self-hosted Ghost in Docker (
ghost,db,traffic-analytics) - Cloudflare Tunnel (
cloudflared) as ingress - Domain:
oberbean.com
Symptoms
- Ghost site loaded, but related subscribe/analytics didn't work properly over the internet.
- Tracker endpoints returned
404when Ghost attempted to send page hit events. - Cloudflared logs showed origin resolution failures.
Architecture Change
Originally, I had Nginx Proxy Manager in the middle of the request path.
During troubleshooting, I simplified the setup by removing Nginx Proxy Manager from the Ghost path and routing Cloudflare Tunnel directly to Docker services over shared Docker networks.
Before
Cloudflare -> cloudflared -> Nginx Proxy Manager -> Ghost
After
Cloudflare -> cloudflared -> Ghost container
Cloudflare -> cloudflared -> traffic-analytics container
This reduced moving parts and made path-based routing behavior much easier.
Root Causes
1. cloudflared was not attached to Ghost’s Docker network
Cloudflared could not resolve the Ghost container hostname, causing origin errors like DNS lookup failures.
2. Tunnel ingress rules were incomplete/mismatched for analytics paths
Ghost and traffic analytics were not aligned on the same public path routing, so requests landed on the wrong service and returned 404.
3. Local DNS override bypassed Cloudflare Tunnel
I had a local DNS entry for oberbean.com in my home network.
That caused some traffic to skip Cloudflare edge/tunnel behavior entirely, so rule changes appeared inconsistent.
Fixes Applied
1. Docker networking fix
Attached cloudflared to the external Ghost network so it could resolve and reach Ghost/analytics containers directly.
2. Ingress path routing fix in Cloudflare Tunnel
Added explicit path-based rules for analytics endpoints and validated rule order/format:
/.ghost/analytics/*/api/v1/*
3. Endpoint alignment in Ghost config
Updated Ghost tracker endpoint to match the ingress strategy.
4. DNS consistency fix
Removed the local DNS override for oberbean.com so all traffic consistently traverses Cloudflare Tunnel.
Validation
- Cloudflared logs showed updated ingress config versions with correct hostname/path mappings.
- Endpoint tests moved from
404(wrong route) to service-level validation responses (400for bad payload), confirming correct backend routing. - Ghost stopped logging analytics requests as
404on its own app routes.
Lessons Learned
- In tunneled container setups, Docker network attachment is non-negotiable.
- Path-based ingress rules must match app endpoints exactly, including ordering and leading slash formatting.
- Local DNS overrides can silently bypass cloud ingress and make troubleshooting misleading.
- Simplifying the architecture (removing unnecessary reverse-proxy hops) can dramatically reduce troubleshooting complexity.
- A response change from
404to validation errors can be a strong signal that routing is finally correct.
In the end, this turned out to be a great learning experience. Building the system was only half the value. The real learning came from stepping back and writing out the architecture, the failure points, a plan to fix them, and then validating each change along the way. I’ve found that forcing myself to document structure and problems makes gaps in understanding obvious and turns frustration into something actionable. If nothing else, this was a good reminder that writing things down is often the fastest way to truly understand what you’ve built.