Настройка nginx как reverse proxy: заголовки, WebSocket и тайм-ауты

Опубликовано 13 мин. чтения

От proxy_pass до WebSocket-upgrade: полное руководство по nginx в роли reverse proxy, включая четыре заголовка, без которых приложение считает каждого посетителя за 127.0.0.1.

Почти любое современное приложение слушает какой-нибудь высокий порт: Node на 3000, Docker-контейнер на 8080, Gunicorn на 8000, сервер приложений Java на 8443. Напрямую в интернет такое не выставляют. Перед ним должен стоять reverse proxy, и на практике это nginx.

Базовая конфигурация умещается в пять строк. Ровно в этом и состоит проблема: пять строк вроде бы работают, а через три недели выясняется, что в логе приложения каждый посетитель записан как 127.0.0.1, что rate-limit на входе блокирует всех разом, а письмо для сброса пароля содержит ссылку на http://127.0.0.1:3000. Именно об этих местах и пойдёт речь.

Предполагается, что nginx уже установлен. Если ещё нет, поможет статья установка nginx на Debian и Ubuntu.

Что reverse proxy делает на самом деле

nginx принимает соединение посетителя и затем открывает собственное, второе соединение к приложению. Это ключевой момент, из которого выводятся все остальные проблемы.

С точки зрения приложения клиент — это не посетитель, а nginx. Адрес отправителя равен 127.0.0.1. Протокол http, даже если снаружи работал HTTPS. Заголовок Host по умолчанию содержит 127.0.0.1:3000, а не app.example.com. И версия HTTP/1.0 вместо HTTP/1.1, поэтому WebSocket без дополнительной настройки не работает в принципе.

Всё, что приложение должно знать о настоящем посетителе, nginx обязан передать ему явно, в виде HTTP-заголовков. Само по себе это не произойдёт.

Базовая конфигурация и её место в системе

Расположение файла зависит от дистрибутива, и это регулярно путают.

Debian и Ubuntu: конфигурация лежит в /etc/nginx/sites-available/app.conf и включается символьной ссылкой в /etc/nginx/sites-enabled/. Иначе все запросы перехватит стандартный серверный блок default.

AlmaLinux, Rocky, RHEL и Oracle Linux: здесь каталога sites-available нет вообще. Файл кладётся сразу в /etc/nginx/conf.d/app.conf и тем самым сразу активен.

server {
    listen 80;
    listen [::]:80;
    server_name app.example.com;

    location / {
        proxy_pass http://127.0.0.1:3000;
    }
}

Включение на Debian и 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 перед каждым перечитыванием конфигурации — не вежливость, а обязанность. При ошибочной конфигурации systemctl reload оставит работать старый процесс, зато после следующей перезагрузки сервера nginx уже вообще не поднимется.

Все команды этого раздела требуют root-прав, иначе добавляйте перед ними sudo. Это касается и чисто проверочных команд: nginx -t и nginx -T от обычного пользователя падают не из-за конфигурации, а из-за файла, в который им просто нельзя писать. Сообщение [emerg] open() "/run/nginx.pid" failed (13: Permission denied) и следующая за ним строка configuration file /etc/nginx/nginx.conf test failed вовсе не означают, что ваша конфигурация сломана.

Эта конфигурация проксирует запросы. И всё же она сломана, причём так, что выяснится это только позже.

Четыре заголовка, без которых приложение слепо

Эти четыре строки должны быть в каждом блоке location с 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

Без этой строки nginx подставит Host: 127.0.0.1:3000. Симптомы: Django отвечает Invalid HTTP_HOST header и отдаёт 400. Laravel и WordPress формируют абсолютные URL на 127.0.0.1, что видно в редиректах и в каждом отправленном письме. Бэкенд, обслуживающий несколько доменов, всегда отдаёт данные не того арендатора.

Правильный выбор здесь $host, а не $http_host: $host содержит имя хоста без порта и откатывается к server_name, если клиент вообще не прислал заголовок Host. $http_host пропускает дальше всё, что пришло, вместе с указанием порта.

X-Real-IP

$remote_addr — это адрес, с которым nginx действительно общается. Ровно один адрес, без запятых, без разбора строки. Для приложений, у которых есть только одно поле под IP клиента, это самый простой путь.

X-Forwarded-For

