Skip to main content

Reverse proxy

Overview​

On a LAN you do not need a reverse proxy — open http://<host>:8080 and you are done.

You want one when you need a hostname instead of a port, HTTPS, or one entrypoint in front of several self-hosted apps.

WebSockets are not optional

UltraTorrent's UI is live: torrent progress, engine health, RSS runs, import progress — all pushed over a WebSocket at /ws/. A proxy that does not forward the Upgrade and Connection headers produces a UI that looks fine, loads, lets you log in — and then never updates. Progress bars sit at 0%. This is by far the most common reverse-proxy mistake, and every config below handles it.

Watch this tutorial

Video coming soon.

Prerequisites​

  • A working Docker Compose install.
  • A DNS name pointing at the host (for a public setup).
  • Ports 80/443 free on the host, or a proxy that already owns them.

Requirements​

A reverse proxy is cheap: ~64 MB RAM and negligible CPU for any of the options below. The only real requirement is that it can proxy WebSockets.

Ports​

PortWho listensNotes
80 / 443Your reverse proxyThe only ports that should face the internet
8080 (host)frontend containerBind it to 127.0.0.1 once a proxy fronts it — see Best practices
8080 (container)nginx inside the frontend imageThe container listens on 8080, not 80 — see the note below
4000 (container)backendInternal only; never published
The frontend container listens on 8080, not 80

The image is built on nginx-unprivileged, which runs as uid 101 and therefore cannot bind a privileged port. Inside the Docker network the frontend is http://frontend:8080.

This bites hand-written proxy configs: pointing an upstream at frontend:80 yields a 502 Bad Gateway, because nothing is listening there. Use frontend:8080.

The deploy/Caddyfile shipped with the repo (the proxy profile) routes to frontend:8080 correctly. If you are running a checkout from before that fix and the bundled proxy 502s, that is why — update it, or pull.

Volumes​

Only your proxy's own state (certificates, ACME account). UltraTorrent's volumes are untouched by a proxy.

Permissions​

None specific — but if your proxy binds 80/443, it needs the capability to do so (root, CAP_NET_BIND_SERVICE, or a container that already has it).

Two ways to route​

Option A is what you want. The frontend's own nginx already proxies /api/ and /ws/ to the backend, complete with the WebSocket upgrade headers — so a single upstream gets you a fully working app, and there is exactly one place that can be misconfigured.

Option B (what the bundled Caddyfile does) shaves one hop off API calls. Use it only if you have a reason to; you must then get the /ws/ upgrade headers right yourself.

Every config below uses Option A unless noted.

Step-by-step​

1. Tell UltraTorrent its public origin​

In .env:

CORS_ORIGIN=https://torrents.example.com

Then recreate the backend:

docker compose up -d backend

The SPA calls the API on a relative path (/api), so it is same-origin and CORS is mostly moot — but the value is used for allowed browser origins and should reflect reality. It accepts a comma-separated list.

2. Bind the UI port to localhost​

If the proxy runs on the same host, stop exposing 8080 to your whole LAN:

# docker-compose.override.yml
services:
frontend:
ports: !override
- "127.0.0.1:8080:8080"
Community-verified

!override requires a recent Compose v2. If your version rejects it, edit the ports: entry in docker-compose.yml directly — remember that a plain override file appends ports rather than replacing them.

If the proxy runs in the same Compose project, drop the host mapping entirely and let the proxy reach frontend:8080 over the internal network.

3. Configure the proxy​

Labels on the frontend service, assuming a Traefik with an websecure entrypoint and a letsencrypt resolver already running on a shared network:

# docker-compose.override.yml
services:
frontend:
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik_proxy"
- "traefik.http.routers.ultratorrent.rule=Host(`torrents.example.com`)"
- "traefik.http.routers.ultratorrent.entrypoints=websecure"
- "traefik.http.routers.ultratorrent.tls.certresolver=letsencrypt"
# The container listens on 8080 (nginx-unprivileged), NOT 80.
- "traefik.http.services.ultratorrent.loadbalancer.server.port=8080"
networks: [internal, traefik_proxy]

networks:
traefik_proxy:
external: true

WebSockets: Traefik proxies them transparently — no extra middleware, no header juggling. It just works, provided you did not strip Upgrade/Connection with a custom headers middleware.

Static-config reminder (traefik.yml), if you do not already have it:

entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint: { to: websecure, scheme: https }
websecure:
address: ":443"

certificatesResolvers:
letsencrypt:
acme:
email: you@example.com
storage: /letsencrypt/acme.json
httpChallenge:
entryPoint: web

Verification​

1. The page loads over HTTPS at your hostname, with a valid padlock.

2. The API answers through the proxy:

curl -s https://torrents.example.com/api/system/live

3. The WebSocket upgrades. This is the test that actually matters:

curl -i -N \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
https://torrents.example.com/ws/

Expected — the server agrees to upgrade:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade

Anything else — 200, 400, 404, 502 — means your proxy is eating the upgrade. Go back and fix the Upgrade / Connection headers.

4. The real test. Open the UI, start a download, and watch the progress bar move without refreshing. If you have to hit F5 to see progress, the WebSocket is not connected.

