lenec ru

← все посты

nginx reverse proxy для Node: рабочий конфиг с TLS

18K

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 и в Node app.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 100

Reload не перезапускает мастер-процесс, активные соединения остаются. Если конфиг сломан — старый продолжает работать, и сайт не падает. На моей памяти 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, не ломая общий каркас.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.