$proxy_add_x_forwarded_for берёт уже существующий X-Forwarded-For, если он есть, и дописывает $remote_addr справа. При нескольких прокси так возникает цепочка.

И вот здесь скрыта уязвимость, о которой почти не пишут в руководствах: клиент может прислать свой собственный X-Forwarded-For. Если ваш nginx стоит прямо в интернете, $proxy_add_x_forwarded_for склеит выдуманное значение атакующего и настоящий IP клиента в один общий список. И если приложение читает как IP клиента первую запись, оно поверит любому адресу. Так обходятся rate-limit, блокировки по IP и гео-логика.

Два правильных вывода:

  • Цепочка читается всегда справа. Последняя запись — единственная, которую записал ваш собственный прокси.
  • Если nginx — единственный узел перед приложением, лучше перезаписывать цепочку целиком, а не дополнять её:
proxy_set_header X-Forwarded-For $remote_addr;

Так исчезает любая поддельная предыстория. Вариант $proxy_add_x_forwarded_for уместен только тогда, когда перед nginx стоит балансировщик нагрузки или CDN, которому вы действительно доверяете. В этом случае дополнительно настраивается модуль realip, чтобы уже сам nginx знал настоящий адрес и не записывал в лог адрес предыдущего узла:

set_real_ip_from 10.0.0.0/8;
real_ip_header   X-Forwarded-For;
real_ip_recursive on;

Директива set_real_ip_from задаёт белый список. Без неё модуль бесполезен, а со слишком широким значением вроде 0.0.0.0/0 он превращается в открытую дверь.

X-Forwarded-Proto

Приложение видит обычное HTTP-соединение независимо от того, что происходит снаружи. Без этого заголовка обычно случается следующее: приложение замечает «HTTPS нет» и редиректит на HTTPS, nginx принимает запрос, расшифровывает его, передаёт дальше снова по HTTP, приложение редиректит опять. Браузер сообщает ERR_TOO_MANY_REDIRECTS. Не реже встречается и другое: cookie с флагом Secure не устанавливаются, вход в систему молча не срабатывает, а страницы подгружают картинки и скрипты по http://, что браузер блокирует как mixed content.

Используйте $scheme, а не жёстко заданное значение "https". Иначе и блок на порту 80 будет утверждать, что соединение было зашифровано.

Как убедиться, что заголовки действительно доходят

Вместо догадок постройте себе зеркало. Этот дополнительный серверный блок отвечает на любой запрос полученными заголовками в виде обычного текста:

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\nПротокол: $server_protocol\n";
    }
}

Временно направьте proxy_pass на http://127.0.0.1:9999, перечитайте конфигурацию nginx и откройте страницу. То, что там написано, и есть ровно то, что иначе получило бы ваше приложение. Если заполнены все четыре строки и адрес совпадает с вашим реальным подключением, конфигурация верна. После проверки тестовый блок нужно удалить.

Но и приложение должно эти заголовки обрабатывать. Express требует app.set('trust proxy', 1), Symfony — настройку trusted_proxies, Django — USE_X_FORWARDED_HOST и SECURE_PROXY_SSL_HEADER. Без этого переключателя фреймворки сознательно игнорируют заголовки, ровно по описанной выше причине с подделкой.

proxy_pass и слэш, который меняет всё

Самая частая тихая ошибка во всей конфигурации nginx. Один-единственный символ решает, какой путь придёт на бэкенд.

КонфигурацияЗапросБэкенд получает
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

Правило такое: как только после хоста и порта стоит хоть какой-нибудь путь, пусть даже один слэш, nginx заменяет ту часть URL, которая совпала с префиксом location. Без указания пути весь URL передаётся дальше без изменений.

Картина ошибки характерна: главная страница работает, а всё, что ниже префикса, отдаёт 404, и в логе бэкенда видны пути с удвоенным префиксом вроде /api/api/users. Взгляд в лог приложения проясняет это за секунды, а гадание над конфигурацией nginx стоит часов.

Два особых случая, у которых свои сообщения об ошибках. В блоке location с регулярным выражением указывать путь запрещено, иначе nginx прервёт запуск с сообщением nginx: [emerg] "proxy_pass" cannot have URI part in location given by regular expression, inside named location, or inside "if" statement. А как только вы используете в proxy_pass переменную, например proxy_pass http://$backend;, nginx перестаёт разрешать имя при запуске и делает это во время работы. Без строки resolver в серверном блоке это заканчивается сообщением no resolver defined to resolve ... и ошибкой 502.

