Nodefony 10.0.0-beta.5
Framework Node.js fullstack — HTTP & WebSocket, même contexte

Mettre un reverse-proxy devant Nodefony (nginx, HAProxy, Traefik)

stablemis à jour 2026-10-21

📍 Documentation › Guides › Reverse-proxy

servers explique ce que l’application attend d’un proxy : trustProxy, trustedHosts, l’origine des WebSockets. Cette page donne l’autre moitié — ce qu’il faut poser côté infrastructure pour que ces réglages disent vrai.

🧠 Le modèle mental — deux moitiés d’un même contrat#

Un proxy et une application se mentent par défaut, et chacun a raison de se méfier de l’autre.

Les deux réglages ne valent que posés ensemble. C’est la première cause d’une IP fausse en production : l’un des deux a été fait, jamais l’autre.

📖 Lexique#

Qu’est-ce que ça résout — la configuration écrite à la main dérive#

Une configuration nginx recopiée depuis un article vieillit mal : elle ne connaît ni vos ports, ni vos domaines de confiance, ni les dossiers statiques que vos modules montent, ni la taille de corps que l’application accepte. Chacun de ces écarts se manifeste en production, et jamais sous la forme du problème réel.

D’où le geste par défaut de ce framework : la configuration se dérive de l’application, elle ne s’écrit pas.

🚀 Démarrage rapide#

# nginx — sur la sortie standard, pour lire avant d'installer
npx nodefony proxy:generate nginx

# HAProxy, écrit dans un fichier
npx nodefony proxy:generate haproxy -o /etc/haproxy/haproxy.cfg

# Le proxy n'est pas sur la même machine que l'application
npx nodefony proxy:generate nginx --backend 10.0.0.12 --listen 443

# TLS jusqu'au backend (re-chiffrement) plutôt qu'en clair sur le réseau interne
npx nodefony proxy:generate nginx --reencrypt

