Built-in Reverse Proxy

July 2, 2026 · View on GitHub

What It Does

When proxy: true is set on an app, Muximux proxies all requests to that app through /proxy/{app-slug}/ on the same port Muximux is running on. The slug is derived from the app name: lowercased, with spaces replaced by hyphens.

For example, an app named "My Sonarr" would be proxied at /proxy/my-sonarr/.

All requests to that path are forwarded to the app's configured url, and the responses are rewritten so the app works correctly at its new location.

Note: This per-app reverse proxy is built into the Go server and works in every deployment mode -- whether Muximux is behind Traefik, running standalone, or acting as a full reverse proxy appliance with Caddy. It is completely independent of Caddy and requires no extra configuration beyond proxy: true.


Why It Exists

Most web applications set security headers that prevent them from being loaded inside iframes:

  • X-Frame-Options: DENY or SAMEORIGIN
  • Content-Security-Policy: frame-ancestors 'none'

Since Muximux's primary interface loads apps in iframes, these headers cause the app to show a blank frame or a "refused to connect" error. The reverse proxy strips these headers so the app can be embedded.

Beyond header stripping, the proxy also rewrites paths throughout the response so that the app's internal links, asset references, and API calls continue to work from the new /proxy/{slug}/ base path.


What It Rewrites

The proxy performs several layers of rewriting to make apps work at their new path.

HTTP Headers

Response headers:

  • Strips X-Frame-Options, Content-Security-Policy, and Permissions-Policy (allows iframe embedding and unrestricted feature access)
  • Rewrites Location, Content-Location, and Refresh redirect headers to the proxy path
  • Rewrites Set-Cookie attributes: Path → proxy prefix, Domain stripped (defaults to proxy host), Secure stripped when frontend is HTTP, SameSite=StrictLax (too restrictive through proxy)
  • Rewrites Link header URIs (e.g., preload hints)
  • Strips ETag and Last-Modified on responses whose body was rewritten (prevents stale cache hits)

Request headers:

  • Strips the Origin header entirely (a server-to-server request has no meaningful browser origin, and backends with no CORS config reject any request bearing an Origin); CSRF protection for frameworks that check Origin is instead covered by the rewritten Referer
  • Rewrites Referer to match the backend's scheme and host, preventing CSRF rejections
  • Sets Host to the backend host
  • Forwards X-Forwarded-For, X-Forwarded-Host, X-Forwarded-Proto, and X-Real-IP

HTML Content

  • Rewrites href, src, action, poster, srcset, content, and data-* attributes
  • Rewrites <base href> tags to point to the proxy path; injects a <base> tag when the document has none, so apps using relative resource URLs (e.g. qBittorrent's css/style.css) resolve against the proxy prefix
  • Strips <meta http-equiv="Content-Security-Policy"> tags so the injected interceptor script is not blocked by nonce requirements

CSS Content

  • Rewrites url() references to point to the proxy path

JavaScript and JSON/XML Content

  • Strips SRI integrity checks (which break when content is modified)
  • Rewrites absolute URLs pointing to the backend server
  • Rewrites base path configuration variables (e.g., urlBase: "" becomes urlBase: "/proxy/sonarr")
  • Root-relative paths in JS/JSON/XML are not statically rewritten - the runtime interceptor (see below) handles these to avoid corrupting URLs meant for third-party servers

Runtime URL Interceptor

For single-page applications (SPAs) that build URLs dynamically in JavaScript, static text rewriting is not enough. The proxy injects a small script into every HTML response that intercepts URL usage at runtime:

  • fetch(), XMLHttpRequest, sendBeacon() - API calls are rewritten before they leave the browser
  • WebSocket, EventSource - Real-time connections are routed through the proxy
  • DOM property setters - img.src, script.src, source.src, media.src, video.poster, iframe.src, link.href, a.href, base.href, form.action, object.data, button.formAction, input.formAction - the browser's internal setter only ever sees the rewritten URL
  • img.srcset - Custom setter parses comma-separated url descriptor pairs and rewrites each URL
  • setAttribute() - Wrapped so libraries using el.setAttribute('src', url) get synchronous URL rewriting
  • CSSStyleSheet.insertRule() - Rewrites url() references in CSS-in-JS rules (styled-components, emotion)
  • insertAdjacentHTML() - Synchronously fixes URLs in newly inserted HTML fragments
  • history.pushState/replaceState - Adds proxy prefix so frame reload hits the correct URL
  • location.pathname/href - Getters transparently strip the proxy prefix so SPA routers see clean paths
  • location.assign/replace, window.open - Wrapped to route through the proxy
  • Worker, SharedWorker, Audio constructors - Script/source URLs routed through the proxy
  • MutationObserver fallback - Catches elements created via innerHTML or HTML parsing where property setters don't fire
  • window.parent and window.top isolation - Overridden to point back to the iframe's own window, so the app behaves as if it is running in a standalone browser tab. Smart detection keeps window.parent intact for sub-iframes within the same proxied app (e.g., qBittorrent's MochaUI dialogs).
  • localStorage / sessionStorage isolation - Namespaced per proxied app so multiple apps sharing the Muximux origin don't collide on storage keys
  • Service worker blocking - register() is no-op'd to prevent proxied apps from registering service workers under the Muximux origin; existing proxy-scoped registrations are unregistered

