SSD Nodes Learn 🎉 VPS от $5.50/мес
Руководства Matt ConnorАвтор: Matt Connor

Настройка nginx в качестве обратного прокси

Пошаговое руководство по настройке блока server в nginx для перенаправления трафика. Разберем параметры proxy_pass, заголовки для передачи IP, поддержку websockets и лимиты.

Что делает конфигурация обратного прокси-сервера nginx

Обратный прокси-сервер nginx принимает запросы, поступающие на порты 80 и 443, передает каждый из них приложению, которое уже ожидает соединение на локальном порту, а затем возвращает ответ этого приложения в браузер. Конфигурация представляет собой единый блок server, и этот блок невелик. Почти вся сложность заключается в пяти-шести строках, которые сообщают вашему приложению, кто был реальным клиентом и какой протокол этот клиент использовал.

Все приведенное ниже строится с нуля на Ubuntu 24.04 с использованием пакета nginx из дистрибутива. Отправной точкой является приложение, которое уже отвечает на 127.0.0.1:3000. Если вы еще не определились с выбором прокси, сравнение nginx с Caddy и Traefik — это то, что стоит прочитать в первую очередь. Ниже показано, как выглядит ответ nginx, строка за строкой.

Запускайте эти конфигурации на своем сервере. Тестируйте каждое изменение с помощью sudo nginx -t перед перезагрузкой и читайте то, что он выводит.

Где nginx хранит конфигурацию в Ubuntu

sudo apt update
sudo apt install -y nginx
ls -l /etc/nginx/sites-enabled/

