Pakkit.net
← Back to blog

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.css got requested at /styles.css and 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.