Configurer nginx en reverse proxy
De proxy_pass à l'upgrade WebSocket : le guide complet de nginx en reverse proxy, avec les quatre en-têtes sans lesquels votre application prend chaque visiteur pour 127.0.0.1.
Presque toutes les applications modernes écoutent quelque part sur un port élevé : Node sur 3000, un conteneur (container) Docker sur 8080, Gunicorn sur 8000, un serveur applicatif Java sur 8443. On ne met pas cela directement sur Internet. Devant, il faut un reverse proxy, et en pratique, c'est nginx.
La configuration de base tient en cinq lignes. C'est précisément là qu'est le problème : ces cinq lignes ont l'air de fonctionner, puis trois semaines plus tard, vous constatez que chaque visiteur apparaît comme 127.0.0.1 dans le log applicatif, que le rate limiting de la connexion bloque tout le monde en même temps, et que l'e-mail de réinitialisation du mot de passe contient un lien vers http://127.0.0.1:3000. Cet article traite exactement de ces points.
Le prérequis est un nginx déjà installé. Si ce n'est pas encore le cas, consultez installer nginx sur Debian et Ubuntu.
Ce que fait réellement un reverse proxy sur le plan technique
nginx accepte la connexion du visiteur, puis ouvre vers l'application une seconde connexion, la sienne. C'est le point décisif dont découlent tous les autres problèmes.
Du point de vue de l'application, le client n'est pas le visiteur mais nginx. L'adresse IP source est 127.0.0.1. Le protocole est http, même si HTTPS était utilisé à l'extérieur. L'en-tête Host vaut par défaut 127.0.0.1:3000 et non app.example.com. Et c'est du HTTP/1.0 au lieu de HTTP/1.1, raison pour laquelle les WebSockets échouent systématiquement sans configuration supplémentaire.
Tout ce que l'application doit savoir du véritable visiteur, nginx doit le lui transmettre activement sous forme d'en-têtes HTTP. Cela ne se fait pas tout seul.
La configuration de base, et où elle doit se trouver
L'emplacement du fichier diffère selon le système, et c'est une confusion récurrente.
Debian et Ubuntu : la configuration se trouve dans /etc/nginx/sites-available/app.conf et s'active par un lien symbolique dans /etc/nginx/sites-enabled/. Sinon, le bloc serveur default intercepte toutes les requêtes.
AlmaLinux, Rocky, RHEL et Oracle Linux : là, sites-available n'existe tout simplement pas. Le fichier va directement dans /etc/nginx/conf.d/app.conf et devient actif immédiatement.
server {
listen 80;
listen [::]:80;
server_name app.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
}
}
Activation sur Debian et Ubuntu :
ln -s /etc/nginx/sites-available/app.conf /etc/nginx/sites-enabled/
rm -f /etc/nginx/sites-enabled/default
nginx -t
systemctl reload nginx
nginx -t avant chaque rechargement n'est pas une politesse, c'est une obligation. Un systemctl reload avec une configuration erronée laisse certes tourner l'ancien processus, mais au prochain redémarrage du serveur, nginx ne remontera plus du tout.
Toutes les commandes de cette section supposent les droits root, sinon travaillez avec sudo devant. Cela vaut aussi pour les simples commandes de vérification : nginx -t et nginx -T lancés en utilisateur normal n'échouent pas à cause de la configuration, mais à cause d'un fichier qu'ils n'ont pas le droit d'écrire. Le message [emerg] open() "/run/nginx.pid" failed (13: Permission denied) suivi de configuration file /etc/nginx/nginx.conf test failed ne signifie donc pas que votre configuration est cassée.
Cette configuration relaie bien les requêtes. Elle est pourtant cassée, et d'une manière qui ne se révèle que plus tard.
Les quatre en-têtes sans lesquels l'application est aveugle
Ces quatre lignes doivent figurer dans chaque bloc location contenant un proxy_pass :
location / {
proxy_pass http://127.0.0.1:3000;
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;
}
Host
Sans cette ligne, nginx envoie Host: 127.0.0.1:3000. Les symptômes : Django répond Invalid HTTP_HOST header et renvoie un 400. Laravel et WordPress génèrent des URL absolues vers 127.0.0.1, visibles dans les redirections et dans chaque e-mail envoyé. Un backend qui dessert plusieurs domaines livre toujours le mauvais tenant.
$host est ici le bon choix, et non $http_host : $host contient le nom d'hôte sans le port et retombe sur le server_name si un client n'envoie aucun en-tête Host. $http_host transmet tel quel ce qui arrive, indication de port comprise.
X-Real-IP
$remote_addr est l'adresse IP avec laquelle nginx dialogue réellement. Exactement une adresse, sans virgule, sans parsing nécessaire. Pour les applications qui ne connaissent qu'un seul champ pour l'IP client, c'est la voie la plus simple.
X-Forwarded-For
$proxy_add_x_forwarded_for reprend un X-Forwarded-For éventuellement déjà présent et y ajoute $remote_addr à droite. Avec plusieurs proxys, une chaîne se constitue ainsi.
Et c'est là que se cache une faille de sécurité que presque aucun tutoriel ne mentionne : un client peut envoyer lui-même un X-Forwarded-For. Si votre nginx est directement exposé à Internet, $proxy_add_x_forwarded_for fusionne dans une seule liste une valeur librement inventée par l'attaquant et votre véritable IP client. Si votre application lit alors la première entrée comme IP client, elle croit n'importe quelle adresse. Rate limits, blocages d'IP et logique géographique deviennent contournables.
Deux conséquences saines :
- La chaîne se lit toujours de droite à gauche. La dernière entrée est la seule que votre propre proxy a écrite.
- Si nginx est la seule instance devant l'application, écrasez plutôt la chaîne complètement au lieu de la compléter :
proxy_set_header X-Forwarded-For $remote_addr;
Tout historique falsifié disparaît ainsi. $proxy_add_x_forwarded_for n'est correct que si un load balancer ou un CDN auquel vous faites réellement confiance se trouve en amont. Dans ce cas, il faut en plus configurer le module realip, afin que nginx lui-même connaisse la véritable IP et ne journalise pas celle de son prédécesseur :
set_real_ip_from 10.0.0.0/8;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
L'indication set_real_ip_from est une liste blanche. Sans elle, le module reste sans effet, et avec une plage trop large comme 0.0.0.0/0, c'est une porte ouverte.
X-Forwarded-Proto
L'application voit une connexion purement HTTP, quoi qu'il se passe à l'extérieur. Si cet en-tête manque, voici ce qui arrive typiquement : l'application constate « pas de HTTPS » et redirige vers HTTPS, nginx reçoit la requête, la déchiffre, la transmet de nouveau en HTTP, et l'application redirige encore. Le navigateur affiche ERR_TOO_MANY_REDIRECTS. Tout aussi fréquent : les cookies portant l'attribut Secure ne sont pas posés, les connexions échouent sans message d'erreur, et les pages chargent images et scripts via http://, ce que le navigateur bloque en tant que contenu mixte.
Utilisez $scheme et non la valeur fixe "https". Sinon, le bloc du port 80 prétend lui aussi que la connexion était chiffrée.
La preuve que les en-têtes arrivent vraiment
Plutôt que de deviner, construisez-vous un miroir. Ce bloc serveur supplémentaire répond à chaque requête avec les en-têtes reçus, en clair :
server {
listen 127.0.0.1:9999;
default_type text/plain;
location / {
return 200 "Host: $host\nX-Real-IP: $http_x_real_ip\nX-Forwarded-For: $http_x_forwarded_for\nX-Forwarded-Proto: $http_x_forwarded_proto\nProtocole: $server_protocol\n";
}
}
Faites pointer proxy_pass à titre de test vers http://127.0.0.1:9999, rechargez nginx et appelez la page. Ce qui s'y affiche est exactement ce que votre application aurait reçu autrement. Si les quatre lignes sont remplies et que l'IP correspond à votre véritable connexion, la configuration est bonne. Supprimez ensuite le bloc de test.
L'application doit toutefois aussi exploiter ces en-têtes. Express a besoin de app.set('trust proxy', 1), Symfony du paramètre trusted_proxies, Django de USE_X_FORWARDED_HOST et SECURE_PROXY_SSL_HEADER. Sans ce commutateur, les frameworks ignorent délibérément les en-têtes, pour la raison de spoofing décrite plus haut.
proxy_pass et la barre oblique qui change tout
L'erreur silencieuse la plus fréquente de toute la configuration nginx. Un seul caractère décide du chemin qui arrive au backend.
| Configuration | Requête | Le backend reçoit |
|---|---|---|
location /api/ { proxy_pass http://127.0.0.1:3000; } | /api/users | /api/users |
location /api/ { proxy_pass http://127.0.0.1:3000/; } | /api/users | /users |
location /api/ { proxy_pass http://127.0.0.1:3000/v2/; } | /api/users | /v2/users |
La règle : dès qu'un chemin quelconque suit l'hôte et le port, ne serait-ce qu'une simple barre oblique, nginx remplace la partie de l'URL qui correspond au préfixe location. Sans indication de chemin, l'URL complète est transmise telle quelle.
Le symptôme est caractéristique : la page d'accueil fonctionne, mais tout ce qui se trouve sous un préfixe renvoie 404, et le log du backend contient des chemins à préfixe doublé comme /api/api/users. Un coup d'œil au log applicatif règle la question en quelques secondes, alors que deviner du côté de nginx coûte des heures.
Deux cas particuliers produisent leurs propres messages d'erreur. Dans un location défini par expression régulière, une indication de chemin est interdite, sinon nginx s'arrête au démarrage avec nginx: [emerg] "proxy_pass" cannot have URI part in location given by regular expression, inside named location, or inside "if" statement. Et dès que vous utilisez une variable dans proxy_pass, par exemple proxy_pass http://$backend;, nginx ne résout plus le nom au démarrage mais à l'exécution. Sans ligne resolver dans le bloc serveur, cela se termine par no resolver defined to resolve ... et un 502.
Relayer les WebSockets
nginx parle par défaut HTTP/1.0 avec le backend. HTTP/1.0 ne connaît pas le mécanisme d'upgrade. C'est pourquoi tout WebSocket échoue avec la configuration de base, aussi correct que soit le reste.
Symptômes typiques : la console du navigateur affiche WebSocket connection to 'wss://app.example.com/ws' failed, souvent avec la mention Error during WebSocket handshake: Unexpected response code: 400. Socket.io bascule silencieusement en long polling, l'application paraît seulement poussive, et le log d'accès répète sans fin des lignes contenant /socket.io/?EIO=4&transport=polling.
D'abord la table de correspondance qui détermine si une connexion demande réellement un upgrade. Elle doit se trouver dans le contexte http, donc de préférence dans un fichier dédié /etc/nginx/conf.d/websocket.conf :
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
Si ce bloc atterrit par mégarde dans un bloc server ou location, nginx ne démarre plus : nginx: [emerg] "map" directive is not allowed here.
Ensuite, dans le bloc location :
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
Pourquoi la table de correspondance et pas simplement proxy_set_header Connection "upgrade"; ? Parce que des requêtes HTTP tout à fait normales demanderaient alors elles aussi un upgrade. Certains backends répondent par un 400, et la réutilisation keepalive disparaît. La table n'envoie upgrade que si le client en a effectivement demandé un, et close dans le cas contraire.
Le test : dans les outils de développement du navigateur, la requête WebSocket doit afficher le statut 101 Switching Protocols. Tout le reste, en particulier 200 ou 400, signifie que l'upgrade n'est pas passé. En ligne de commande, cela fonctionne aussi sans navigateur :
curl -sSi -o /dev/null -w '%{http_code}\n' \
-H 'Connection: Upgrade' -H 'Upgrade: websocket' \
-H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
http://127.0.0.1/ws
Régler correctement les timeouts
Si une connexion se coupe de façon reproductible après exactement 60 secondes, ce n'est pas un hasard, c'est la valeur par défaut de proxy_read_timeout. Point important à comprendre : cette valeur ne limite pas la durée totale de la requête, mais la pause entre deux opérations de lecture. Un téléchargement qui dure dix minutes mais fournit des données en permanence passe sans problème. Un WebSocket sur lequel il ne se passe rien pendant 61 secondes est éjecté.
proxy_connect_timeout 10s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_connect_timeout ne vaut que pour l'établissement de la connexion et est plafonné à 75 secondes. Le monter plus haut ne sert à rien. Pour un backend local, 10 secondes sont généreuses, et une valeur basse vous fait remarquer plus vite que le service ne tourne tout simplement pas.
Pour les WebSockets, la meilleure approche n'est pas proxy_read_timeout 86400s; mais un heartbeat dans l'application, qui envoie un ping toutes les 30 secondes. Le timeout reste ainsi une protection contre les connexions bloquées, au lieu d'être désactivé de fait.
Deux autres valeurs par défaut frappent régulièrement. client_max_body_size vaut 1 MB, et tout envoi plus volumineux se termine par 413 Request Entity Too Large et la ligne de log client intended to send too large body. Et avec des Server-Sent Events ou des réponses en streaming, le visiteur ne reçoit longtemps rien, parce que nginx met en tampon. Dans ce cas, proxy_buffering off; aide dans le bloc location concerné, de façon ciblée et non globale.
Mettre du HTTPS devant
La voie la plus commode est certbot avec le plugin nginx. Il lit le bloc serveur existant, complète la partie TLS et met en place le renouvellement :
apt-get install -y certbot python3-certbot-nginx
certbot --nginx -d app.example.com
Ces noms de paquets valent pour Debian et Ubuntu. Sur AlmaLinux, Rocky Linux et Oracle Linux, certbot ne se trouve pas dans les dépôts de base, et un simple dnf install -y certbot python3-certbot-nginx s'y termine par Error: Unable to find a match. EPEL y est indispensable :
dnf install -y epel-release
dnf install -y certbot python3-certbot-nginx
Sur Oracle Linux 9, le paquet EPEL s'appelle oracle-epel-release-el9, et il faut le cas échéant activer d'abord le dépôt avec dnf config-manager --enable ol9_developer_EPEL.
Pour plusieurs sous-domaines, jetez un œil aux certificats wildcard Let's Encrypt.
Une différence de version qui provoque des avertissements quand on copie des configurations trouvées ailleurs : jusqu'à nginx 1.24, HTTP/2 s'active dans la ligne listen, et à partir de nginx 1.25.1, il existe une directive dédiée. Debian 13 livre nginx 1.26.3 et veut la nouvelle écriture, Debian 12 (1.22.1), Ubuntu 24.04 (1.24.0) et Ubuntu 22.04 (1.18.0) l'ancienne. Du côté Red Hat, la frontière passe exactement au même endroit : AlmaLinux 10 embarque 1.26.3 et donc la nouvelle forme, tandis qu'AlmaLinux 9, Rocky Linux 9 et Oracle Linux 9 embarquent 1.20.1 et ont besoin de l'ancienne.
# nginx à partir de 1.25.1, donc entre autres Debian 13
listen 443 ssl;
http2 on;
# nginx jusqu'à 1.24, donc Debian 12, Ubuntu 24.04 et 22.04
listen 443 ssl http2;
Si vous utilisez l'ancienne forme sur un nginx récent, nginx -t signale : nginx: [warn] the "listen ... http2" directive is deprecated, use the "http2" directive instead. Ce n'est qu'un avertissement, cela continue donc de fonctionner. La nouvelle forme sur un ancien nginx, en revanche, est une erreur de démarrage bloquante : unknown directive "http2".
Pour finir, le point le plus important de tout l'exercice : le backend ne doit pas être exposé lui-même à Internet. Un reverse proxy ne sert à rien si http://server-ip:3000 reste joignable directement, car n'importe qui peut alors définir les en-têtes comme bon lui semble. Liez le service à 127.0.0.1.
Avec Docker, le piège est particulièrement redoutable : -p 3000:3000 publie le port sur toutes les adresses et inscrit des règles qui contournent purement et simplement un pare-feu ufw. La bonne écriture est -p 127.0.0.1:3000:3000. Les détails de la mise en place figurent dans installer Docker sur Debian et Ubuntu.
Si vous proxyez exceptionnellement vers un backend HTTPS, nginx a besoin d'une ligne supplémentaire, sinon il n'envoie aucun nom SNI et l'hôte en face livre le mauvais certificat :
proxy_pass https://backend.intern:8443;
proxy_ssl_server_name on;
Quand ça tourne mal : les messages, mot pour mot
Le premier réflexe est toujours tail -f /var/log/nginx/error.log. La page d'erreur du navigateur ne dit rien, le log dit tout.
Une précision au préalable, pour que la première ligne ne vous déroute pas : sur AlmaLinux, Rocky Linux et Oracle Linux, /var/log/nginx/error.log n'existe pas encore juste après l'installation, il n'apparaît qu'au premier démarrage de nginx. tail répond alors cannot open ... No such file or directory. Sur Debian et Ubuntu, le paquet crée access.log et error.log dès l'installation. Une commande robuste dans les deux mondes :
tail -n 50 /var/log/nginx/error.log 2>/dev/null || journalctl -u nginx -n 50 --no-pager
| Message dans error.log | Signification et remède |
|---|---|
connect() failed (111: Connection refused) while connecting to upstream | Rien n'écoute sur le port cible. Vérifiez l'état du service et contrôlez avec ss -ltnp que le port et l'adresse correspondent à la ligne proxy_pass. Si ss manque, il est fourni sur Debian et Ubuntu par le paquet iproute2, et sur la famille Red Hat par iproute (là sans le 2 dans le nom). |
connect() failed (113: No route to host) | Un pare-feu entre nginx et le backend bloque. Avec des conteneurs, c'est souvent un mauvais réseau. |
connect() to 127.0.0.1:3000 failed (13: Permission denied) while connecting to upstream | Sur AlmaLinux, Rocky, RHEL et Oracle Linux, c'est presque toujours SELinux. Preuve avec ausearch -m AVC -ts recent, remède avec setsebool -P httpd_can_network_connect 1. Sur Debian et Ubuntu, cela ne se produit pas. |
upstream timed out (110: Connection timed out) while reading response header from upstream | Donne un 504. Le backend répond trop lentement. Regardez d'abord de ce côté, et seulement ensuite augmentez proxy_read_timeout. |
upstream prematurely closed connection while reading response header | Donne un 502. Le processus backend est mort pendant la requête, souvent à cause de l'OOM killer. Voir configurer le swap. |
upstream sent too big header while reading response header from upstream | En-têtes de réponse trop volumineux, un classique avec des cookies nombreux ou longs. Définissez proxy_buffer_size 32k; et proxy_buffers 8 32k;. |
no live upstreams while connecting to upstream | Dans un bloc upstream, toutes les cibles ont été marquées comme indisponibles. Pilotez ce comportement via max_fails et fail_timeout. |
Pour les cas particuliers autour du 502, il existe un guide dédié : résoudre un nginx 502 Bad Gateway. Si votre backend n'est pas encore un service qui démarre proprement, mieux vaut d'abord créer un service systemd.
Comment savoir que tout fonctionne vraiment
Cinq vérifications qui, ensemble, sont probantes :
nginx -taffichesyntax is okettest is successful.nginx -Tmontre la configuration complète assemblée. Cherchez-yproxy_set_headeret comptez : les quatre en-têtes doivent figurer dans chaque bloc location concerné. Unproxy_set_headerdans un bloc interne annule tous les en-têtes hérités du bloc externe, et c'est la cause la plus fréquente du fameux « pourtant je l'avais bien défini ».- Le log de l'application contient la véritable IP du visiteur et non
127.0.0.1. - Le bloc serveur miroir ci-dessus renvoie les quatre valeurs remplies, avec
X-Forwarded-Proto: httpslors d'un appel en HTTPS. - Pour les WebSockets : statut 101 dans les outils de développement, et la connexion survit à plus de 60 secondes de silence.
Un format de log qui consigne l'IP transmise est utile en complément. Vous voyez ainsi tout de suite si nginx et l'application désignent la même adresse :
log_format proxied '$remote_addr xff="$http_x_forwarded_for" '
'host=$host "$request" $status $body_bytes_sent '
'upstream=$upstream_addr rt=$request_time urt=$upstream_response_time';
access_log /var/log/nginx/app.access.log proxied;
Les deux valeurs de temps en fin de ligne valent de l'or : $request_time est la durée totale du point de vue du visiteur, $upstream_response_time la durée de la réponse du backend. Si les deux sont proches, c'est le backend qui est lent. S'il y a un écart, cela vient de la liaison vers le client ou de la mise en tampon.
Vous disposez ainsi d'un reverse proxy qui rend l'application non seulement joignable, mais qui lui transmet aussi tout ce qu'elle doit savoir de ses visiteurs. Si vous montez cette configuration sur un système neuf, la checklist pour les nouveaux serveurs root est un bon point de départ pour tout ce qui vient avant.
Questions fréquentes
Pourquoi mon application voit-elle 127.0.0.1 au lieu de la véritable IP du visiteur ?
Quelle est la différence entre X-Real-IP et X-Forwarded-For ?
Pourquoi mes WebSockets ne fonctionnent-ils pas derrière nginx ?
Pourquoi ma connexion se coupe-t-elle toujours après exactement 60 secondes ?
Que provoque la barre oblique à la fin de proxy_pass ?
Pourquoi ai-je un 502 avec Permission denied sur AlmaLinux ?
2026 KernelHost GmbH. Tous droits réservés. Ce guide est protégé par le droit d'auteur. Sa republication sur d'autres sites web, même partielle ou sous une forme modifiée, n'est pas autorisée sans notre accord écrit. Les citations accompagnées de la source et d'un lien sont expressément les bienvenues.

