Mettre en place un reverse proxy Nginx avec SSL

Credit : Logo officiel

Mettre en place un reverse proxy Nginx avec SSL

Dylan D. — Agent Support Technique Serveur Linux 1834 mots 10 min de lecture

Mettre en place un reverse proxy Nginx avec SSL

Un client lance une app Node.js sur son VPS IONOS, en deploiement direct sur le port 3000. Au bout de 48h il me passe un message paniqué : son app crashe avec des milliers de requetes/seconde, des bots tentent de la fuzzer, et son IP est sur tous les annuaires de scanners. Premiere chose que je fais : je passe son app derriere un reverse proxy Nginx avec SSL, rate limiting et headers de securite. Cinq minutes plus tard, le port 3000 est ferme au monde exterieur, l'app respire, et son score SSL Labs est passe de F a A+.

Le reverse proxy c'est mon outil prefere pour exposer proprement des apps backend (Node, Python, Go, Java) sur Internet. Voici la config complete que j'utilise sur mes serveurs Debian 12 en production.

Le principe

Le client se connecte a Nginx sur le port 443 en HTTPS. Nginx termine le SSL, ajoute les headers de securite, applique du rate limiting si besoin, puis transmet la requete a l'app interne (en HTTP localhost) et renvoie la reponse. L'application n'est jamais exposee directement.

Client (HTTPS) --> Nginx (443/SSL/headers) --> App Node.js (127.0.0.1:3000)

Avantages de cette architecture :

Prerequis : firewall et user dedie

Avant de configurer Nginx, ferme les ports applicatifs au niveau du firewall :

# Avec UFW (Debian/Ubuntu)
ufw default deny incoming
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable

# Verifier que le port 3000 n'est PAS ouvert
ufw status verbose

Lance ton app en bind explicite sur localhost (pas 0.0.0.0) pour eviter qu'elle soit accessible meme par accident :

// Dans server.js
app.listen(3000, '127.0.0.1', () => {
    console.log('App ready on localhost:3000');
});

Configuration de base avec SSL

Cree /etc/nginx/sites-available/app.monsite.fr :

upstream backend_app {
    server 127.0.0.1:3000 max_fails=3 fail_timeout=30s;
    keepalive 32;
    keepalive_requests 100;
    keepalive_timeout 60s;
}

# Redirection HTTP -> HTTPS
server {
    listen 80;
    listen [::]:80;
    server_name app.monsite.fr;

    # Endpoint Let's Encrypt
    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    location / {
        return 301 https://$server_name$request_uri;
    }
}

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name app.monsite.fr;

    # ===== SSL =====
    ssl_certificate /etc/letsencrypt/live/app.monsite.fr/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/app.monsite.fr/privkey.pem;
    ssl_trusted_certificate /etc/letsencrypt/live/app.monsite.fr/chain.pem;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
    ssl_prefer_server_ciphers off;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;

    # OCSP stapling
    ssl_stapling on;
    ssl_stapling_verify on;
    resolver 1.1.1.1 8.8.8.8 valid=300s;
    resolver_timeout 5s;

    # ===== Headers de securite =====
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
    add_header X-Frame-Options DENY always;
    add_header X-Content-Type-Options nosniff always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Referrer-Policy strict-origin-when-cross-origin always;
    add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
    add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:;" always;

    # Cacher la version Nginx
    server_tokens off;

    # ===== Logs =====
    access_log /var/log/nginx/app.monsite.fr.access.log;
    error_log /var/log/nginx/app.monsite.fr.error.log warn;

    # ===== Limites =====
    client_max_body_size 10M;
    client_body_timeout 30s;
    client_header_timeout 30s;

    location / {
        proxy_pass http://backend_app;
        proxy_http_version 1.1;

        # Headers vers l'app
        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;
        proxy_set_header X-Forwarded-Host $host;
        proxy_set_header X-Forwarded-Port $server_port;
        proxy_set_header Connection "";

        # Timeouts
        proxy_connect_timeout 10s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;

        # Buffering
        proxy_buffering on;
        proxy_buffer_size 4k;
        proxy_buffers 8 16k;
        proxy_busy_buffers_size 32k;

        # Reessayer en cas d'erreur
        proxy_next_upstream error timeout http_502 http_503 http_504;
        proxy_next_upstream_tries 3;
    }

    # Healthcheck
    location /health {
        proxy_pass http://backend_app/health;
        access_log off;
    }
}