DevTools Network panel, Socket filter: the /ws/ request returns 101 Switching Protocols

To check it yourself: DevTools → Network → Socket, then reload. The ws/?EIO=4&transport=websocket request must report 101 Switching Protocols, with Connection: upgrade and Upgrade: websocket in the response headers. Anything else — most often a 200 or a 400 — means your proxy stripped the upgrade headers, and no amount of restarting UltraTorrent will fix it.

HTTPS​

Every config above assumes TLS terminates at the proxy. Certificates, Let's Encrypt, custom CAs and DNS-01: TLS.

Updates​

A reverse proxy is orthogonal to UltraTorrent updates — rebuilds do not touch it. The one thing to re-check after an upgrade: if you edited deploy/Caddyfile in the repo, a git pull may conflict with your change. Keep proxy config outside the repo (or in docker-compose.override.yml, which is not tracked) so upgrades stay clean.

Backups​

Back up your proxy's certificate store — caddy_data, Traefik's acme.json, /etc/letsencrypt, or NPM's data/ + letsencrypt/ folders. Losing it is survivable (certificates re-issue) but you will hit Let's Encrypt rate limits if you do it repeatedly.

Troubleshooting​

SymptomCauseFix
502 Bad Gateway from the bundled Caddy proxydeploy/Caddyfile routes to frontend:80, but the nginx-unprivileged image listens on 8080Change it to reverse_proxy frontend:8080
502 / connection refused generallyProxy cannot reach the upstream — wrong port, wrong hostname, or not on the same Docker networkUpstream is frontend:8080 (same network) or 127.0.0.1:8080 (same host). Attach the proxy to the internal network if you use service names
UI loads, logs in, but nothing ever updatesThe /ws/ upgrade is being droppedRun the 101 Switching Protocols curl test above. Add Upgrade/Connection headers (NGINX), enable Websockets Support (NPM), or set timeout tunnel (HAProxy)
Live updates work for ~60 seconds, then stopThe proxy's read timeout is killing the idle WebSocketproxy_read_timeout 86400s (NGINX/NPM), timeout tunnel 24h (HAProxy)
Login works but a refresh logs you outCookies/headers mangled, or X-Forwarded-Proto missing so the app thinks it is on HTTPForward Host and X-Forwarded-Proto
Static assets 404 under a subpath (e.g. /ultratorrent/)The SPA is built for the root path — its asset URLs are absoluteServe UltraTorrent on its own hostname or subdomain, not a subpath
Uploading a .torrent file → 413 Request Entity Too LargeProxy body-size limitclient_max_body_size 64m (NGINX/NPM)
Certificate issuance failsDNS not pointing at the proxy yet, or 80/443 not reachable for the HTTP-01 challengeSee TLS
Everything works on the LAN, nothing from outsideFirewall or routerOnly 80/443 should be forwarded — and never 4000 or 5000

Best practices​

  • Terminate TLS at the proxy, and let the proxy be the only thing on 80/443.
  • Use Option A (single upstream, frontend:8080) unless you have a specific reason not to. Fewer places to get the WebSocket wrong.
  • Bind the container's host port to 127.0.0.1 once a proxy fronts it, so the raw HTTP port is not reachable from your LAN.
  • Never proxy the backend (4000) or SCGI (5000) to the internet. The API is reachable through /api/ already; SCGI is unauthenticated remote control.
  • Set long read/tunnel timeouts — a WebSocket is a long-lived idle connection by design.
  • Keep proxy config out of the repo so git pull never conflicts with it.
  • Give it its own hostname, not a subpath.
  • Add authentication in front of it if it is public — a Cloudflare Access policy, or your proxy's basic-auth — as defence in depth on top of UltraTorrent's own login. See Security.

FAQ​

Do I need a reverse proxy on my LAN? No. http://<host>:8080 works fine.

Can I run UltraTorrent under example.com/torrents? Not supported — the SPA is built for the root path. Use a subdomain.

Which proxy is the least trouble? Caddy or Traefik: both handle WebSocket upgrades transparently and issue certificates themselves.

Do I still need the bundled proxy profile if I run my own proxy? No. Do not start it — it would fight for ports 80/443.

Does the WebSocket need its own hostname or port? No. It is served at /ws/ on the same origin as the UI.

Why does the connection test to Prowlarr pass while grabs fail? That is not a proxy issue — it is the SSRF guard. See Docker Compose → optional profiles.

Checklist​

  • Proxy reaches the upstream — frontend:8080 (same network) or 127.0.0.1:8080 (same host)
  • /ws/ returns 101 Switching Protocols through the proxy
  • Read / tunnel timeouts raised well above 60 seconds
  • client_max_body_size (or equivalent) at least 64 MB
  • CORS_ORIGIN in .env matches the public URL; backend recreated
  • HTTPS works, HTTP redirects to it
  • Host port 8080 bound to 127.0.0.1 (or removed entirely)
  • Backend (4000) and SCGI (5000) are not exposed
  • A download's progress bar moves live, with no page refresh
  • Proxy config lives outside the git repo

See also​