Основной файл — /etc/nginx/nginx.conf. В нем задаются глобальные параметры внутри блока http { }, после чего подключаются два каталога: /etc/nginx/conf.d/*.conf и /etc/nginx/sites-enabled/*. В Ubuntu и Debian для каждого сайта создается отдельный файл в /etc/nginx/sites-available/, который активируется через символическую ссылку в /etc/nginx/sites-enabled/. Удаление ссылки отключает сайт, но сохраняет файл конфигурации.

Две директивы, которые потребуются далее, работают только в контексте http и никогда внутри блока server: map и upstream. Разместите их в отдельном файле в каталоге /etc/nginx/conf.d/, так как содержимое этого каталога подключается на уровне http.

В пакете по умолчанию активирован сайт default. Он помечен как default_server, что означает, что он обрабатывает любой запрос, если заголовок Host не совпадает ни с одним server_name в вашей конфигурации. Пока этот сайт включен, запросы, не соответствующие вашим доменным именам, будут попадать на него, а не в ваше приложение. Удалите символическую ссылку, как только настроите собственный сайт.

sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx

Минимальный server block для проксирования одного приложения

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

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

Сохраните это как /etc/nginx/sites-available/app.example.com, затем включите конфигурацию и примените её.

sudo ln -s /etc/nginx/sites-available/app.example.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
curl -sI -H 'Host: app.example.com' http://127.0.0.1/

listen 80; привязывается к IPv4, а listen [::]:80; — к IPv6. Если убрать вторую строку, посетитель, чей DNS (domain name system) запрос вернет AAAA-запись для вашего сервера, получит отказ в соединении, в то время как пользователи IPv4 увидят работающий сайт. В отчете об ошибке, который вы получите, будет сказано: «у меня всё работает».

server_name сопоставляется с заголовком Host, который отправляет браузер. Можно указать несколько имен через пробел. Если ни один блок не совпадает, nginx использует тот, который помечен как default_server, поэтому стандартный сайт из пакета пришлось удалить.

location / выполняет префиксное сопоставление пути запроса, а / соответствует любому пути. proxy_pass — это адрес, к которому nginx открывает соединение. Оставляйте приложение привязанным к 127.0.0.1, чтобы единственный путь доступа к нему проходил через nginx. Если приложение работает в контейнере, публикуйте его как 127.0.0.1:3000:3000, а не как 3000:3000, потому что Docker создает собственные правила и публикует порты в обход ufw, поэтому открытый порт будет доступен из Интернета независимо от настроек вашего межсетевого экрана.

Строка curl отправляет корректный заголовок Host с самого сервера, что позволяет протестировать блок до того, как DNS начнет указывать на ваш сервер.

Что nginx отправляет на upstream, если не указаны дополнительные параметры

proxy_pass сам по себе скрывает от вашего приложения четыре вещи.

По умолчанию nginx общается с бэкендом по протоколу HTTP/1.0 и отправляет Connection: close, поэтому каждый запрос открывает новое соединение с upstream, а обновление протокола становится невозможным.

Заголовок Host перезаписывается значением proxy_pass, которое равно 127.0.0.1:3000. Приложение, формирующее абсолютные ссылки на основе Host, теперь создает ссылки, которые никто за пределами сервера не сможет открыть.

Соединение, поступающее в приложение, исходит от nginx, поэтому приложение видит адрес клиента 127.0.0.1. В результате каждая строка лога и каждый лимит запросов внутри приложения фиксируют прокси-сервер вместо реального посетителя.

Приложение не может определить, использовал ли браузер HTTPS, так как полученное соединение является обычным HTTP на адресе loopback.

Четыре строки исправляют все эти проблемы.

Четыре заголовка для настройки и то, что они позволяют увидеть бэкенду

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 передает имя хоста, которое ввел посетитель. $host — это имя из запроса, очищенное от порта и приведенное к нижнему регистру. Установите его, чтобы приложение формировало корректные абсолютные URL: например, для редиректа после входа в систему или ссылки в письме для сброса пароля. Если этот заголовок не задан, ссылки будут указывать на 127.0.0.1:3000, из-за чего после входа браузер перенаправится на адрес, который отклонит соединение. Если приложению требуется и порт (например, при работе на 8080), используйте $http_host — этот заголовок передает значение в точности так, как его отправил клиент.

X-Real-IP содержит одно значение: $remote_addr, адрес, с которого nginx принял соединение. Приложения считывают его для собственных логов доступа и ограничения частоты запросов (rate limiting).

X-Forwarded-For содержит список. $proxy_add_x_forwarded_for добавляет $remote_addr к тому, что уже было в этом заголовке, поэтому значения разделяются запятыми, а запись, добавленная вашим nginx, оказывается последней. Эта деталь определяет, можно ли доверять заголовку: клиент может отправить любой X-Forwarded-For, поэтому приложение, считывающее первую запись, может получить любой адрес. Если nginx является пограничным сервером, используйте $remote_addr и отбрасывайте версию клиента. Если перед ним стоит CDN или другой прокси, используйте set_real_ip_from и real_ip_header из модуля realip, чтобы сам $remote_addr стал истинным адресом клиента.

X-Forwarded-Proto содержит http или https. Фреймворки считывают его, чтобы решить, помечать ли cookies флагом Secure и нужно ли принудительно перенаправлять на HTTPS. Если опустить этот заголовок на TLS-сайте, приложение, настроенное на принудительный HTTPS, увидит http, ответит редиректом на HTTPS-адрес, получит следующий запрос через nginx, снова увидит http и снова выполнит редирект. Браузер сдастся и покажет ERR_TOO_MANY_REDIRECTS.

Повторение этих четырех строк в каждой location приводит к расхождениям в конфигурации. Поместите их в отдельный файл и используйте include.

# /etc/nginx/snippets/proxy-headers.conf
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;
location / {
    include snippets/proxy-headers.conf;
    proxy_pass http://127.0.0.1:3000;
}

Наследование здесь имеет подвох. Location наследует директивы proxy_set_header из блока server только в том случае, если внутри самой location не определено ни одной такой директивы. Добавьте хотя бы один proxy_set_header внутрь location, и все заголовки, определенные на уровне server, будут отброшены для этого блока. Поэтому держите их все на одном уровне или используйте include для вставки сниппета в каждую location, где настроен прокси.

Почему мое WebSocket-приложение подключается, а затем отключается?

Потому что настройки по умолчанию запрещают обновление соединения, а стандартный таймаут чтения закрывает простаивающий туннель через 60 секунд. WebSocket начинается как HTTP-запрос, содержащий Upgrade: websocket и Connection: Upgrade. Это заголовки hop-by-hop, что означает, что прокси должен обработать их самостоятельно, а не передавать дальше, к тому же в HTTP/1.0 механизм обновления отсутствует. Оба заголовка необходимо восстанавливать вручную.

Карта (map) размещается в контексте http, в отдельном файле.

# /etc/nginx/conf.d/websocket.conf
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

Затем настраивается location.

location / {
    include snippets/proxy-headers.conf;
    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_read_timeout 3600s;
    proxy_send_timeout 3600s;
}

Карта нужна для того, чтобы один location мог обслуживать оба типа трафика. При обычном запросе переменная $http_upgrade пуста, поэтому $connection_upgrade принимает значение close. При запросе на обновление она содержит websocket, поэтому вышестоящему серверу (upstream) передается заголовок Connection: upgrade. Жесткое прописывание proxy_set_header Connection "upgrade"; приведет к отправке этого заголовка при каждом обычном запросе страницы, на что некоторые бэкенды отвечают ошибкой 400.

proxy_read_timeout — это причина сообщений вида «загружается, а потом перестает обновляться». По умолчанию значение составляет 60 секунд, и оно отсчитывает интервал между двумя операциями чтения из бэкенда, а не общее время жизни соединения. WebSocket, который остается неактивным в течение 60 секунд, закрывается Nginx, и в консоли браузера отображается закрытие сокета с кодом 1006. Приложения, которые отправляют собственный heartbeat чаще одного раза в минуту, не сталкиваются с этой проблемой. Приложения, которые этого не делают, разрывают соединение через минуту. Чаще всего это проявляется в интерактивных редакторах и дашбордах, распространенным примером является самостоятельно развернутый экземпляр n8n за HTTPS.

Почему завершающий слэш в proxy_pass меняет мои URL?

Правило формулируется одним предложением. Если proxy_pass заканчивается на URI (идентификатор ресурса), даже на простой /, nginx удаляет часть пути запроса, которая совпала с префиксом location, и подставляет на это место указанный URI. Если proxy_pass заканчивается на хосте и порте, путь запроса передается без изменений.

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

Запрос к /app/status доходит до бэкенда как /status.

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

Запрос к /app/status доходит до бэкенда как /app/status.

Выбор формы зависит от приложения. Приложению с настройкой базового пути или подпапки нужна вторая форма, при этом в настройках приложения должен быть указан /app. Приложению, которое ничего не знает о префиксах, нужна первая форма. У первой формы есть очевидный недостаток: HTML, который возвращает приложение, все еще содержит абсолютные пути, такие как /static/main.css. Браузер запрашивает их из корня сайта, ни один location не совпадает, и страница отображается без стилей. В сетевой вкладке браузера видно, что запросы к этим ресурсам возвращают 404. Решение заключается в настройке базового пути самого приложения или в добавлении второго location location /static/, указывающего на тот же бэкенд.

Location с регулярным выражением не может содержать URI в proxy_pass. sudo nginx -t отклоняет такую конфигурацию и указывает причину: "proxy_pass" cannot have URI part in location given by regular expression, or inside named location, or inside "if" statement, or inside "limit_except" block.

Весь этот класс проблем исчезает, когда каждое приложение получает собственное имя, app.example.com, проксируемое с location /. Использование подпутей оправдано только в том случае, если вы не можете добавить DNS-записи.

Как разместить несколько бэкендов под одним именем?

Используйте блок upstream. Он относится к контексту http, поэтому размещайте его перед блоком server в том же файле или в /etc/nginx/conf.d/.

upstream app_backend {
    least_conn;
    server 127.0.0.1:3000 max_fails=3 fail_timeout=30s;
    server 127.0.0.1:3001 max_fails=3 fail_timeout=30s;
    keepalive 32;
}

Затем укажите его в блоке location: proxy_pass http://app_backend;.

Метод по умолчанию — round robin. Параметр least_conn направляет каждый запрос на бэкенд с наименьшим количеством активных соединений, что подходит для запросов разной длительности. Параметр ip_hash привязывает один клиентский адрес к одному бэкенду. Параметр ip_hash необходим, когда приложение хранит сессии в собственной памяти, так как round robin между двумя такими бэкендами будет случайным образом разлогинивать пользователей, если запрос попадет на инстанс, где сессия отсутствует. Перенос сессий во внешнее общее хранилище — более правильное решение.

Параметр max_fails=3 fail_timeout=30s означает, что три неудачные попытки в течение 30 секунд исключат сервер из ротации на 30 секунд. Когда все серверы в блоке переходят в это состояние, клиенты получают 502, а в логе ошибок появляется запись no live upstreams while connecting to upstream.

Параметр keepalive 32 поддерживает до 32 открытых неактивных соединений с бэкендами на каждый рабочий процесс, что исключает выполнение TCP-рукопожатия для большинства запросов. Это работает только с proxy_http_version 1.1 и при отсутствии Connection: close при передаче запроса выше по цепочке. Если в том же location используется карта для WebSocket, измените пустой случай с close на пустую строку, чтобы обычные запросы не содержали заголовок Connection и пул соединений мог использоваться повторно.

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

Имена внутри блока upstream разрешаются при запуске nginx. Если ваш бэкенд — это контейнер, который получает новый адрес при перезапуске, nginx продолжит использовать старый адрес до тех пор, пока вы не выполните перезагрузку конфигурации. Внутри сети Docker можно перенести разрешение имен на момент запроса с помощью встроенного резолвера.

resolver 127.0.0.11 valid=10s;
set $backend http://app:3000;
proxy_pass $backend;

Если контейнеры появляются и исчезают так часто, что вам приходится постоянно редактировать nginx, лучше использовать прокси, который считывает метки контейнеров. Traefik перед несколькими приложениями Docker Compose строит свои маршруты автоматически на основе самих контейнеров.

Почему загрузка файлов завершается ошибкой 413 Request Entity Too Large?

client_max_body_size по умолчанию составляет 1 мегабайт. Nginx отклоняет запрос с телом большего размера до того, как он дойдет до вашего приложения, а в лог ошибок записывается client intended to send too large body. Увеличьте это значение в блоке server или в location, где происходит загрузка файлов.

client_max_body_size 512m;

Значение 0 полностью отключает проверку. У приложения также есть собственные лимиты, поэтому если ошибка 413 сохраняется после внесения изменений, значит, ограничение исходит от бэкенда, и далее следует проверить настройки загрузки самого приложения.

По умолчанию Nginx считывает всё тело запроса перед открытием соединения с апстримом, предварительно записывая большие данные во временный файл на диске. Это защищает приложение от медленных клиентов, так как бэкенд получает загружаемые данные на полной локальной скорости. Для очень больших файлов можно использовать потоковую передачу.

proxy_request_buffering off;

В этом случае бэкенд получает тело запроса по мере его поступления и должен быть готов к его обработке. Nginx при этом теряет возможность повторить запрос к другому апстриму, так как тело запроса уже передано.

client_body_timeout, по умолчанию равный 60 секундам, применяется к интервалу между двумя последовательными операциями чтения тела запроса, а не ко всей загрузке целиком. Медленная, но стабильная загрузка не будет прервана. Зависшая загрузка будет сброшена.

Буферизация ответов и настройка, нарушающая вывод в реальном времени

proxy_buffering включена по умолчанию, и в большинстве случаев это именно то, что требуется. Nginx считывает ответ от вашего приложения так быстро, как приложение успевает его записывать, удерживает его и передает медленному клиенту с его собственной скоростью. Воркер приложения завершает работу раньше, вместо того чтобы оставаться занятым на протяжении всей медленной загрузки.

Это нарушает потоковую передачу ответов. События, отправляемые сервером (Server-sent events), и вывод логов в реальном времени не отображаются у пользователя, пока буфер не заполнится. Отключайте буферизацию только в этом конкретном location.

proxy_buffering off;

Если вы управляете приложением, лучше отправлять заголовок X-Accel-Buffering: no только для потоковых ответов. Nginx считывает этот заголовок для каждого ответа и отключает буферизацию только для него, поэтому обычные страницы сохраняют все преимущества.

Когда в error log появляется сообщение upstream sent too big header while reading response header from upstream, это означает, что заголовки ответа не поместились в один буфер. proxy_buffer_size по умолчанию равен одной странице памяти, 4 или 8 килобайтам в зависимости от платформы, и длинные куки или большие заголовки аутентификации переполняют его. Увеличьте оба значения.

proxy_buffer_size 16k;
proxy_buffers 8 16k;

Где в этой конфигурации место для TLS?

На уровне Nginx, перед всеми остальными компонентами. TLS (transport layer security) завершается на прокси-сервере, а соединение от Nginx к приложению остается обычным HTTP через loopback-адрес, где его не может перехватить никто другой в сети. Приложение узнает о том, что посетитель использовал HTTPS, из X-Forwarded-Proto — четвертого из четырех заголовков.

Не прописывайте пути к сертификатам вручную. Направьте DNS-запись на сервер, откройте брандмауэр и позвольте Certbot отредактировать этот же server block: он добавит строку listen 443 ssl с путями ssl_certificate, а также редирект с 80 порта. В статье Выпуск сертификата Let's Encrypt для Nginx с помощью Certbot описаны процесс выпуска и таймер автоматического обновления.

sudo ufw allow 'Nginx Full'
sudo ufw status

Nginx Full — это профиль приложения, который устанавливается вместе с пакетом Nginx; он открывает порты 80 и 443 одновременно. Порт 80 должен оставаться открытым для прохождения проверки HTTP-01 при обновлении сертификата, даже после того, как все посетители будут перенаправлены на HTTPS.

Проверка конфигурации и перезагрузка

sudo nginx -t
sudo systemctl reload nginx

nginx -t анализирует все включенные файлы и либо сообщает об успешном прохождении теста, либо выводит имя файла и номер строки, на которой произошла ошибка. Изучите этот вывод перед перезагрузкой. Перезагрузка с некорректной конфигурацией не применяется: Nginx продолжает работать с предыдущей конфигурацией, поэтому сайт остается доступным, а ваши изменения игнорируются. systemctl restart ведет себя иначе и хуже, так как перезапуск сначала останавливает работающий сервер, поэтому ошибка в конфигурации приведет к тому, что Nginx вообще не запустится. По умолчанию используйте перезагрузку, а перезапуск оставьте для редких случаев, когда это действительно необходимо.

sudo tail -f /var/log/nginx/error.log
sudo ss -lntp | grep -E ':(80|443|3000)'

Строка ss показывает, какой процесс занимает каждый порт, поэтому вы можете подтвердить, что приложение действительно ожидает соединений там, куда указывает proxy_pass.

Типичные ошибки, с которыми вы столкнетесь

502 Bad Gateway, в журнале ошибок connect() failed (111: Connection refused) while connecting to upstream. По адресу в proxy_pass никто не ожидает соединений. Приложение остановлено, привязано к другому порту или к внутреннему адресу контейнера, недоступному для хоста.

502 с no live upstreams while connecting to upstream. Все серверы в блоке upstream в данный момент помечены max_fails как нерабочие. Восстановите работу бэкендов. nginx повторит попытку после истечения fail_timeout.

504 Gateway Time-out, с upstream timed out (110: Connection timed out) while reading response header from upstream. Бэкенд принял соединение, но не передал данные в течение proxy_read_timeout секунд. Увеличение таймаута оправдано для действительно медленных отчетов, но является ошибкой, если приложение зависло.

Все пути возвращают 404 от приложения. Правило обработки завершающего слэша изменило путь. Сравните путь, который фиксирует приложение, с тем, который вы запрашивали.

Отвечает другой сайт. server_name не совпадает с заголовком Host, поэтому запрос был обработан блоком default_server.

Страница загружается, но интерфейс зависает примерно через минуту. Это случай с WebSocket: отсутствует обработка Upgrade или значение proxy_read_timeout по-прежнему составляет 60 секунд.

FAQ

Почему nginx возвращает 502 Bad Gateway после добавления proxy_pass?

nginx не может установить соединение с адресом, указанным в proxy_pass. Лог ошибок в /var/log/nginx/error.log указывает на причину: connect() failed (111: Connection refused) while connecting to upstream означает, что по этому адресу никто не слушает порт, а no live upstreams означает, что все серверы в блоке upstream помечены как недоступные. Выполните sudo ss -lntp | grep 3000, чтобы увидеть, какой процесс занимает порт и к какому адресу он привязан. Приложение, привязанное к внутреннему адресу контейнера или к порту, отличному от указанного, всегда вызывает эту ошибку.

Почему мое приложение разрывает соединение примерно через минуту работы за nginx?

Соединение является WebSocket, а значение proxy_read_timeout по умолчанию составляет 60 секунд — это время ожидания между двумя операциями чтения из бэкенда. Неактивный сокет закрывается nginx, и консоль браузера сообщает о коде закрытия 1006. Установите proxy_http_version 1.1, передайте заголовки Upgrade и Connection с помощью map в $http_upgrade и увеличьте proxy_read_timeout до значения, например, 3600s. Без заголовка Upgrade обновление соединения не происходит, поэтому приложение переключается на поллинг или перестает отображать обновления в реальном времени.

Имеет ли значение завершающий слэш в proxy_pass?

Да, он меняет путь, который получает бэкенд. При использовании location /app/ и proxy_pass http://127.0.0.1:3000/ запрос к /app/status приходит на бэкенд как /status, так как любой URI после хоста и порта заменяет совпавший префикс location. Уберите этот завершающий слэш, и тот же запрос придет как /app/status. Удаление префикса часто нарушает ссылки на статические ресурсы в приложении, так как они остаются абсолютными и выдают 404 в корне сайта. Поэтому для приложений с настройкой базового пути лучше использовать форму, которая передает путь целиком.

Почему мое приложение записывает 127.0.0.1 как IP-адрес каждого посетителя?

Потому что соединение, которое получает приложение, действительно исходит от nginx через loopback-интерфейс. Адрес посетителя доходит до приложения только в заголовке, который вы задаете: proxy_set_header X-Real-IP $remote_addr; для одного значения и proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; для цепочки адресов. Приложение должно быть настроено на доверие этим заголовкам. Помните, что клиент может отправить свой собственный заголовок X-Forwarded-For, поэтому, когда nginx является пограничным сервером, перезаписывайте его с помощью $remote_addr вместо добавления.

Нужен ли TLS для соединения между nginx и моим приложением?

Нет, если приложение запущено на том же сервере и привязано к 127.0.0.1, так как этот трафик не покидает машину. Завершайте TLS на nginx, используйте proxy_pass по обычному HTTP через loopback и отправляйте X-Forwarded-Proto $scheme, чтобы приложение знало, что посетитель использовал HTTPS. Если бэкенд находится на другом хосте в сети, которую вы не контролируете, этот сегмент требует собственной защиты: либо HTTPS до бэкенда, либо создание частного туннеля между двумя машинами.