nginx reverse proxy для Node: рабочий конфиг с TLS
nginx перед Node — паттерн настолько древний, что я его уже автоматически набираю с закрытыми глазами. И всё равно каждый раз что-то выпадает: то WebSocket не пробрасывается, то HSTS никто не выставил, то таймаут отсек длинный SSE-стрим. Соберу свой рабочий конфиг и пройдусь по нюансам.
Контекст: Ubuntu 24.04 на VPS, nginx из дистрибутивного пакета (1.24+), Node-приложение на 127.0.0.1:3000. TLS через Let's Encrypt с certbot.
Зачем перед Node
Node умеет HTTPS сам, и это не плохо. Но nginx даёт три вещи, которые в проде нужны почти всегда:
- Терминация TLS с автообновлением сертификатов и удобной настройкой шифров.
- Раздача статики со своими заголовками кэша, без захода в Node.
- Управление таймаутами, размерами буферов, лимитами без перезапуска приложения.
Плюс на одном VPS обычно живёт пара сервисов: nginx разруливает их по доменам.
Базовый шаблон
Файл /etc/nginx/sites-available/myapp.conf:
upstream myapp_backend {
server 127.0.0.1:3000;
keepalive 32;
}
# Редирект 80 → 443
server {
listen 80;
listen [::]:80;
server_name app.example.ru;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name app.example.ru;
# TLS
ssl_certificate /etc/letsencrypt/live/app.example.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.ru/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256: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;
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 8.8.8.8 valid=300s;
# Заголовки безопасности
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), geolocation=(), microphone=()" always;
# Лимиты
client_max_body_size 25m;
client_body_timeout 60s;
keepalive_timeout 75s;
server_tokens off;
# Сжатие
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_types text/plain text/css application/json application/javascript application/xml+rss text/xml application/xml image/svg+xml;
gzip_min_length 1024;
# Раздача статики напрямую
location /static/ {
alias /opt/myapp/current/public/;
expires 30d;
access_log off;
add_header Cache-Control "public, immutable";
}
location / {
proxy_pass http://myapp_backend;
proxy_http_version 1.1;
proxy_set_header Connection '';
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_redirect off;
proxy_buffering on;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
location = /favicon.ico {
log_not_found off;
access_log off;
}
}Заметки по конфигу:
http2 on;в новых nginx — отдельная директива, а не флаг к listen. На старых версиях писалlisten 443 ssl http2;.keepalive 32в upstream — даёт переиспользование соединений до Node. На активных API экономит десятки миллисекунд на запрос.proxy_set_header Connection '';вместе сproxy_http_version 1.1;— важная пара. Иначе keepalive не работает, и соединения дёргаются по одному на запрос.X-Forwarded-Proto $schemeи в Nodeapp.set('trust proxy', 1)— без этого редиректы могут уходить на http://, потому что Node не знает, что снаружи https.
WebSocket и Server-Sent Events
Если у тебя есть real-time, конфиг для проксирования соединений с upgrade:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
location /ws/ {
proxy_pass http://myapp_backend;
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 $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}Для SSE дополнительно нужно отключить буферизацию — иначе nginx будет копить ответ и отправлять чанками раз в секунду, а не сразу:
location /events/ {
proxy_pass http://myapp_backend;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 24h;
chunked_transfer_encoding on;
}Лимиты на тело запроса
По умолчанию nginx режет тело на 1 MB. Если у тебя загрузка изображений, видео, документов — поднимай client_max_body_size. Я обычно ставлю 25–50 MB для картинок, отдельные ручки разнимаю в отдельные location с большими лимитами.
location /api/uploads/ {
client_max_body_size 100m;
proxy_pass http://myapp_backend;
proxy_http_version 1.1;
proxy_request_buffering off;
proxy_set_header Connection '';
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}proxy_request_buffering off — важно для больших загрузок. Иначе nginx сначала читает весь файл к себе на диск, и только потом передаёт в Node.
Rate limit на стороне nginx
Когда хочется простой защиты до того, как запрос дойдёт до Node:
limit_req_zone $binary_remote_addr zone=auth:10m rate=20r/m;
server {
# ...
location ~ ^/api/auth/(sign-in|sign-up|forgot-password)$ {
limit_req zone=auth burst=10 nodelay;
proxy_pass http://myapp_backend;
# ... остальные proxy_set_header
}
}20 запросов в минуту с одного IP на чувствительные ручки. Если кто-то прилёг с brute force — ловит 503 ещё до Node. Это в дополнение к rate limit в самом приложении, не вместо.
HTTP/3 и QUIC
На свежих nginx есть поддержка HTTP/3. Включается отдельной директивой и слушает UDP/443. На моих сервисах я её включаю осторожно: пользователи не каждый броузер ходит через QUIC, а отладка тоньше.
server {
listen 443 ssl;
listen [::]:443 ssl;
listen 443 quic reuseport;
listen [::]:443 quic reuseport;
http2 on;
http3 on;
add_header Alt-Svc 'h3=":443"; ma=86400' always;
# ...
}UDP/443 нужно открыть в файрволе. Если приложение работает идеально на HTTP/2, не торопись на HTTP/3 без причин.
Логи
По умолчанию nginx пишет всё в общий access.log. Я предпочитаю отдельный лог на каждое приложение и формат, удобный для парсера:
log_format main_json escape=json
'{ "time": "$time_iso8601", '
'"client": "$remote_addr", '
'"host": "$host", '
'"method": "$request_method", '
'"uri": "$request_uri", '
'"status": $status, '
'"size": $body_bytes_sent, '
'"duration": $request_time, '
'"upstream_duration": "$upstream_response_time", '
'"referer": "$http_referer", '
'"ua": "$http_user_agent" }';
server {
# ...
access_log /var/log/nginx/myapp_access.log main_json;
error_log /var/log/nginx/myapp_error.log warn;
}JSON-логи легко парсятся vector, fluent-bit, любой ELK-частью. upstream_response_time покажет, что тормозит — Node или сам nginx.
Конфиг проверять до перезапуска
sudo nginx -t # проверить синтаксис
sudo systemctl reload nginx
# в случае проблем:
sudo journalctl -u nginx -n 100Reload не перезапускает мастер-процесс, активные соединения остаются. Если конфиг сломан — старый продолжает работать, и сайт не падает. На моей памяти 99% всех «у меня сайт упал после деплоя» — забыли nginx -t и сделали reload с битым конфигом.
Несколько частых граблей
504 Gateway Timeout
nginx по умолчанию ждёт 60 секунд ответа от upstream. Если у тебя длинный запрос — увеличивай proxy_read_timeout. Лучше при этом задуматься, нужен ли такой длинный запрос вообще.
«Domain не подходит» в Let's Encrypt
certbot в режиме webroot хочет видеть live HTTP/80 с открытой /.well-known/acme-challenge/. У меня в каждом 80-серверном блоке есть отдельный location с root /var/www/certbot — иначе обновление падает.
Бесконечный 502
Node-процесс умер, nginx это видит и возвращает 502. systemd должен перезапустить процесс. Проверяй journalctl -u myapp — почти всегда понятно, что упало.
HSTS на dev-домене
HSTS прибит к домену надолго. Если ты включишь его на тестовом поддомене и потом захочешь убрать — клиенты будут заходить только по HTTPS месяцами. На stage и dev я HSTS не ставлю, чтобы можно было быстро отладить любой сценарий.
Что у меня всегда в чек-листе
- HTTPS-only с редиректом с 80.
- HTTP/2 включён.
- HSTS, X-Content-Type-Options, Referrer-Policy.
X-Forwarded-*иtrust proxyв Node.- Keepalive в upstream.
- Раздача статики напрямую с
expires. - Лимит на тело запроса под нужный сценарий.
- WebSocket-блок, если есть real-time.
- Отдельные access/error логи в JSON.
nginx -tперед каждым reload.
Этот шаблон закрывает 90% задач. Оставшиеся 10% — это специфика конкретного приложения: SSE, gRPC, особые кэши, mTLS, BasicAuth для админок. Каждое из этого добавляется отдельным location, не ломая общий каркас.