This means apps like Plex, which construct all their image and API URLs in JavaScript at runtime, work through the proxy without needing any configuration in the app itself.

SRI (Subresource Integrity)

  • Strips integrity attributes from HTML tags, since hashes become invalid after the content has been rewritten
  • Neutralizes dynamic SRI checks in JavaScript

Gzip Handling

Compressed (gzipped) responses are transparently decompressed before rewriting. The rewritten response is sent uncompressed; if Muximux sits behind an external reverse proxy (nginx, Traefik, Caddy), that proxy can apply its own compression to the final response. No configuration is needed.


When to Use It

Per-App Proxy Settings

When proxy: true, you can fine-tune the proxy behavior per app:

apps:
  - name: Sonarr
    url: https://sonarr.internal:8989
    proxy: true
    proxy_skip_tls_verify: true      # Skip TLS cert verification (default: true)
    proxy_headers:                    # Custom headers sent to the backend
      X-Api-Key: "your-api-key"
      Authorization: "Bearer token"
SettingDefaultDescription
proxy_skip_tls_verifytrueWhen the backend uses HTTPS with a self-signed or internal CA certificate, this skips verification. Set to false if you want strict TLS validation.
proxy_headers(none)Key-value map of headers added to every request forwarded to the backend. Useful for API keys or auth tokens the app requires.

Forwarding the signed-in user's identity

A proxy_headers value can reference the authenticated Muximux user, so the backend can do header-based ("proxy") auth without its own login. Use any of these variables in a value:

VariableExpands to
${user}Username
${role}admin / power-user / user
${email}Email (if known)
${display_name}Display name
${groups}Group memberships, comma-separated
proxy_headers:
  X-Forwarded-User: "${user}"
  X-Forwarded-Groups: "${groups}"

The values are resolved per request from the current session and stripped of any CR/LF/NUL, so a crafted identity claim can't inject a header. On an unauthenticated instance (auth.method: none) the variables expand to empty. Static values (API keys, bearer tokens) are sent verbatim -- only values containing ${...} are templated.

Security: these headers assert who the user is. The backend must accept them only from Muximux -- bind the backend to Muximux's network / an internal address so a client can't send X-Forwarded-User directly. This is the same trust model as any reverse-proxy header auth.

The global server.proxy_timeout (default: 30s) controls how long the proxy waits for a backend response before timing out. This applies to all proxied apps.

When to Use It

Enable proxy: true when:

  • The app refuses to load in an iframe. You see a blank frame, a "refused to connect" error, or a message saying the page cannot be displayed in a frame.
  • The app loads in the iframe but assets (CSS, JS, images) fail because their paths do not resolve correctly.
  • The app loads and looks correct but navigation and links break because they point to the original path instead of the proxy path.

When NOT to Use It

Leave proxy: false (or omit it) when:

  • The app already works fine in an iframe without proxy. Some apps allow embedding by default and do not need any rewriting.
  • You are using open_mode: new_tab, new_window, or redirect. The proxy is only useful for iframe mode, since the other modes open the app at its original URL.
  • You want to reduce overhead. The proxy adds a small amount of latency due to the rewriting step, so skip it if it is not needed.

