Saltar al contenido
binarios.com.ar/blog/lo-que-se-rompe-detras-de-un-proxy-inverso/

archivo / Redes / Acceso remoto

Lo que se rompe detrás de un proxy inverso

20 de septiembre de 2026 1.867 palabras 1 imagen 9 min de lectura

El post anterior quedó un poco extenso, pero era escencial abordar todos los puntos como lo fuimos haciendo. Era el paso a paso para tener un reverse proxy funcional y con certificados. Pero hay servicios que suelen fallar cuando hay un intermediario. Acá intentaré abordar esos casos y vamos a intentar listarlos y resolverlos.

Todo anda, salvo lo que no

En el post anterior hablé de levantar el servicio de jellyfin. Entrando a jellyfin.casa.ar todo funcionaba aparentemente bien. Sin embargo, en forma constante me encontraba con problemas que se los atribuía al reproductor: el timeline se colgaba, la reproducción se entrecortaba, entre otros problemas.

Mi hipótesis era que algo no andaba bien en el servidor. Sin embargo, me desconcertaba comprobar que conectado directamente por la IP al servidor en la red local los problemas esos no existían. ¿Qué fallaba?

WebSockets: por qué Jellyfin se queda mudo

Allí es donde se me hizo clara la idea: la conexión con jellyfin no era una conexión unidireccional como la que tenía con una página web convencional.

La interfaz necesita una conexión bidireccional que mantenga comunicados el frontend con el backend. Y esta comunicación Jellyfin la resuelve con websockets.

Cometí un error: daba por sentado que nginx redirigiría todas esas conexiones de websockets en forma transparente. ¡Pero esto no es así!

Sin las cabeceras Upgrade y Connection el pedido llega como HTTP común; con ellas, el servidor responde 101 y la conexión queda abiertaSin las dos cabecerasclientenginxJellyfinGET /socketUpgrade: websocketConnection: upgradeGET /socket(sin Upgrade ni Connection)nginx no las reenvía por defecto200 OK200 OKLa interfaz carga, pero nunca recibe una novedad.Con proxy_set_header Upgrade y ConnectionclientenginxJellyfinGET /socketUpgrade: websocketConnection: upgradeGET /socketUpgrade: websocketConnection: upgradelas reenvía tal cual101 Switching Protocols101 Switching ProtocolsLa misma conexión queda abierta en los dos sentidos.
Arriba, nginx deja caer las cabeceras Upgrade y Connection y Jellyfin responde como a un pedido común. Abajo, con las dos cabeceras reenviadas, responde 101 y la conexión queda abierta en los dos sentidos.

Un WebSocket empieza como un pedido HTTP normal, con dos cabeceras de más: Upgrade: websocket y Connection: upgrade. Si el servidor acepta, responde 101 Switching Protocols y esa misma conexión deja de ser HTTP para quedar abierta en los dos sentidos. El problema es que esas dos cabeceras son de las que se negocian entre vecinos, no de punta a punta: describen la conexión entre el cliente y lo que tiene enfrente, no el pedido. nginx, que es lo que el cliente tiene enfrente, no las reenvía. Y para peor, hacia el servicio habla HTTP/1.0 por defecto, donde el mecanismo de upgrade ni siquiera existe. Jellyfin recibe un GET /socket que parece un pedido cualquiera, contesta 200 y la interfaz se queda esperando novedades que nunca llegan.

La solución a esto es hacer un map, que va en el bloque http, fuera de cualquier server. En Debian, un archivo nuevo en /etc/nginx/conf.d/ queda incluido ahí:

# /etc/nginx/conf.d/websocket.conf
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

El map hace que la cabecera Connection se reenvíe como upgrade solo cuando el cliente pidió un WebSocket, y como close en cualquier otro caso. Así los pedidos comunes siguen siendo comunes y no hace falta saber en qué ruta abre cada servicio su WebSocket.

Después, tres líneas en la location / del servicio, junto a las cabeceras que ya tenía:

location / {
    proxy_pass http://127.0.0.1:8096;
    proxy_http_version 1.1;
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection $connection_upgrade;

    # ... las cabeceras de siempre
}

nginx -t, reload, y la interfaz vuelve a moverse sola. Esta misma receta sirve para cualquier servicio que use WebSockets, no solo para Jellyfin.

