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