Support WebSocket

Si ton app utilise des WebSockets (Socket.io, ws, channels Django, etc.), ajoute un bloc dedie. Les headers Upgrade et Connection "upgrade" sont indispensables, sinon la connexion ne passe jamais en mode WebSocket :

location /socket.io/ {
    proxy_pass http://backend_app;
    proxy_http_version 1.1;

    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    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;

    # WebSocket : timeouts longs
    proxy_read_timeout 86400s;
    proxy_send_timeout 86400s;

    # Pas de buffering pour les events temps reel
    proxy_buffering off;
}

Pour gerer dynamiquement upgrade/non-upgrade, utilise une map dans le bloc http :

# Dans /etc/nginx/nginx.conf, bloc http {
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

Puis dans ton location : proxy_set_header Connection $connection_upgrade;

Certificat SSL avec Certbot

Installe Certbot avec le plugin Nginx :

apt install certbot python3-certbot-nginx -y

# Genere le certificat (mode interactif)
certbot --nginx -d app.monsite.fr

# Ou en mode standalone si Nginx n'est pas encore configure
certbot certonly --webroot -w /var/www/certbot -d app.monsite.fr

Le renouvellement est automatique via un timer systemd. Verifie quand meme :

certbot renew --dry-run
systemctl list-timers | grep certbot
# NEXT                        LEFT     LAST                        PASSED      UNIT             ACTIVATES
# Wed 2026-05-08 02:47:52 CEST 14h left Tue 2026-05-07 02:47:52 CEST 9h ago      certbot.timer    certbot.service

Verifie le score SSL avec ssllabs.com/ssltest. Tu dois viser A+ avec la config ci-dessus.

Rate limiting et protection

Le rate limiting evite qu'un bot ou une attaque saturent ton app. Dans /etc/nginx/nginx.conf, bloc http { :

# Zones de rate limiting
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=5r/m;
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;

Dans ton vhost :

location /api/ {
    limit_req zone=api_limit burst=20 nodelay;
    limit_conn conn_limit 10;
    proxy_pass http://backend_app;
    # ... autres directives proxy
}

location /api/auth/login {
    limit_req zone=auth_limit burst=3 nodelay;
    proxy_pass http://backend_app;
}

5 requetes/minute sur le login, 10 req/s sur l'API, max 10 connexions simultanees par IP. Suffisant pour bloquer la plupart des bots tout en laissant passer les utilisateurs legitimes.

Compression et cache