Проксирование WebSocket

По умолчанию nginx общается с бэкендом по HTTP/1.0, а HTTP/1.0 не знает механизма upgrade. Поэтому на базовой конфигурации ломается любой WebSocket, независимо от того, насколько правильно настроено всё остальное.

Типичные симптомы: консоль браузера сообщает WebSocket connection to 'wss://app.example.com/ws' failed, часто с дополнением Error during WebSocket handshake: Unexpected response code: 400. Socket.io молча откатывается на long-polling, приложение просто кажется вязким, а в access-логе бесконечно повторяются строки с /socket.io/?EIO=4&transport=polling.

Сначала сопоставление, которое решает, запрашивает ли соединение upgrade вообще. Оно должно находиться в контексте http, то есть лучше всего в отдельном файле /etc/nginx/conf.d/websocket.conf:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

Если этот блок случайно окажется внутри блока server или location, nginx больше не запустится: nginx: [emerg] "map" directive is not allowed here.

Затем в блоке 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;
}

Почему именно сопоставление, а не просто proxy_set_header Connection "upgrade";? Потому что тогда upgrade будут запрашивать и совершенно обычные HTTP-запросы. Часть бэкендов отвечает на это кодом 400, а переиспользование keepalive-соединений пропадает. Сопоставление отправляет upgrade только тогда, когда клиент действительно его запросил, и close во всех остальных случаях.

Проверка: в инструментах разработчика браузера запрос WebSocket должен показывать статус 101 Switching Protocols. Всё остальное, особенно 200 или 400, означает, что upgrade не прошёл. В командной строке это проверяется и без браузера:

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

Как правильно выставить тайм-ауты

Если соединение воспроизводимо рвётся ровно через 60 секунд, это не случайность, а значение proxy_read_timeout по умолчанию. Важно понимать: значение ограничивает не общую длительность запроса, а паузу между двумя операциями чтения. Загрузка, которая длится десять минут, но постоянно передаёт данные, проходит без проблем. А WebSocket, на котором 61 секунду ничего не происходит, вылетает.

proxy_connect_timeout 10s;
proxy_send_timeout    60s;
proxy_read_timeout    60s;

proxy_connect_timeout относится только к установке соединения и ограничен сверху 75 секундами. Ставить больше бессмысленно. Для локального бэкенда 10 секунд — это с запасом, а низкое значение позволяет быстрее заметить, что служба вообще не работает.

Для WebSocket лучший путь не proxy_read_timeout 86400s;, а heartbeat в самом приложении, который каждые 30 секунд отправляет ping. Тогда тайм-аут сохраняется как защита от зависших соединений, вместо того чтобы быть фактически отключённым.

Ещё два значения по умолчанию, которые срабатывают регулярно. client_max_body_size равен 1 МБ, и любая загрузка большего размера заканчивается ошибкой 413 Request Entity Too Large и строкой в логе client intended to send too large body. А при Server-Sent Events или потоковых ответах посетитель долго не получает ничего, потому что nginx буферизует. Тут помогает proxy_buffering off; в нужном блоке location, точечно, а не глобально.

HTTPS перед приложением

Самый удобный путь — certbot с плагином для nginx. Он читает существующий серверный блок, дописывает TLS-часть и настраивает обновление сертификата:

apt-get install -y certbot python3-certbot-nginx
certbot --nginx -d app.example.com

Эти имена пакетов действуют для Debian и Ubuntu. В AlmaLinux, Rocky Linux и Oracle Linux certbot отсутствует в базовых репозиториях, и простая команда dnf install -y certbot python3-certbot-nginx завершается там сообщением Error: Unable to find a match. EPEL обязателен:

dnf install -y epel-release
dnf install -y certbot python3-certbot-nginx

В Oracle Linux 9 пакет EPEL называется oracle-epel-release-el9, и при необходимости репозиторий нужно предварительно включить командой dnf config-manager --enable ol9_developer_EPEL.

Если поддоменов несколько, стоит посмотреть wildcard-сертификаты Let's Encrypt.

