Infrastructure
A Reverse Proxy Can't Fix an App That Assumes It Owns the Root
Serving several apps under one hostname with path prefixes is clean in theory, but a static site or SPA that bakes its base URL in at build time can't be reliably proxied under a subpath — so give each one its own origin instead.
- Infrastructure
- Networking
- Nginx
- Web
I had a handful of small self-hosted services in my homelab and wanted one tidy
front door: a single hostname, each app living under its own path prefix —
/docs, /dashboard, /status. It’s the obvious shape, the proxy config is
short, and it almost worked. Then one of the apps — a static site generator with
client-side navigation — broke in a way no amount of proxy tuning would fix, and
it taught me a boundary worth knowing before you design the routing: a reverse
proxy can map a request, but it can’t talk an app out of believing it owns the
web root.
The clean idea: one host, three subpaths
The plan was a single proxy routing by path: requests to /docs go to the docs
container, /dashboard to another, and so on. For plain apps that serve relative
links and don’t care where they live, this is fine and lovely. The trouble starts
with anything that has an opinion about its own base address — and modern static
generators and single-page apps almost all do.
Why the proxy can’t rewrite the assumption away
The app that broke was a static site with client-side routing. Two things about how it was built made it un-proxyable under a subpath:
- Its base URL was baked in at build time. The generator decided, when it built, that the site lived at the root. Its internal links, asset references, and navigation were all computed against that assumption and frozen into the output. A proxy rewriting the incoming path does nothing about URLs that were already hardcoded into the HTML and JavaScript that gets shipped.
- Relative assets resolved against the wrong base. Served under
/docs/, a backend trailing-slash redirect nudged the browser’s notion of “current directory,” so the page’s relative asset links overshot — a stylesheet meant for/docs/styles.cssgot requested at/styles.cssand 404’d. Client-side navigation then jumped to root-relative routes the proxy wasn’t expecting, and those 404’d too.
I could patch one symptom and surface another, because I was fighting the app’s build-time worldview from outside. You can rewrite headers and paths at the proxy all day, but you cannot reach back in time and un-bake a base URL that was decided when the site was compiled.
A proxy maps requests. It can’t rewrite an assumption the app already shipped.
Port-per-service: let each app think it owns the root
The fix that actually held was to stop pretending these apps could share an
origin. Instead of one hostname with path prefixes, I gave each service its own
port, proxied at /. Now every app genuinely is at the root of its origin,
exactly the world it was built for. No path rewriting, no asset overshoot, no SPA
navigation landing in the void. A small static landing page links out to each
service, and the apps themselves run unmodified.
It’s slightly less pretty than one-host.example/docs, and it’s a far more honest
match for how the apps actually behave. The ugliness is the truth leaking through:
those apps were never going to live under a subpath.
The general rule, and the cleaner options
The principle generalizes well beyond my little portal: routing can only relocate an app that doesn’t assume where it lives. Before you put something behind a subpath, ask whether it builds with a configurable base path. If it does, set it and you’re fine. If its base is hardcoded to root, a subpath will betray you, and your real choices are:
- A subdomain per app — the cleanest separation; each app owns an origin and a certificate, and nothing collides. My first pick when DNS allows it.
- A port per app — same “owns the root” property without needing new DNS records. What I landed on, and a fine answer for a homelab.
- A subpath — only when the app explicitly supports a configurable base path. Otherwise you’re signing up to fight its build output forever.
What I check now before routing anything
When I add a service behind the proxy, the first question isn’t “what path?” — it’s “does this app know it might not be at the root?” That one question sorts apps into the easy pile and the give-it-its-own-origin pile before I write a line of proxy config. It’s the same flavor of lesson as the rest of running your own stack: the tool in front rarely fixes a decision baked in further back. More of those in my private cloud notes, and the related art of reaching services without opening ports. If you’ve wrestled a stubborn SPA behind a proxy, trade notes with me.