Skip to content

Appendix: Reverse proxy ​

This is a reference appendix for the Direct path of the Remote access guide — read that page first if you're setting up remote access for the first time. This page is the full technical detail behind Direct's "I already have a reverse proxy" mode, and is also the right page if you're running Loombre on your own local network behind a proxy you already operate, with no remote-access path enabled at all.

Running Loombre behind a reverse proxy you already operate (Caddy, nginx, Traefik, or anything else) is the first-class, recommended remote-access path (docs/PLAN.md §10) — Loombre speaks plain HTTP, your proxy terminates TLS, and you get to reuse whatever certificate/routing/logging setup you already run for other services. The two alternatives — built-in ACME (Built-in ACME) and LAN-only/no-TLS (below) — exist for installs that don't already have a proxy.

Every recipe below satisfies the SAME six real requirements Loombre's HTTP surface has — skipping any one of them breaks something specific, noted inline:

  1. WebSocket upgrade for /v1/events — presence/live-update pushes. Without this, the web client falls back to fully static state (no live "someone started watching" pulses, no live job progress).
  2. No buffering on HLS segment paths (/playback/sessions/*/hls/**) — segments are written to disk as transcoding produces them; a proxy that buffers the full response before forwarding it adds multi-second latency to every segment fetch, which reads as stuttering/rebuffering in the player. proxy_buffering off (nginx), Caddy's default (no buffering by default), Traefik's default (streaming by default) — the nginx recipe below is the one that needs an explicit opt-out.
  3. ?token= query-string auth is real auth, not a decoration — GET /images/** and GET /playback/sessions/{id}/file accept the access JWT via a ?token= query param (P2.18 — <img>/<video>/ <audio> elements cannot set an Authorization header). Two consequences for your proxy config: (a) never strip query strings on these paths, and (b) your proxy's OWN access log almost certainly records the full request line including that token by default — redact it (see the nginx map example below; apply the same idea to whatever proxy you run).
  4. client_max_body_size (or equivalent) large enough for POST /import — the export/import round-trip (docs/PLAN.md §8.4) uploads a full archive in one request; most proxies default to a body-size cap (nginx: 1MB) that's nowhere near big enough for a real library export. Size this to your actual library, not a guess — err large.
  5. X-Forwarded-For + LOOMBRE_TRUST_PROXY are a PAIR — set both or neither. Setting LOOMBRE_TRUST_PROXY without a proxy in front that actually strips/overwrites incoming X-Forwarded-For lets any client spoof its own rate-limit/anomaly-log identity; setting a proxy without LOOMBRE_TRUST_PROXY makes Loombre's rate limiter/anomaly log key on your proxy's own address for every request (still safe, just useless for per-client limiting). See "Trust-proxy configuration" below.
  6. The invite-claim and password-reset API routes are public, and already rate-limited inside Loombre itself — GET /invites/claim/{token}, POST /invites/claim/{token}, POST /auth/forgot-password, POST /auth/reset-password, and GET /auth/reset-password/{token} have to work with no session at all (a brand-new person has no account yet; someone locked out has no way to sign in). If your setup adds its own gate in front of everything — basic auth, an IP allowlist, a separate rate limiter — exclude these five the same way /setup/* already is; anything that blocks or challenges them blocks exactly the people they exist for. Separately: whichever address you set as the public web address setting (LOOMBRE_PUBLIC_URL — see the Admin Guide's Mail page) has to be the address people reach from outside, i.e. this proxy's own hostname, never Loombre's internal origin (http://127.0.0.1:3001) or an upstream target — every invitation and password-reset link is built from that one setting alone, so an internal address here means correctly-sent mail with links nobody outside your network can open. Note the DISTINCT paths: the invite link itself ({publicUrl}/claim/{token}) is a web page — route it like every other browser page, to the web UI, not the API — while the JSON calls that page makes live under /invites/claim/{token} (API-side, listed above). /reset/{token} and /forgot are web pages too, same rule: page routes go to the web UI, and only /auth/forgot-password, /auth/reset-password, and GET /auth/reset-password/{token} (the API calls those pages make) go to the API. That last one is the reset page's own token check — it fires when someone opens the link, so a recipe that misroutes it sends every reset link to the web UI's HTML instead, and the page reports perfectly good links as unusable. Its path can't collide with the /reset/{token} page the way the claim pair once did — the two live under different prefixes by construction.
  7. GET /probe/{token} — the reachability-proof arrival route — goes to the API. The remote-access wizard's "prove it works" step mints a one-time link at <your hostname>/probe/{token} — its scheme follows the public web address setting's own scheme (https only as the fallback when that setting is empty), so the "LAN-only, no TLS" path below gets a reachable http:// link rather than a dead https:// one — and has you open it from a phone on cellular data; the API serves the arrival page itself, at the root (it is the one public remote-access operation — deliberately not under /v1). Every recipe below routes it; a setup that misses it sends the probe to the web UI, which 404s, and the wizard's proof step reports an unreachable server that is actually reachable — on exactly the deployment shape (Direct behind your own proxy) that most needs the proof. Same public-and-already-rate-limited posture as the five routes in requirement 6: exclude it from any gate of your own.
caddyfile
media.example.com {
    # API paths -> the server (:3001); everything else -> the web UI (:3000).
    # /invites/claim/* and the /auth/*-password routes (requirement 6) are
    # the public invite-claim and password-reset API calls — bare paths,
    # not under /v1, so they need listing explicitly like /healthz and
    # /setup/*. NOTE: bare /claim/* (no /invites prefix) is the human
    # invite-link PAGE — it deliberately falls through to the web UI catch-
    # all below, not this @api matcher (F1: the two used to collide on the
    # same path before the API's own claim routes moved under /invites).
    # /auth/reset-password/* (with the trailing wildcard, since Caddy's
    # `path` is otherwise an exact match) is the reset page's own token
    # check — distinct from the /reset/{token} PAGE, which stays on the web
    # UI; omit it and every reset link is reported as unusable.
    # /probe/* (requirement 7) is the reachability-proof arrival route —
    # API-side, at the root; without it the wizard's proof step 404s into
    # the web UI.
    @api path /v1/* /playback/* /images/* /healthz /setup/* /invites/claim/* /auth/forgot-password /auth/reset-password /auth/reset-password/* /probe/*
    reverse_proxy @api 127.0.0.1:3001
    reverse_proxy 127.0.0.1:3000
}

That's genuinely it. Caddy: obtains and renews its own TLS certificate automatically, upgrades WebSocket connections transparently for any reverse_proxy block (no separate config), streams responses without buffering by default, and sets X-Forwarded-For/X-Forwarded-Proto automatically. Only LOOMBRE_TRUST_PROXY=1 needs adding on the Loombre side (Caddy runs as a single trusted hop). Large uploads need no Caddy-side change (no default cap) — only the Loombre-side POST /import implementation's own limits, if any, apply.

nginx ​

nginx
server {
    listen 443 ssl http2;
    server_name media.example.com;

    ssl_certificate     /etc/letsencrypt/live/media.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/media.example.com/privkey.pem;

    client_max_body_size 20g;  # size to your actual library — POST /import (requirement 4)

    # Redact ?token= from THIS proxy's own access log (requirement 3b) —
    # Loombre's own server-side logs already never echo it.
    map $request $request_no_token {
        "~^(?<verb>\S+)\s(?<path>[^?\s]+)(\?\S*)?\s(?<proto>\S+)$" "$verb $path $proto";
    }
    log_format redacted '$remote_addr - [$time_local] "$request_no_token" $status $body_bytes_sent';
    access_log /var/log/nginx/loombre-access.log redacted;

    # WebSocket upgrade for /v1/events (requirement 1) — the Upgrade/
    # Connection headers are NOT inherited from a parent `location /`
    # block in nginx, so this location needs its own copy even though
    # proxy_pass target is identical.
    location /v1/events {
        proxy_pass http://127.0.0.1:3001;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # No buffering on HLS segment/manifest paths (requirement 2) — nginx
    # buffers proxied responses by default, which is the opposite of what
    # a segment being written mid-transcode needs.
    location ~ ^/playback/sessions/[^/]+/hls/ {
        proxy_pass http://127.0.0.1:3001;
        proxy_buffering off;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # Remaining API paths (requirements 3-5) — everything the browser
    # calls on the server origin rather than the web UI. `images` was
    # missing here for a while despite requirement 3 naming it explicitly
    # alongside the playback file route (both are `?token=`-authed
    # <img>/<video> src targets) — every poster/thumbnail would silently
    # fall through to the web UI location below without it.
    location ~ ^/(v1|playback|images|setup)/ {
        proxy_pass http://127.0.0.1:3001;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # Invite-claim and password-reset API routes (requirement 6) — public,
    # rate-limited inside Loombre itself, bare paths (not under /v1) so
    # they need their own location the same way /healthz does above.
    # Bare /claim/[^/]+ (no /invites prefix) is deliberately NOT matched
    # here — that's the human invite-link PAGE, and falls through to the
    # web UI location below (F1: the API's own claim routes moved under
    # /invites/claim/* specifically so the two stop colliding).
    # The regex is ANCHORED at both ends, so the reset page's own token
    # check needs its own alternative: without `auth/reset-password/[^/]+`
    # the `$` rejects it and every reset link falls through to the web UI,
    # which answers HTML and makes good links look unusable. The
    # /reset/{token} PAGE is a different path entirely and still falls
    # through, as it should.
    # probe/[^/]+ (requirement 7) is the reachability-proof arrival route
    # — API-side, at the root; without it the wizard's proof step falls
    # through to the web UI location below and 404s.
    location ~ ^/(invites/claim/[^/]+|auth/(forgot|reset)-password|auth/reset-password/[^/]+|probe/[^/]+)$ {
        proxy_pass http://127.0.0.1:3001;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location /healthz {
        proxy_pass http://127.0.0.1:3001;
    }

    # Everything else is the browser-facing web UI (the Next.js standalone
    # server every install channel runs as its own service on :3000).
    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
}

LOOMBRE_TRUST_PROXY=1 on the Loombre side (nginx is one hop, forwarding its own $proxy_add_x_forwarded_for, which correctly appends to — rather than blindly trusts — any inbound value).

Traefik ​

yaml
# docker-compose labels (or the equivalent static/dynamic file config)
labels:
  - "traefik.enable=true"
  # API router (server, :3001) — higher priority than the catch-all.
  # PathPrefix(`/invites/claim`) + the exact /auth/*-password paths
  # (requirement 6) are the public invite-claim and password-reset API
  # calls — bare paths, not under /v1, so they're listed explicitly here
  # the same way /healthz and /setup are. Bare `/claim` (no `/invites`
  # prefix) is deliberately NOT in this rule — that's the human
  # invite-link PAGE, and falls through to the web UI router below (F1:
  # the API's own claim routes moved under /invites/claim/* specifically
  # so the two stop colliding). PathPrefix(`/auth/reset-password/`) — with
  # the TRAILING SLASH, so it covers the token segment and nothing else —
  # is the reset page's own token check; `Path` alone is an exact match
  # and would miss it, sending every reset link to the web UI and making
  # good links look unusable. The /reset/{token} PAGE is a different path
  # and still falls through to the web router. PathPrefix(`/images`)
  # (requirement 3) is the OTHER `?token=`-authed <img>/<video> src target
  # alongside the playback file route — missing here would silently 404
  # every poster/thumbnail through this router.
  # PathPrefix(`/probe/`) (requirement 7) — with the trailing slash, the
  # token segment — is the reachability-proof arrival route, API-side at
  # the root; without it the wizard's proof step 404s into the web router.
  - "traefik.http.routers.loombre-api.rule=Host(`media.example.com`) && (PathPrefix(`/v1`) || PathPrefix(`/playback`) || PathPrefix(`/images`) || PathPrefix(`/setup`) || Path(`/healthz`) || PathPrefix(`/invites/claim`) || Path(`/auth/forgot-password`) || Path(`/auth/reset-password`) || PathPrefix(`/auth/reset-password/`) || PathPrefix(`/probe/`))"
  - "traefik.http.routers.loombre-api.tls.certresolver=letsencrypt"
  - "traefik.http.routers.loombre-api.service=loombre-api"
  - "traefik.http.services.loombre-api.loadbalancer.server.port=3001"
  # Web UI router (everything else, :3000)
  - "traefik.http.routers.loombre-web.rule=Host(`media.example.com`)"
  - "traefik.http.routers.loombre-web.tls.certresolver=letsencrypt"
  - "traefik.http.routers.loombre-web.service=loombre-web"
  - "traefik.http.services.loombre-web.loadbalancer.server.port=3000"
  # Uploads (requirement 4) — Traefik has no default body-size cap of its
  # own; a cap here only matters if you've added one elsewhere in your
  # chain (e.g. a WAF/CDN in front of Traefik itself).

Traefik upgrades WebSocket connections and streams responses without buffering automatically for any HTTP router — no extra config needed for requirements 1/2, same as Caddy. It also sets X-Forwarded-* headers by default; LOOMBRE_TRUST_PROXY=1 on the Loombre side.

Trust-proxy configuration ​

LOOMBRE_TRUST_PROXY accepts exactly what Express's own trust proxy setting understands (apps/server/src/main.ts's resolveTrustProxySetting — unit-tested, and end-to-end tested both ON and OFF in apps/server/test/auth-security.e2e.spec.ts / apps/server/test/trust-proxy-hardening.e2e.spec.ts):

bash
LOOMBRE_TRUST_PROXY=1              # exactly one proxy hop you control
LOOMBRE_TRUST_PROXY=2              # a hop count, if you chain two proxies
LOOMBRE_TRUST_PROXY=loopback       # Express preset: 127.0.0.1/8, ::1
LOOMBRE_TRUST_PROXY=10.0.0.0/8     # a CIDR (or comma-separated list)

Default (unset): X-Forwarded-For is completely ignored — req.ip is always the raw socket address, so nothing a client sends can move the auth rate limiter's or anomaly log's IP key. This is a strict allowlist handed straight to Express's own well-tested trust proxy implementation — Loombre never parses X-Forwarded-For itself anywhere in the codebase (every consumer reads Express's already-gated req.ip).

LOOMBRE_CORS_ORIGINS ​

If the web client is served from a DIFFERENT origin than the API (e.g. app.example.com talking to api.example.com, or any non-same-origin proxy topology), set the allowlist explicitly:

bash
LOOMBRE_CORS_ORIGINS=https://app.example.com,https://media.example.com

Default covers only the local dev pairing (http://localhost:3000/http://127.0.0.1:3000). An explicit empty value (LOOMBRE_CORS_ORIGINS=) disables CORS entirely — correct for a same-origin deployment where one proxy serves both the web client and the API under one hostname (no cross-origin requests ever happen, so no CORS headers are needed). Only EXACT listed origins are ever reflected — no wildcard, no credentialed requests (auth is Bearer/?token=, never cookies, so credentials: false is fixed).

LOOMBRE_SERVER_ORIGIN (the web app's side of the same pairing) ​

LOOMBRE_CORS_ORIGINS above is set on the server service and lists which WEB origins may call the API. LOOMBRE_SERVER_ORIGIN is set on the web service and is the inverse: which API/server origin(s) THIS web build's Content-Security-Policy is allowed to fetch/stream from (connect-src/ img-src/media-src). Set by the SAME operator, pointing at each other:

bash
# server
LOOMBRE_CORS_ORIGINS=https://loombre.example.com

# web
LOOMBRE_SERVER_ORIGIN=https://api.loombre.example.com

Comma-separated, same parsing rule as LOOMBRE_CORS_ORIGINS above. This is optional: Loombre's web client takes an arbitrary server URL on the login screen (persisted client-side) — a single public web build can legitimately talk to many different self-hosted servers with no one origin known in advance, which is exactly why leaving this unset falls back to a scheme-wildcarded CSP (https:/wss: etc.) rather than breaking. Set it when you run web+server together as a fixed pairing (the increasingly common single-tenant case) to get a materially tighter CSP — connect-src/img-src/media-src collapse to 'self' plus exactly your server's origin instead of any HTTPS host.

Docker/Compose deployments (documented in docs/install/docker.md) run three services from two images: server and worker share one image, and the web UI runs as its own web service from a second image (ghcr.io/loombre/loombre-web). The compose file already sets LOOMBRE_SERVER_ORIGIN on the web service the same way a native install's web service does, so the tighter CSP described above applies there too.

HSTS is yours, not Loombre's ​

On this path the proxy is the browser's actual TLS endpoint, so the proxy — not Loombre — owns Strict-Transport-Security. Loombre only sends the header itself when it is terminating TLS AND LOOMBRE_TRUST_PROXY is unset; setting LOOMBRE_TRUST_PROXY (which you do here) turns it off on Loombre's side even if LOOMBRE_TLS_MODE is also non-off. The full rule, by combination, is the HSTS table in Built-in ACME.

Add it at the proxy layer instead. The nginx recipe above already carries the line:

nginx
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;

The Caddy equivalent inside the site block is header Strict-Transport-Security "max-age=63072000; includeSubDomains"; on Traefik it's a headers middleware (stsSeconds, stsIncludeSubdomains). None of the three set it for you — HSTS is a commitment about a domain you own, so it stays an explicit operator decision.

LAN-only, no TLS ​

If Loombre never leaves a network you trust (no port-forward, no public DNS record, VPN-only access), plain HTTP with no proxy and no LOOMBRE_TLS_MODE is a legitimate, supported third path — nothing extra to configure beyond the defaults. LOOMBRE_TRUST_PROXY stays unset (no proxy exists to trust) and LOOMBRE_CORS_ORIGINS only needs setting if your web client's LAN origin differs from the default dev pairing.

Released under the AGPL-3.0-only license. No telemetry, ever.