La commande lit l’application réelle (proxyGenerateCommand.ts#ProxyGenerate) : ports effectifs, domaines de confiance, dossiers statiques montés par chaque module, et deux réglages du serveur que le proxy doit refléter sous peine d’imposer les siens en silence — la taille de corps acceptée et le battement du WebSocket.

Ce que le générateur pose, et pourquoi chaque ligne compte#

Ce qui est généré Ce que ça évite
X-Forwarded-For écrasé avec l’adresse vue du client un client qui s’invente une IP — le motif est écrit dans le générateur (generateProxyConfig.ts#NGINX_FORWARD_HEADERS_TPL)
Upgrade / Connection relayés un WebSocket qui « ne se connecte pas » sans le moindre message d’erreur
proxy_read_timeout dérivé du battement WebSocket — quatre battements, plancher 300 s (generateProxyConfig.ts#NGINX_FORWARD_HEADERS_TPL) un proxy qui coupe des sockets vivantes parce que son inactivité par défaut est plus courte que le battement
client_max_body_size dérivé de maxBodySize un 413 posé par nginx (1 Mo par défaut) que l’application n’a jamais vu passer
une chaîne de try_files sur les racines statiques les statiques d’un module en 404, alors qu’ils sont bien montés (generateProxyConfig.ts#nginxServerBlock)
HAProxy : Forwarded entrant effacé avant le nôtre un en-tête forgé conservé à côté du vrai (RFC 7239 §8.1)
HAProxy : le schéma constaté sur ssl_fc annoncer proto=https à un client venu en clair — le défaut a existé, il a été corrigé par le banc contre proxy réel
HAProxy : option httpchk GET /readyz dans chaque backend (generateProxyConfig.ts#generateHaproxyConfig) un exemplaire qui draine gardé dans la table : un contrôle TCP le croit sain tant que son port accepte

Les flux SSE derrière le proxy#

Un flux SSE est une réponse HTTP qui ne finit pas : le proxy n’a rien à apprendre de neuf, mais trois réglages décident s’il le relaie bien. proxy:generate les pose tous.

De quel côté Ce qui est réglé Pourquoi
navigateur ↔ proxy HTTP/2 servi par le proxy (http2 on, generateProxyConfig.ts#nginxServerBlock) le plafond d’environ six connexions par origine est celui du navigateur envers le proxy : c’est là que le multiplexage compte
proxy ↔ Nodefony HTTP/1.1 (proxy_http_version 1.1, generateProxyConfig.ts#nginxServerBlock) une connexion par flux vers le backend ; activer HTTP/2 sur Nodefony n’y change rien
mise en tampon Nodefony envoie X-Accel-Buffering: no (SseStream.ts#startSseStream) nginx relaie chaque événement au lieu d’attendre de remplir son tampon. Le scénario edge du banc de publication mesure les arrivées à travers le frontal généré : nginx 1.30 les relaie au fil de l’eau même SANS l’en-tête — il reste la consigne pour un proxy qui, lui, retient
inactivité délai d’au moins 300 s, soit vingt battements SSE de 15 s un flux muet n’est tenu que par son battement ; un test lie les deux valeurs, il tombe si le battement est relevé au-delà

Sous Kubernetes, l’ingress-nginx a ses propres réglages, que ce générateur n’écrit pas — à poser en annotations sur l’Ingress :

metadata:
  annotations:
    nginx.ingress.kubernetes.io/proxy-read-timeout: "300"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "300"
    nginx.ingress.kubernetes.io/proxy-buffering: "off"

L’en-tête X-Accel-Buffering: no suffit déjà à nginx ; l’annotation de tampon protège un ingress configuré pour l’ignorer.

Et côté application — l’autre moitié#

// nodefony/config/http.ts — sans ceci, le proxy parle dans le vide
import type { IHttpConfigInput } from "@nodefony/http";

export const httpConfig = () =>
  ({
    // L'adresse du proxy, jamais `true`, sauf s'il est l'unique point d'entrée.
    trustProxy: ["10.0.0.0/8"],
    // Le proxy filtre déjà le Host ? Alors la barrière applicative peut s'ouvrir.
    // Sinon, énumérez vos vhosts plutôt que de passer `true`.
    trustedHosts: ["app.example.com"],
  }) satisfies IHttpConfigInput;

Le détail de ces deux clés — préréglages, remontée de chaîne de droite à gauche, barrière Host avant le routage — vit dans servers.

La connexion par un fournisseur d’identité, derrière le proxy#

Un fournisseur d’identité (Keycloak, Google…) renvoie l’utilisateur vers l’application après la connexion, à une adresse qu’il compare au caractère près à celle qu’on lui a enregistrée. Derrière un proxy, l’application ne voit pas sa propre adresse publique : elle doit la recevoir.

# Secret ou variables du conteneur — l'URL PUBLIQUE de l'application, sans chemin.
# Absente, elle vaut https://localhost:<port> : correct en développement, faux en ligne.
NF_OAUTH_REDIRECT_BASE=https://app.example.com

Si Keycloak a, lui aussi, un proxy devant lui, deux réglages de son côté évitent les deux pannes les plus fréquentes :

Un Keycloak servi par une autorité de certification interne se déclare par NF_TLS_CA_FILE (fichier PEM), qui remplace NODE_EXTRA_CA_CERTS et s’écrit pareil sous Windows.

Traefik — la seule cible qui n’est pas générée#

proxy:generate ne connaît que nginx et HAProxy. Pour Traefik, la déclaration vit là où vos conteneurs sont décrits, et c’est à vous de l’écrire :

# compose.yaml (extrait) — Traefik v3, découverte par labels
services:
  app:
    image: mon-app:10.0.0
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.app.rule=Host(`app.example.com`)"
      - "traefik.http.routers.app.entrypoints=websecure"
      - "traefik.http.routers.app.tls.certresolver=le"
      - "traefik.http.services.app.loadbalancer.server.port=5151"
      # 🔴 Le battement WebSocket de Nodefony est de 20 s par défaut. Une
      # inactivité plus courte que lui tranche des sockets VIVANTES : viser au
      # moins quatre battements, comme le fait `proxy:generate` (plancher 300 s).
      - "traefik.http.services.app.loadbalancer.responseforwarding.flushinterval=100ms"
      # La sonde de disponibilité, pas celle de vivacité : Traefik doit retirer
      # le pod du service dès le début de l'arrêt, pas quand le process meurt.
      - "traefik.http.services.app.loadbalancer.healthcheck.path=/readyz"
      - "traefik.http.services.app.loadbalancer.healthcheck.interval=5s"

Traefik pose X-Forwarded-* de lui-même et ne fait confiance qu’aux adresses déclarées dans entryPoints.<nom>.forwardedHeaders.trustedIPs. Deux conséquences : l’Upgrade WebSocket est relayé sans rien demander, mais un Traefik derrière un autre proxy (un équilibreur de charge d’hébergeur) ne transmettra la bonne IP que si cet amont est déclaré.

HTTP/2 jusqu’à l’application — le port h2c#

Par défaut, le proxy parle HTTP/1.1 à Nodefony. Un proxy qui termine le TLS peut aussi lui parler HTTP/2 en clair (h2c) sur un port dédié, servers.h2c (5153), désactivé par défaut. Quand ça vaut la peine — gRPC, flux SSE nombreux, en-têtes lourds — et ce que ça coûte, mesuré : servers › Faut-il l’activer ?.

// nodefony.config.ts — ou NF__APP__SERVERS__H2C__ENABLED=true au déploiement
servers: { h2c: { enabled: true } },

Trois règles valent pour tous les proxys :

Règle Pourquoi
les requêtes vont au port h2c, les WebSocket au port HTTP le port h2c n’annonce pas le CONNECT étendu (RFC 8441), seul chemin d’une WebSocket sur HTTP/2 ; il ne sert pas non plus Upgrade (HTTP/1.1)
les sondes (/livez, /readyz) visent le port HTTP le port h2c refuse HTTP/1.1, et une sonde de kubelet ou de proxy parle HTTP/1.1
le schéma passe par X-Forwarded-Proto et trustProxy en clair, le serveur ne croit jamais le :scheme que le proxy écrit : seul un proxy de confiance dit https — l’autre moitié

HAProxy — généré#

proxy:generate haproxy lit la configuration de l’application : si servers.h2c y est actif, il envoie le trafic au port h2c en proto h2 et garde les WebSocket sur le port HTTP (generateProxyConfig.ts#generateHaproxyConfig). Rien à écrire à la main :

  use_backend be_nodefony if { hdr(upgrade) -i websocket }
  default_backend be_nodefony_h2c

backend be_nodefony_h2c
  option httpchk GET /readyz
  http-check expect status 200
  # la sonde vise le port HTTP en HTTP/1.1 : le port h2c refuse HTTP/1.1
  server nodefony_h2c 10.0.0.12:5153 check port 5151 check-proto h1 proto h2

La commande lit la configuration de l’environnement où elle tourne : lancez-la avec h2c activé comme en production, sinon elle produit la variante HTTP/1.1. Avec --reencrypt, h2c est ignoré — le port TLS négocie déjà HTTP/2.

nginx — HTTP/1.1 vers l’application#

nginx ne parle pas HTTP/2 à ses amonts (hors gRPC, par grpc_pass) : proxy_http_version s’arrête à 1.1. Gardez le port HTTP ; proxy:generate nginx ignore h2c.

Envoy#

Un cluster h2c (http2_protocol_options) pour les requêtes, un cluster HTTP/1.1 pour les WebSocket :

# extrait — route_config et clusters
virtual_hosts:
  - name: app
    domains: ["app.example.com"]
    routes:
      # WebSocket : HTTP/1.1, vers le port HTTP
      - match:
          prefix: "/"
          headers:
            - name: upgrade
              string_match: { exact: websocket, ignore_case: true }
        route:
          {
            cluster: nodefony_http,
            upgrade_configs: [{ upgrade_type: websocket }],
          }
      - match: { prefix: "/" }
        route: { cluster: nodefony_h2c }
clusters:
  - name: nodefony_h2c # port 5153
    typed_extension_protocol_options:
      envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
        "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
        explicit_http_config:
          http2_protocol_options: {}
  - name: nodefony_http # port 5151, HTTP/1.1 par défaut

Istio et Gateway API — par le protocole du port du Service#

Sous Kubernetes, c’est le Service qui déclare le protocole de chaque port :

apiVersion: v1
kind: Service
spec:
  ports:
    - name: http # sondes, WebSocket
      port: 5151
      appProtocol: http
    - name: h2c
      port: 5153
      # Istio : `http2` (ou un nom de port préfixé `http2-`)
      appProtocol: http2
      # Gateway API (GEP-1911) : `kubernetes.io/h2c` à la place

Puis la route envoie les requêtes au port 5153 et Upgrade: websocket au port 5151 — un VirtualService (Istio) ou une HTTPRoute (Gateway API) avec une règle sur l’en-tête upgrade. Le Service des WebSocket peut déclarer appProtocol: kubernetes.io/ws sous Gateway API.

Traefik#

Un service au schéma h2c, et un routeur prioritaire qui garde les WebSocket sur le port HTTP :

labels:
  - "traefik.http.routers.app.rule=Host(`app.example.com`)"
  - "traefik.http.routers.app.service=app-h2c"
  - "traefik.http.services.app-h2c.loadbalancer.server.port=5153"
  - "traefik.http.services.app-h2c.loadbalancer.server.scheme=h2c"
  - "traefik.http.routers.app-ws.rule=Host(`app.example.com`) && HeaderRegexp(`Upgrade`, `(?i)^websocket$`)"
  - "traefik.http.routers.app-ws.priority=100"
  - "traefik.http.routers.app-ws.service=app-http"
  - "traefik.http.services.app-http.loadbalancer.server.port=5151"

Ce qui a été joué contre un proxy réel : HAProxy 3.0, avec la configuration générée — requête servie en h2c, sonde /readyz lue sur le port HTTP, et un exemplaire dont la sonde ne répond pas 200 retiré alors que son port accepte. C’est ce banc qui a trouvé le défaut suivant, invisible aux tests directs : HAProxy, comme le veut RFC 9113 §8.3.1, convertit une requête HTTP/1.1 sans fabriquer :authority — il ne garde que Host, et le serveur, qui ne lisait que :authority, répondait 421. Il lit désormais Host en repli (transport.ts#requestAuthority). Envoy, Istio et Traefik : recettes tirées de leur documentation, pas encore jouées par le banc.

⚠️ Pièges (symptôme → cause → correction)#

Symptôme Cause Correction
Toutes les IP des journaux sont celles du proxy trustProxy est resté à false — le défaut sûr déclarer l’adresse ou le CIDR du proxy, jamais true s’il y a plusieurs entrées
Une IP cliente forgée apparaît dans l’audit trustProxy: true alors que l’application est joignable directement, ou un proxy qui ajoute au lieu d’écraser fermer l’accès direct au port applicatif, et vérifier que le proxy pose $remote_addr
Le WebSocket se ferme tout seul au bout d’une minute l’inactivité du proxy est plus courte que le battement de l’application régénérer la configuration — le délai en est dérivé — ou allonger l’inactivité à la main
Le WebSocket ne s’ouvre pas, aucune erreur nulle part Upgrade/Connection ne sont pas relayés proxy:generate les pose ; sur Traefik c’est natif ; sur un proxy écrit à la main, les ajouter
413 sur un envoi que l’application accepte pourtant la limite de corps du proxy est plus basse que maxBodySize régénérer : client_max_body_size est dérivé de la configuration applicative
Les statiques d’un module répondent 404 derrière nginx une seule racine statique déclarée, alors que chaque module monte la sienne régénérer : la chaîne de try_files est construite depuis les montages réels
Le déploiement coupe des requêtes en cours le proxy interroge /livez au lieu de /readyz /readyz bascule en 503 dès le début de l’arrêt ; /livez reste à 200 pendant le drain
Un flux SSE n’arrive qu’en bloc, ou à la fin le proxy met la réponse en tampon ne pas filtrer X-Accel-Buffering ; sur un ingress, proxy-buffering: "off"
Les cookies de session ne reviennent jamais X-Forwarded-Proto annonce http alors que le client est en https, donc le cookie Secure est refusé le schéma se constate sur la connexion entrante, il ne se suppose pas
Derrière un proxy en h2c, la WebSocket ne s’ouvre plus le proxy l’envoie au port h2c, qui ne sert ni Upgrade ni le CONNECT étendu router Upgrade: websocket vers le port HTTP — proxy:generate haproxy le fait
Le pod n’est jamais prêt depuis l’activation de h2c la sonde vise le port h2c, qui refuse HTTP/1.1 sonder /readyz sur le port HTTP
Keycloak affiche Invalid parameter: redirect_uri en ligne, pas en local NF_OAUTH_REDIRECT_BASE absente : l’adresse de retour vaut encore https://localhost:<port> la poser à l’URL publique de l’application
Retour de connexion refusé, WARNING OAuth issuer mismatch le Keycloak, derrière son propre proxy, prend son nom de l’hôte demandé KC_HOSTNAME = l’URL publique complète

🔴 Le piège du proxy en cascade. Un X-Forwarded-For correct chez le proxy de tête devient faux dès qu’un second proxy s’intercale sans être déclaré. La résolution remonte la chaîne de droite à gauche depuis la socket réelle : chaque saut de confiance doit être listé, sinon la remontée s’arrête au premier inconnu — ce qui est exactement le comportement voulu.

🧪 Tests & couverture#

Les chiffres exacts vivent dans la carte de l’aperçu, régénérée en comptant — jamais figés ici.

Type Où Ce qui est prouvé
Unitaires — générateur unit/generateProxyConfig.test.ts ce que produisent les deux cibles, à partir d’une introspection donnée
Unitaires — confiance unit/trustProxy.test.ts CIDR, préréglages, listes — quelle socket a le droit d’être crue
Unitaires — résolution unit/forwarded.test.ts, unit/forwardedWiring.test.ts la remontée RFC 7239, et son câblage dans le pipeline
Intégration — h2c http/h2c.test.ts le port h2c tel qu’un proxy le voit : Host sans :authority, flux multiplexés, WebSocket et HTTP/1.1 refusés, relais interne
Intégration — proxys RÉELS integration/reverse-proxy.test.ts les raccords contre nginx et HAProxy en marche, derrière PROXY_GATE (scripts/test/vitest/gates.ts#PROXY_GATE) ; et un HAProxy 3.0 lancé sur la configuration générée : sonde /readyz, retrait sur 503, h2c, syntaxe des variantes

Ce qui a été attrapé par le banc réel, et par rien d’autre : proxy:generate annonçait proto=https à un client venu en clair. Aucun test unitaire ne pouvait le voir — il fallait un proxy qui termine vraiment TLS.

Ce banc se skippe sans son décor, et un skip compte comme vert : ses trois variables (NF_PROXY_NGINX_URL, NF_PROXY_HAPROXY_URL, NF_PROXY_HAPROXY_TLS_URL) doivent être posées — npm run test:all s’en charge — avant de conclure qu’il est passé.

🔗 Pour aller plus loin#