Половина конфигов Nginx, которые вы находите в интернете, работают по случайности. Они ставят proxy_pass, добавляют пару заголовков — и двигаются дальше, пока не истекает таймаут загрузки, не обрывается WebSocket или в логах не появляется клиентский IP вида 127.0.0.1. Разница между «работает» и «работает и говорит правду» — всего несколько директив. Вот конфиг, который я реально запускаю, и обоснование каждого фрагмента.

Зачем вообще нужен reverse proxy

Один сервер, много приложений, один терминатор TLS. В этом вся идея. Вы запускаете каждое приложение на loopback-порту (3000, 8000, 8080), а Nginx маршрутизирует по server_name или location. SSL, переписывание заголовков, gzip и rate limiting живут в одном месте, а не дублируются в каждом приложении.

example.com   → 127.0.0.1:3000  (Next.js)
api.example.com → 127.0.0.1:8000 (Node API)

Блок заголовков, который действительно важен

Поведение прокси по умолчанию многое отбрасывает. Ответ работает, но вышестоящее приложение не знает, кто спрашивает и был ли это HTTP или HTTPS, — что ломает редиректы, rate limiting и логи. Всегда передавайте реальные значения:

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;
  • X-Real-IP — это IP пира, который увидел Nginx.
  • X-Forwarded-For — строится через $proxy_add_x_forwarded_for, добавляя реальный клиентский IP к уже существующей цепочке — корректно, когда спереди стоит ещё один прокси.
  • X-Forwarded-Proto — тот, который все забывают. Без него приложение за TLS видит обычный HTTP и строит http://-редиректы и canonical-URL. Express/Fastify явно читают этот заголовок, чтобы решить, защищён ли запрос.

Для WebSocket (а также dev-режима HMR в Next.js) добавьте заголовки upgrade и принудительно HTTP/1.1:

proxy_http_version 1.1;
proxy_set_header Upgrade    $http_upgrade;
proxy_set_header Connection "upgrade";

Без proxy_http_version 1.1 вышестоящий сервер получает HTTP/1.0, и Connection: upgrade бесполезен. Это самая частая причина «socket hang up» в проксированном WebSocket-приложении.

Полный укреплённый блок сервера

Вот конфиг для одного приложения, HTTPS-first, со всеми деталями:

# /etc/nginx/sites-available/example.com
server {
    listen 80;
    server_name example.com www.example.com;
    # Certbot заменит этот блок при первой настройке TLS
    return 301 https://example.com$request_uri;
}

server {
    listen 443 ssl;
    http2 on;
    server_name example.com www.example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    # Скорость: держим соединение с приложением живым
    keepalive_timeout 65;

    # Проксируем в приложение
    location / {
        proxy_pass http://127.0.0.1:3000;
        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;

        # Даём приложению закончить, не убиваем длинные запросы
        proxy_read_timeout 60s;
    }

    # Базовые заголовки безопасности
    add_header X-Content-Type-Options nosniff always;
    add_header X-Frame-Options DENY always;
    add_header Referrer-Policy strict-origin-when-cross-origin always;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    # Сжатие
    gzip on;
    gzip_types text/plain text/css application/javascript application/json image/svg+xml;
    gzip_min_length 1024;
}

Ловушка со слешем в конце, о которую спотыкаются все

proxy_pass трактует завершающий слеш как префикс URL. Это классический источник сбивающих с толку 404:

# передаёт /app/foo  →  http://upstream/app/foo
proxy_pass http://127.0.0.1:3000/;

# передаёт /app/foo  →  http://upstream/foo
proxy_pass http://127.0.0.1:3000;

При location /app/ { proxy_pass http://127.0.0.1:3000/; } совпадение из location заменяет префикс пути location /app/ на то, что идёт после слеша в proxy_pass. Хотите убрать /app — используйте завершающий слеш. Хотите его сохранить — не используйте. Сомневаетесь — проверьте через curl -v и посмотрите строку запроса, которую получает вышестоящий сервер.

TLS: используйте Let’s Encrypt, а не полунастроенный сертификат

sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
sudo certbot renew --dry-run

Плагин nginx у Certbot — это installer: он редактирует ваш блок сервера, чтобы отдавать HTTPS с новым сертификатом.[1] После первого запуска верните свои кастомные директивы (типа http2 on и заголовки безопасности) — Certbot их за вас не вставит. Проверьте итоговый блок, прежде чем считать задачу сделанной.

Несколько заметок по TLS, которые легко переусложнить:

  • Strict-Transport-Security (max-age=31536000) говорит браузерам требовать HTTPS. Это безопасно, как только ваш сертификат работает, но не задавайте его, пока не убедитесь, что все поддомены отдают HTTPS, иначе закроете доступ к незащищённой странице.
  • Оставьте значения ssl_protocols / ciphers по умолчанию; современный nginx со свежим сертификатом Let’s Encrypt уже набирает A+ на обычном тесте SSL, если вы не принудительно включили старые TLS 1.0/1.1.

Тюнинг, который действительно двигает стрелку

Keepalive вышестоящих серверов — пул соединений, чтобы Nginx не открывал новое TCP-рукопожатие к вашему приложению на каждый запрос. Поместите это в блок upstream и обращайтесь по имени:

upstream myapp {
    server 127.0.0.1:3000;
    keepalive 16;
}
server {
    location / {
        proxy_pass http://myapp;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
    }
}

Установка Connection "" внутри location держит вышестоящее соединение живым (заголовок upgrade выше — исключение; делайте так только когда нужны WebSocket).

proxy_buffering — при проксировании потока (SSE, большая загрузка) или long-poll-эндпоинта может понадобиться proxy_buffering off;, чтобы байты шли потоком, а не буферизовались и доставлялись с задержкой.

client_max_body_size — умолчание 1 МБ. Любой загрузочный API за прокси отдаст 413, как только вы его превысите. Задайте то, что реально принимает ваше приложение:

client_max_body_size 50m;

worker_connections — в nginx.conf это ограничивает число одновременных соединений на один воркер; по умолчанию 512.[2] Для небольшого сайта этого достаточно, но при всплеске нагрузки это первое, что стоит проверить. Задавайте его исходя из ulimit -n сервера, а не из круглого числа из туториала.

Выбор цели proxy_pass

  • http://127.0.0.1:3000 — самое простое и переносимое.
  • http://unix:/run/myapp.sock — Unix-сокет, быстрее loopback и не нужно сторожить TCP-порт. Чуть больше конфигурации на стороне приложения, и обязательно настройте права между пользователем приложения и Nginx.

Форма с loopback-TCP — правильный вариант по умолчанию. Переключайтесь на сокет, только если гонитесь за последними миллисекундами.

Короткая версия

Nginx как reverse proxy — это тридцать строк и несколько решений. Передавайте Host, X-Real-IP, X-Forwarded-For и X-Forwarded-Proto; принудительно HTTP/1.1 и заголовки upgrade для WebSocket; следите за завершающим слешем в proxy_pass; задайте разумный client_max_body_size; TLS пусть делает Certbot. Это закрывает подавляющее большинство случаев «локально работало, а на сервере сломалось».

Источники

  1. Certbot — Using Certbot (authenticators vs installers; nginx plugin modifies server config). https://eff-certbot.readthedocs.io/en/stable/using.html — проверено 2026-09-27
  2. nginx — Core functionality (worker_connections, default 512). https://nginx.org/en/docs/ngx_core_module.html — проверено 2026-09-27