La IP real del cliente

Otro problema frecuente que ocurre con un reverse proxy son los logs: todas las peticiones que recibe el servicio provienen de la misma IP ¡el mismo reverse proxy!

Si, además, tenemos fail2ban o alguna herramienta como esa, automáticamente el servicio bloqueará a nginx por tantas peticiones o errores en campos de login.

Para resolverlo:

Del lado de nginx ya está hecho: las cabeceras X-Real-IP y X-Forwarded-For de la parte 1 llevan la IP del visitante en cada pedido. Lo que falta es del lado del servicio, que por defecto las ignora, y con razón: cualquiera puede mandar esa cabecera con una IP inventada. Hay que decirle explícitamente de qué proxy confiar.

  • Jellyfin: Panel → Redes → «Proxies conocidos», y ahí 127.0.0.1 (o la IP del equipo del proxy).
  • Nextcloud: en config.php, 'trusted_proxies' => ['127.0.0.1'],.
  • Home Assistant: en configuration.yaml, bajo http:, use_x_forwarded_for: true y trusted_proxies: [127.0.0.1].

A partir de ahí los logs muestran la IP real, y fail2ban, si lo hay, banea al visitante y no al proxy.

Subidas grandes y timeouts

Continuando con los problemas, los archivos de gran tamaño que estamos subiendo a algún servicio que estemos corriendo (como nextcloud) pueden sufrir cortes, interrupciones o fallas. Y esto se debe a que client_max_body_size suele quedar fijado en el tamaño por defecto de 1 MB.

Para resolverlo:

El síntoma es un 413 Request Entity Too Large que el cliente muchas veces no muestra: la subida simplemente falla. En el server block del servicio que recibe archivos:

client_max_body_size 10G;     # o 0 para no limitar
proxy_read_timeout   3600s;
proxy_send_timeout   3600s;

La primera línea levanta el tope. Las otras dos evitan que nginx corte una transferencia larga por inactividad: el valor por defecto es de 60 segundos, poco para una subida grande o para una respuesta que tarda en generarse.

Buffering y streaming

Este es otro gran problema que tuve con Jellyfin: el stuttering (o trabas en la reproducción). El problema era claro que provenía del buffer. Pero el ancho de banda y las métricas eran impecables. ¿Qué estaba pasando?

Lo que pasaba es que nginx, por defecto, no reenvía la respuesta del servicio a medida que llega: la acumula en memoria y, si no le alcanza, en disco, y recién después la manda al cliente. Para una página web es una ventaja, porque libera rápido al servicio. Para un video es lo contrario: el reproductor recibe el contenido a ráfagas, con pausas entre una y otra, aunque el ancho de banda sobre. Eso es el stuttering.

Para resolverlo, en la location del servicio que transmite:

proxy_buffering off;

Con eso nginx pasa cada trozo apenas lo recibe. Para Jellyfin no siempre hace falta, porque el reproductor tiene su propio buffer y en muchas instalaciones anda bien sin tocar nada; si hay trabas con la red sana, este es el primer lugar donde mirar.

Los servicios tienen que dejar de escuchar hacia afuera

Ahora dejamos los errores de lado para dar paso a una consideración de seguridad importante:

Los servicios que están detrás de un edge server (o reverse proxy) deberían solo escuchar lo que proviene de él y no al tráfico y peticiones que llegan desde internet.

Para resolverlo:

Primero, ver qué está escuchando hacia afuera:

ss -tlnp

Un servicio en 0.0.0.0:8096 acepta conexiones de cualquiera; en 127.0.0.1:8096, solo del propio equipo. La idea es que todos los que están detrás del proxy queden en la segunda forma. Hay tres maneras, de la más limpia a la más general:

  • En el servicio: la mayoría permite elegir la dirección donde escucha. Jellyfin lo tiene en Panel → Redes → «Dirección de enlace»; otros lo llevan en su archivo de configuración.
  • En Docker: publicar el puerto solo en localhost, -p 127.0.0.1:8096:8096, o "127.0.0.1:8096:8096" en compose.yaml. Sin el prefijo, Docker abre el puerto al mundo y además saltea el firewall del equipo.
  • En el firewall: si el servicio no puede restringirse, que el equipo solo acepte desde afuera el 80 y el 443. Con ufw, ufw allow 80,443/tcp y ufw deny 8096/tcp.

