◂ writeups // michaelz.dev

Everyone Looks Local: the reverse proxy that lied about my friends

A friend messaged me: "your Jellyfin keeps buffering." Not unwatchable, just death by a thousand pauses — the little spinner every couple of minutes, right in the middle of a scene. On my own TV, in my own living room, the exact same library played flawlessly. So it was remote, it was the internet, it was bandwidth. Obvious. Except every obvious fix I reached for did nothing, and the reason turned out to be a small lie my own infrastructure was telling.

The reasonable first guess

Jellyfin has a setting for exactly this: a remote-client bitrate limit. The idea is simple — local players on your LAN can slurp the full-fat 4K file straight off the disk, but anyone coming in over the internet should be transcoded down to something their connection can actually sustain. I had a GPU sitting idle in the server specifically to do that transcoding. So I set the cap, felt clever, and went back to my evening.

The buffering continued. Unchanged. Not "a bit better" — identical.

When a setting that should obviously work has zero effect, that's not a tuning problem, that's a sign you've misunderstood something. So I stopped guessing and went to look at what the server was actually doing during a remote stream.

The tell in the logs

Jellyfin will happily tell you how it's serving each stream, if you read its transcode logs while a session is live. There are basically three modes: Transcode (re-encode on the fly, what the GPU is for), Remux (repackage without re-encoding), and DirectStream / DirectPlay (send the original file, untouched). During my friend's buffering session, the log said the same thing over and over:

...Static stream, DirectStream...

Never "Transcode." The GPU was stone cold. Jellyfin was shipping the original ~66 Mbit 4K file across the internet, completely unmodified, to a connection that could not possibly keep up. My carefully-set remote bitrate cap was being ignored — because as far as Jellyfin was concerned, there were no remote clients.

The bug was never bandwidth. The bug was identity. Jellyfin genuinely believed my friend in another city was sitting on my couch.

Why everyone was "local"

Here's the part that took me embarrassingly long to see. My Jellyfin doesn't face the internet directly — nothing in my lab does. Everything public comes in through a single reverse proxy that terminates TLS and forwards the request inward. Clean, and it's how you're supposed to do it.

But think about what the application sees. When the proxy forwards a connection, the TCP socket Jellyfin accepts comes from the proxy. So the source IP on every single request — my phone, my TV, a friend three countries away — is the same private LAN address: the proxy's. And Jellyfin's rule for "is this client local?" is "is their IP inside my local network?" The proxy's address is, of course, extremely local. So every client on earth passed the local check, got classified as LAN, and was handed the raw file. The remote cap only applies to remote clients, and I had accidentally made sure I never had any.

The real client IP isn't lost, though — the proxy tucks it into an X-Forwarded-For header. The catch is that an app will only trust that header if you explicitly tell it which upstream is allowed to set it. Otherwise any client could forge it, so by default Jellyfin ignores it and just believes the socket. Which is the safe default, and also exactly the thing biting me.

Teaching it who the proxy is

The fix is two declarations in Jellyfin's networking config: mark the proxy as a known proxy, and define which subnet actually counts as local. Once Jellyfin trusts the proxy, it reads the forwarded header instead of the socket, and suddenly my friend has their real public IP again — and lands, correctly, on the remote path.

KnownProxies:        the reverse proxy's LAN address
LocalNetworkSubnets: 192.168.1.0/24

One crucial detail: Jellyfin wires up its proxy trust at startup. Setting the values isn't enough — you have to restart the service for it to take. After the restart I checked the friend's live session, and where it used to show the proxy's address, it now showed their real public IP. The very next play started a Transcode. The GPU woke up, the stream dropped to the capped bitrate, and the buffering was gone.

The part that made it recur

I'd like to say that was the end. It was, for a couple of weeks — until a routine Jellyfin update reset the networking config back to empty, silently, and the whole thing came back. Same buffering, same confused friend, same "but I fixed this" feeling.

A config that a background update can quietly wipe is a config you cannot set by hand and trust. So the durable fix wasn't the setting at all — it was a tiny guard script that re-asserts the known-proxy and local-subnet values every single time Jellyfin starts, wired in as a pre-start step. Now an update can blow the config away all it likes; the next start puts it right back before the app finishes coming up. I proved it by deliberately wiping the file and restarting: it healed itself.

The lesson

Two things I'll carry forward. First, the obvious one: the moment you put an application behind a reverse proxy, every client looks like the proxy — same source IP, same "location," same trust level — until you explicitly teach the app to trust the proxy and read the forwarded address. Rate limits, geo-rules, "local vs remote" logic, audit logs: all of it quietly breaks in the same way, and all of it looks like a bandwidth or bug problem instead of an identity one.

Second, the one I keep relearning: if a fix can be undone by an unattended update, it isn't a fix, it's a to-do reminder. The setting was correct. The guard that keeps re-applying the setting is what actually solved the problem.

Comments

◂ all writeups michaelz.dev ▸