Различие версий, из-за которого при копировании чужих конфигураций появляются предупреждения: до nginx 1.24 HTTP/2 включается прямо в строке listen, а начиная с nginx 1.25.1 для этого есть отдельная директива. Debian 13 поставляет nginx 1.26.3 и требует новую запись, а Debian 12 (1.22.1), Ubuntu 24.04 (1.24.0) и Ubuntu 22.04 (1.18.0) — старую. На стороне Red Hat граница проходит точно так же: AlmaLinux 10 приносит 1.26.3 и вместе с ним новую форму, а AlmaLinux 9, Rocky Linux 9 и Oracle Linux 9 приносят 1.20.1 и требуют старую.

# nginx начиная с 1.25.1, в том числе Debian 13
listen 443 ssl;
http2 on;

# nginx до 1.24, то есть Debian 12, Ubuntu 24.04 и 22.04
listen 443 ssl http2;

Если использовать старую форму на новом nginx, nginx -t сообщит: nginx: [warn] the "listen ... http2" directive is deprecated, use the "http2" directive instead. Это всего лишь предупреждение, то есть всё продолжает работать. А новая форма на старом nginx, наоборот, даёт жёсткую ошибку запуска: unknown directive "http2".

И напоследок самый важный пункт всего упражнения: сам бэкенд не должен смотреть в интернет. Reverse proxy бесполезен, если http://server-ip:3000 по-прежнему доступен напрямую, ведь тогда каждый выставит заголовки так, как ему удобно. Привяжите службу к 127.0.0.1.

В случае с Docker это особенно острая ловушка: -p 3000:3000 публикует порт на всех адресах и заносит для этого правила, которые просто обходят файрвол ufw. Правильно писать -p 127.0.0.1:3000:3000. Подробности по настройке есть в статье установка Docker на Debian и Ubuntu.

Если вы в порядке исключения проксируете на HTTPS-бэкенд, nginx нужна дополнительная строка, иначе он не отправит имя SNI и удалённая сторона отдаст неправильный сертификат:

proxy_pass https://backend.intern:8443;
proxy_ssl_server_name on;

Если что-то пошло не так: сообщения дословно

Первым делом всегда смотрите в tail -f /var/log/nginx/error.log. Страница ошибки в браузере не говорит ничего, лог говорит всё.

Небольшое замечание заранее, чтобы уже первая строка не сбила с толку: в AlmaLinux, Rocky Linux и Oracle Linux файла /var/log/nginx/error.log сразу после установки ещё нет, он появляется только при первом запуске nginx. tail отвечает тогда cannot open ... No such file or directory. В Debian и Ubuntu пакет создаёт access.log и error.log уже при установке. Надёжный вариант для обоих миров:

tail -n 50 /var/log/nginx/error.log 2>/dev/null || journalctl -u nginx -n 50 --no-pager
Сообщение в error.logЧто это значит и что делать
connect() failed (111: Connection refused) while connecting to upstreamНа целевом порту никто не слушает. Проверьте состояние службы и убедитесь через ss -ltnp, что порт и адрес совпадают со строкой proxy_pass. Если ss нет, в Debian и Ubuntu его даёт пакет iproute2, а в семействе Red Hat пакет iproute (там без цифры 2 в названии).
connect() failed (113: No route to host)Между nginx и бэкендом блокирует файрвол. В случае с контейнерами часто виновата не та сеть.
connect() to 127.0.0.1:3000 failed (13: Permission denied) while connecting to upstreamВ AlmaLinux, Rocky, RHEL и Oracle Linux почти всегда виноват SELinux. Подтверждение через ausearch -m AVC -ts recent, решение через setsebool -P httpd_can_network_connect 1. В Debian и Ubuntu такого не бывает.
upstream timed out (110: Connection timed out) while reading response header from upstreamДаёт 504. Бэкенд отвечает слишком медленно. Сначала разберитесь там и только потом поднимайте proxy_read_timeout.
upstream prematurely closed connection while reading response headerДаёт 502. Процесс бэкенда умер во время запроса, часто из-за OOM-killer. Смотрите настройку swap.
upstream sent too big header while reading response header from upstreamСлишком большие заголовки ответа, классика при большом количестве или чрезмерной длине cookie. Задайте proxy_buffer_size 32k; и proxy_buffers 8 32k;.
no live upstreams while connecting to upstreamВ блоке upstream все цели помечены как недоступные. Поведение проверок регулируется через max_fails и fail_timeout.

Для особых случаев вокруг 502 есть отдельное руководство: как устранить nginx 502 Bad Gateway. Если ваш бэкенд ещё не оформлен как корректно стартующая служба, сначала стоит создать systemd-сервис.