Dans /etc/nginx/nginx.conf, bloc http { :

# Gzip
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss application/atom+xml image/svg+xml;

# Brotli (si module installe)
brotli on;
brotli_comp_level 6;
brotli_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss image/svg+xml;

Pour les assets statiques servis par l'app, mets-les en cache au niveau Nginx :

location ~* \.(jpg|jpeg|png|gif|webp|svg|woff2|css|js)$ {
    proxy_pass http://backend_app;
    proxy_cache_valid 200 30d;
    expires 30d;
    add_header Cache-Control "public, immutable";
    access_log off;
}

Proxifier plusieurs applications

Un seul Nginx peut proxifier plein d'apps differentes :

# api.monsite.fr -> Node.js sur 3000
# admin.monsite.fr -> Python sur 8000
# grafana.monsite.fr -> Grafana sur 3001
# notion.monsite.fr -> AppFlowy sur 8080

Chaque sous-domaine a son propre fichier dans sites-available et son certificat. Pour eviter la multiplication de certs Let's Encrypt, utilise un wildcard *.monsite.fr via le challenge DNS :

certbot certonly --manual --preferred-challenges dns -d "*.monsite.fr" -d monsite.fr

Erreurs courantes et leur fix

502 Bad Gateway

Cause : Nginx arrive a se connecter au backend mais le backend ne repond pas correctement (crash, timeout, ferme la connexion).

Fix :

# Verifie que l'app tourne
ss -tlnp | grep 3000
# Lis les logs Nginx
tail -f /var/log/nginx/app.monsite.fr.error.log
# Teste le backend en direct
curl -v http://127.0.0.1:3000/

WebSocket connection failed

Cause : il manque les headers Upgrade/Connection dans le block location WebSocket.

Fix : verifie que le bloc /socket.io/ (ou equivalent) a bien proxy_set_header Upgrade $http_upgrade; et proxy_set_header Connection "upgrade";

nginx: [emerg] SSL_CTX_use_PrivateKey_file failed

Cause : chemin incorrect vers le certificat ou permissions trop strictes.

Fix :

ls -la /etc/letsencrypt/live/app.monsite.fr/
nginx -t
# Verifie que les fichiers existent et que Nginx peut les lire

Mixed content blocked

Cause : l'app backend genere des URLs en http:// alors que le proxy est en https://.

Fix : passe le bon scheme via X-Forwarded-Proto et configure ton app pour le respecter. En Express : app.set('trust proxy', 1). En Django : SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https').

IP affichee = 127.0.0.1 dans les logs de l'app

Cause : l'app loggue req.ip qui est l'IP de Nginx, pas du client.

Fix : utilise X-Forwarded-For ou X-Real-IP dans ton app. Verifie aussi que tu fais confiance au proxy (trust proxy en Express).

Tester et activer

ln -s /etc/nginx/sites-available/app.monsite.fr /etc/nginx/sites-enabled/
nginx -t
# nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
# nginx: configuration file /etc/nginx/nginx.conf test is successful
systemctl reload nginx

Test final :

curl -I https://app.monsite.fr
# HTTP/2 200
# server: nginx
# strict-transport-security: max-age=63072000; includeSubDomains; preload
# x-frame-options: DENY
# x-content-type-options: nosniff
# referrer-policy: strict-origin-when-cross-origin

Load balancing : plusieurs backends

Quand ton app monte en charge, tu peux faire tourner plusieurs instances et laisser Nginx repartir le trafic :

upstream backend_app {
    least_conn;   # Methode de balancing (least_conn, ip_hash, round-robin)
    server 127.0.0.1:3000 weight=3 max_fails=3 fail_timeout=30s;
    server 127.0.0.1:3001 weight=2 max_fails=3 fail_timeout=30s;
    server 127.0.0.1:3002 weight=1 backup;   # Backup (utilise si les autres tombent)
    keepalive 32;
}

Methodes de balancing disponibles :

Pour faire tourner plusieurs instances Node sur differents ports, utilise PM2 ou une unite systemd template :

# /etc/systemd/system/mon-app@.service
[Unit]
Description=Mon app instance %i

[Service]
Type=simple
User=www-data
WorkingDirectory=/var/www/mon-app
Environment=PORT=%i
ExecStart=/usr/bin/node server.js
Restart=on-failure

Puis :

systemctl enable --now mon-app@3000.service
systemctl enable --now mon-app@3001.service
systemctl enable --now mon-app@3002.service

Bonus : monitoring du proxy

Active le module stub_status pour avoir des metriques basiques :

server {
    listen 127.0.0.1:8080;
    location /nginx_status {
        stub_status on;
        allow 127.0.0.1;
        deny all;
    }
}
curl http://127.0.0.1:8080/nginx_status
# Active connections: 142
# server accepts handled requests
# 18923 18923 234567
# Reading: 0 Writing: 12 Waiting: 130

Ces metriques peuvent etre scrappees par Prometheus via nginx-exporter, ou affichees dans un dashboard Grafana. Tres utile pour reperer les pics de trafic et les saturations de connexion.

Pour aller plus loin

Le proxy comme rempart

Quinze minutes de config et ton app passe de directement exposee a proprement isolee derriere Nginx avec SSL, headers de securite, rate limiting et logs propres. C'est le minimum pour exposer une app moderne en 2026, et c'est ce que je mets sur tous les serveurs que je gere. Une fois que t'as ce squelette, tu le clones pour chaque nouvelle app et tu adaptes deux ou trois lignes. Gain de temps massif et serveur drastiquement plus solide.

# Articles similaires

Sur les memes sujets et plus loin