Si los servicios corren en otro equipo de la red, escuchan en su IP de la LAN y es el firewall de ese equipo, o el del router, el que tiene que dejar pasar solo al proxy.

Un default_server que no diga nada

Un detalle que suele pasar desapercibido: ¿qué responde nginx cuando alguien entra por la IP pública pelada, o por un nombre que apunta a nuestra IP pero no tiene server block? Responde con el primer bloque que encuentra, que es uno de nuestros servicios. Cualquiera que escanee rangos de IP se encuentra con la pantalla de login de Jellyfin sin haber conocido el nombre.

Para resolverlo:

Un bloque que atrape todo lo que no coincide con ningún nombre y corte la conexión sin responder nada, en sites-available/default.conf:

server {
    listen 80  default_server;
    listen 443 ssl default_server;
    server_name _;

    ssl_reject_handshake on;
    return 444;
}

444 es un código propio de nginx: cierra la conexión sin enviar respuesta. ssl_reject_handshake on hace lo mismo en la capa TLS, y evita tener que darle a este bloque un certificado que no tiene. En Debian conviene borrar el enlace sites-enabled/default que trae el paquete, porque es otro default_server y los dos chocan.

Un certificado para todos: wildcard con DNS-01

Con tres servicios, un certificado por nombre se maneja. Con diez, cada alta es un certbot --nginx más, y además todos los nombres quedan publicados en los registros de transparencia de certificados, que son públicos: cualquiera puede listar qué servicios tenemos.

La alternativa es un certificado comodín para *.casa.ar, que cubre todos los subdominios de una vez. Let’s Encrypt lo emite, pero no con el desafío por HTTP: exige el DNS-01, que consiste en publicar un registro TXT en la zona del dominio. certbot lo automatiza con un complemento para el proveedor de DNS, si es que existe para el nuestro, y ahí está la traba: depende de que el proveedor tenga API y complemento. Si lo tiene, es un comando; si no, hay que responder el desafío a mano cada 90 días, y no vale la pena.

Recapitulación

Este es jellyfin.conf con todo lo de las dos partes junto. Es lo que conviene copiar, y ajustar el nombre y el puerto para cada servicio:

# /etc/nginx/conf.d/websocket.conf: el map de la sección de WebSockets
# tiene que existir una sola vez fuera de este archivo.

server {
    server_name jellyfin.casa.ar;

    # Subidas y transferencias largas
    client_max_body_size 10G;
    proxy_read_timeout   3600s;
    proxy_send_timeout   3600s;

    location / {
        proxy_pass http://127.0.0.1:8096;

        # WebSockets
        proxy_http_version 1.1;
        proxy_set_header Upgrade           $http_upgrade;
        proxy_set_header Connection        $connection_upgrade;

        # Quién pidió y por dónde: el servicio tiene que confiar en el proxy
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # Streaming: descomentar si la reproducción se entrecorta
        # proxy_buffering off;
    }

    # Lo que agregó certbot --nginx
    listen 443 ssl;
    ssl_certificate /etc/letsencrypt/live/jellyfin.casa.ar/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/jellyfin.casa.ar/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}

# Redirección del 80, también de certbot
server {
    if ($host = jellyfin.casa.ar) {
        return 301 https://$host$request_uri;
    }

    listen 80;
    server_name jellyfin.casa.ar;
    return 404;
}

Y fuera de este archivo, lo que no es configuración de nginx pero sin lo cual el proxy no protege nada:

  • el servicio escuchando solo en 127.0.0.1
  • el proxy declarado como confiable dentro del servicio
  • el bloque default_server que no diga nada.

Así concluye esta serie de dos artículos que engloban en general todo el aspecto de propósito, uso y configuración de nginx como proxy inverso.

¿Dudas? ¿Sugerencias? ¿Correcciones? Ponte en contacto conmigo

binarios.com.ar · publicado el 20.09.2026
Texto e imágenes de Cristian Bottazzi bajo CC BY-NC-SA 4.0.
Código de barras Code128 que codifica binari-os