Why Some Apps May Not Work

Even with the proxy and runtime interceptor enabled, some applications may not work correctly in an iframe. The most common reasons are:

  • Binary protocols -- gRPC, MessagePack, and other non-text formats with embedded paths cannot be rewritten.
  • Runtime-constructed paths -- Template literals (`${base}/api`) and string concatenation ('/api' + endpoint) are invisible to any rewriter.
  • SPA routing conflicts -- Some SPAs may not recognize the /proxy/{slug}/ prefix in their client-side router if they hardcode routes rather than using a configurable base path. The runtime interceptor patches location.pathname to strip the prefix, which handles most cases.

Note: Many patterns that were previously problematic are now handled automatically: Origin/Referer header validation, cookie scoping, service workers, history.pushState/replaceState, location.pathname, Worker/SharedWorker, and localStorage/sessionStorage isolation.

For detailed explanations of each limitation, symptoms, and workarounds, see the Troubleshooting page.

WebSocket Connections

WebSocket connections are fully supported. The proxy detects Upgrade: websocket requests and transparently proxies them by establishing a direct TCP connection to the backend. Path rewriting is applied to the initial HTTP upgrade request, then data flows bidirectionally without modification. Apps using WebSockets for live updates, logs, or chat should work through the proxy without additional configuration.

Mixed Content

If Muximux is served over HTTPS but the proxied app is HTTP-only on the internal network, this is handled transparently by the proxy (the browser talks HTTPS to Muximux, and Muximux talks HTTP to the app). However, if the app's JavaScript makes direct HTTP requests to other internal services, the browser may block those as mixed content.


Troubleshooting Proxy Issues

Blank iframe

Open the browser's developer tools (F12) and check the Console tab for errors. Common causes:

  • The app uses a Content-Security-Policy that was not fully stripped. Look for "refused to frame" or "blocked by Content-Security-Policy" messages.
  • The app's URL is unreachable from the Muximux server. Verify you can reach the URL from the machine running Muximux.

Broken Styles or Images

The path rewriting may have missed some URLs. Try accessing the app directly at /proxy/{slug}/ in a new browser tab (not in the iframe). This lets you use developer tools more easily to see which requests are failing and what paths they are trying to reach.

Login Loops

The app's authentication system may conflict with the proxy. This often happens when the app redirects to a login page using an absolute URL that bypasses the proxy. If the app supports configuring a base URL or external URL, set it to match the proxy path (e.g., /proxy/sonarr).

Intermittent Failures

If the app works sometimes but not others, it may be a timing issue with WebSocket connections or service workers. Check the Network tab in developer tools for failed requests.

If Nothing Works

Set open_mode: new_tab as a fallback:

apps:
  - name: Problematic App
    url: http://app:8080
    proxy: false
    open_mode: new_tab

The app opens in its own browser tab with no proxy involvement. You lose the integrated dashboard experience, but the app will work exactly as it does when accessed directly.


How It Differs from TLS/Gateway (Caddy)

The built-in reverse proxy and the Caddy-based gateway are completely separate systems that serve different purposes.

Built-in Reverse ProxyCaddy Gateway
PurposeEmbed apps in iframesServe Muximux with TLS, or host additional sites alongside it
Configured byproxy: true on individual appsserver.gateway_sites: declarative YAML (or Settings -> Gateway)
Runs insideThe Go server processEmbedded Caddy instance
Works without TLSYesThe gateway is only active when TLS/Caddy is enabled
Rewrites contentYes (headers, HTML, CSS, JS, runtime)No (standard reverse proxy behavior)

The per-app proxy: true setting is for iframe embedding. The gateway_sites: entries serve additional sites alongside Muximux on their own subdomains or handle TLS termination. They can be used independently or together.


Dynamic Route Rebuilds

Proxy routes are rebuilt automatically whenever you save configuration changes (add, edit, or delete an app). You do not need to restart Muximux for proxy changes to take effect. New apps with proxy: true become available immediately, and removed apps stop being proxied right away.


