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.
- L’application ignore
X-Forwarded-Fortant quetrustProxyvautfalse: sans cela, n’importe quel client s’inventerait une IP, contournerait la limitation de débit et falsifierait l’audit. - Le proxy, lui, doit écraser l’en-tête entrant plutôt que d’y ajouter sa ligne. Un proxy qui concatène laisse le client choisir le début de la chaîne.
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#
- Reverse-proxy — le serveur qui reçoit le trafic public et le relaie à l’application. Il termine TLS, sert parfois les statiques, et décide de ce que l’application apprendra du client.
- Edge — le proxy le plus en amont, celui qui voit l’adresse réelle du client. C’est le seul
qui a le droit d’écrire
X-Forwarded-For. - Upgrade — la bascule d’une requête HTTP vers le protocole WebSocket. Un proxy qui ne la relaie pas explicitement la transforme en réponse HTTP ordinaire.
- Tunnel — une connexion que le proxy ne fait que transporter, sans la comprendre. Un WebSocket en est un, et il vit bien plus longtemps qu’une requête.
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 :
KC_HOSTNAMEen URL complète (https://sso.example.com) : sans elle, Keycloak prend son nom de l’adresse par laquelle on l’interroge, et l’application refuse un émetteur qui varie (RFC 8414 §3.3) ;- si l’application joint Keycloak par une autre adresse que les navigateurs (réseau interne),
KC_HOSTNAME_BACKCHANNEL_DYNAMIC=truecôté Keycloak etNF_KEYCLOAK_DISCOVERY_URLcôté application — le pas à pas est dans Keycloak › En cluster.
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
/readyzlue sur le port HTTP, et un exemplaire dont la sonde ne répond pas200retiré 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 queHost, et le serveur, qui ne lisait que:authority, répondait421. Il lit désormaisHosten 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-Forcorrect 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:generateannonçaitproto=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:alls’en charge — avant de conclure qu’il est passé.
🔗 Pour aller plus loin#
- ⬆️ Retour au hub : Guides · Toute la documentation
- 🛡️ Ce que l’application attend d’un proxy, en détail : servers
- 🐳 Le conteneur et l’orchestrateur au-dessus :
docker-cloud-native.md - 🗝️ Pourquoi les sessions décident de votre scaling :
session-storage.md - 📖 Lexique général du framework.