---
title: "Mettre un reverse-proxy devant Nodefony (nginx, HAProxy, Traefik)"
source: docs/guides/reverse-proxy.md
url: https://nodefony.github.io/nodefony-core/docs/guides/reverse-proxy/
---


📍 [Documentation](../../) › [Guides](../) › **Reverse-proxy**

[servers](../../modules/http/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-For` tant que `trustProxy` vaut `false` : 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.

```mermaid
flowchart LR
  C["Client"] -->|"X-Forwarded-For forgé ?"| P["Reverse-proxy"]
  P -->|"écrase avec l'IP RÉELLE"| A["Nodefony"]
  A -->|"trustProxy: l'adresse du proxy"| V["IP cliente de confiance"]
```

## 📖 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

```bash
# 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` :

```yaml
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é

```typescript
// 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](../../modules/http/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.

```bash
# 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_HOSTNAME` en **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=true` côté Keycloak et `NF_KEYCLOAK_DISCOVERY_URL` côté
  application — le pas à pas est dans
  [Keycloak › En cluster](../../modules/security/keycloak/#-en-cluster--joindre-keycloak-par-son-adresse-interne).

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 :

```yaml
# 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 ?](../../modules/http/servers/#faut-il-lactiver---h2c-nest-pas--plus-rapide--par-principe).

```typescript
// 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é](#et-côté-application--lautre-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 :

```text
  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 :

```yaml
# 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 :

```yaml
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 :

```yaml
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

- ⬆️ **Retour au hub** : [Guides](../) · [Toute la documentation](../../)
- 🛡️ **Ce que l'application attend d'un proxy**, en détail :
  [servers](../../modules/http/servers/)
- 🐳 **Le conteneur et l'orchestrateur au-dessus** : [`docker-cloud-native.md`](../docker-cloud-native/)
- 🗝️ **Pourquoi les sessions décident de votre scaling** : [`session-storage.md`](../session-storage/)
- 📖 [Lexique général](../../lexique/) du framework.