Как понять, что всё действительно работает

Пять проверок, которые вместе дают ясную картину:

  1. nginx -t сообщает syntax is ok и test is successful.
  2. nginx -T показывает полную собранную конфигурацию. Найдите в ней proxy_set_header и посчитайте: все четыре заголовка должны стоять в каждом значимом блоке location. Один proxy_set_header во вложенном блоке отменяет все унаследованные заголовки внешнего блока, и это самая частая причина мысли «я же это уже настраивал».
  3. В логе приложения стоит настоящий адрес посетителя, а не 127.0.0.1.
  4. Зеркальный серверный блок из примера выше возвращает все четыре значения заполненными, а при обращении по HTTPS показывает X-Forwarded-Proto: https.
  5. Для WebSocket: статус 101 в инструментах разработчика, и соединение переживает более 60 секунд тишины.

Кроме того, полезен формат лога, который записывает переданный дальше адрес. Так сразу видно, имеют ли nginx и приложение в виду один и тот же адрес:

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;

Оба значения времени в конце на вес золота: $request_time — это общая длительность с точки зрения посетителя, а $upstream_response_time — длительность ответа бэкенда. Если они близки, значит, медленный именно бэкенд. Если между ними разрыв, дело в канале до клиента или в буферизации.

Так получается reverse proxy, который не просто делает приложение доступным, но и передаёт ему всё, что нужно знать о посетителях. Если вы разворачиваете такую конфигурацию на свежей системе, чек-лист для нового root-сервера будет хорошей отправной точкой для всего, что делается до этого.

Частые вопросы

Почему моё приложение видит 127.0.0.1 вместо настоящего адреса посетителя?
Потому что nginx открывает собственное, второе соединение к бэкенду. С точки зрения приложения клиентом оказывается именно nginx. Настоящий адрес нужно передавать явно, заголовками proxy_set_header X-Real-IP $remote_addr и proxy_set_header X-Forwarded-For. Дополнительно фреймворк должен разрешать их обработку, например app.set('trust proxy', 1) в Express или SECURE_PROXY_SSL_HEADER в Django.
В чём разница между X-Real-IP и X-Forwarded-For?
X-Real-IP содержит ровно один адрес, а именно адрес непосредственно подключённого клиента. X-Forwarded-For — это цепочка через запятую, в которую каждый прокси дописывает своего предшественника. Цепочку нужно читать всегда справа, потому что только последняя запись гарантированно поставлена вашим собственным прокси. Всё, что левее, клиент мог подделать.
Почему WebSocket не работает за nginx?
По умолчанию nginx общается с бэкендом по HTTP/1.0, а HTTP/1.0 не знает механизма upgrade. Нужны proxy_http_version 1.1, proxy_set_header Upgrade $http_upgrade и proxy_set_header Connection $connection_upgrade, причём переменная $connection_upgrade должна быть определена блоком map в контексте http. Признак успеха: браузер показывает статус 101 Switching Protocols.
Почему соединение рвётся ровно через 60 секунд?
60 секунд — это значение proxy_read_timeout по умолчанию. Оно ограничивает не общую длительность, а паузу между двумя операциями чтения. Для долго живущих соединений его можно увеличить, но для WebSocket лучше сделать heartbeat в самом приложении, чтобы тайм-аут сохранился как защита.
Что меняет слэш в конце proxy_pass?
Если после хоста и порта стоит путь, пусть даже один-единственный слэш, nginx заменяет ту часть URL, которая совпала с префиксом location. При location /api/ и proxy_pass http://127.0.0.1:3000/ из /api/users на бэкенде получается /users. Без слэша /api/users приходит без изменений. Типичные симптомы неверного выбора: 404 на всём, что ниже префикса, или удвоенные пути вида /api/api/users.
Почему на AlmaLinux я получаю 502 с Permission denied?
В семействе Red Hat SELinux активен по умолчанию и запрещает контексту веб-сервера исходящие сетевые соединения. В error.log тогда появляется connect() failed (13: Permission denied) while connecting to upstream. Помогает setsebool -P httpd_can_network_connect 1, изменение действует сразу, без перечитывания конфигурации nginx. В Debian и Ubuntu этой проблемы нет.

nginx Reverse Proxy proxy_pass WebSocket X-Forwarded-For HTTPS Linux Управление сервером