How It Works (Advanced)

This section describes the technical internals for users who want to understand why something works (or doesn't) and how to debug proxy issues.

Three Layers of URL Rewriting

The proxy uses three complementary strategies to ensure URLs work correctly:

Layer 1: Static Rewriting (Server-Side)

When a response passes through the proxy, the Go server rewrites URLs in the response body based on content type:

Content TypeRewriting Strategy
HTMLFull rewriting - attribute paths (href, src, etc.), base tags, SRI stripping, CSP meta tag stripping, and interceptor script injection
CSSFull rewriting - url() references
JS, JSON, XMLSafe-only - SRI stripping, absolute URL rewriting, base path config values. Root-relative paths are left untouched to avoid corrupting API data

The distinction matters: API responses (JSON, XML) contain data that the SPA reads programmatically. If the proxy rewrites paths inside API data (e.g., "/library/metadata/123""/proxy/plex/library/metadata/123"), the SPA may embed those already-rewritten paths in new URLs, causing double-prefixing.

Only text content types are buffered for rewriting. Binary responses (images, videos, archives, file downloads) stream directly from the backend to the browser without being read into memory. Text responses larger than 50 MB are also streamed through without rewriting as a safety measure.

Layer 2: Runtime Interceptor (Client-Side, Synchronous)

A small <script> tag injected into every HTML response patches browser APIs before the app's own JavaScript runs:

CategoryWhat's PatchedHow
API callsfetch(), XMLHttpRequest.open(), sendBeacon()Wrapper rewrites URL argument
Real-timeWebSocket, EventSource constructorsWrapper rewrites URL argument
DOM settersimg.src, script.src, source.src, media.src, video.poster, iframe.src, link.href, a.href, base.href, form.action, object.data, button.formAction, input.formActionProperty setter override on prototype
Special settersimg.srcset, setAttribute(), CSSStyleSheet.insertRule(), insertAdjacentHTML()Custom wrappers for multi-URL and CSS-in-JS
Locationpathname, href getters, assign(), replace(), toString(), document.URL, document.documentURIGetters strip prefix; setters add prefix; Navigation API fallback intercepts location.href assignment when setter is non-configurable (Chrome)
Navigationhistory.pushState/replaceState, window.openWrapper adds proxy prefix; init guard re-strips prefix during initialization when getters can't be patched (Chrome); prefix restored after init so back/forward navigates correctly
ConstructorsWorker, SharedWorker, AudioWrapper rewrites URL argument
Isolationwindow.parent, window.topObject.defineProperty override
StoragelocalStorage, sessionStorageNamespaced proxy with key prefix
Blockednavigator.serviceWorker.register()Returns resolved promise (no-op)

Property setter overrides are synchronous - when the app sets img.src = "/photo/...", the browser's internal setter only ever sees the rewritten URL. This preserves the app's normal event chain (load events, animations, etc.) because the image loads from the correct URL on the first try.

Layer 3: MutationObserver (Client-Side, Fallback)

A MutationObserver watches for new elements added to the DOM and attribute changes on src/poster. This catches elements created via innerHTML, HTML template parsing, or other paths that bypass JavaScript property setters. If a src attribute contains an un-prefixed URL, it's rewritten.

Chrome Iframe Timeline Workaround

Chrome may freeze document.timeline inside iframes, causing CSS and Web Animations API animations to stall. Some apps (notably Plex) use element.animate() for image fade-in effects with fill: "auto", which means the animation's end state is not persisted. When the timeline freezes, images remain stuck at opacity 0 despite being fully loaded.

The interceptor includes a periodic scan (every 200ms for the first 30 seconds) that detects loaded images with style.opacity === "0", cancels any frozen animations, and forces them visible. This self-disables after 30 seconds to avoid unnecessary work on long-lived pages.

Tested Apps

The following apps have been tested with the built-in reverse proxy and runtime interceptor:

AppStatusNotes
PlexWorksFull support including posters, PIN auth, WebSocket, media playback
Sonarr/Radarr/LidarrWorksSet base URL to /proxy/{slug} for best results
OverseerrWorks
TautulliWorksSet URL base in Tautulli settings