Как слеш в proxy_pass превращает рабочий API в 404
Один символ в `proxy_pass` меняет контракт между nginx и приложением. Было `proxy_pass http://backend;` — API получал `/api/v1/users`. Поставили после адреса `/` — получил `/v1/users` и ответил 404. Я покажу точную механику без магии, разберу случай из инфраструктуры питомника собак «Дом чемпионов» и дам конфигурацию, которую сам предпочитаю ставить в продакшен.
Слеш относится не к адресу сервера, а к URI
Главная ловушка — визуальная. Строки proxy_pass http://backend; и proxy_pass http://backend/; выглядят как два способа записать один адрес. Для nginx это разные инструкции. В первом варианте у proxy_pass нет URI: указаны только схема и upstream. Поэтому nginx передаёт запрос дальше с исходным путём. Во втором варианте URI присутствует и равен /. Значит, nginx должен заменить на него ту часть нормализованного URI, которая совпала с префиксным location.
Возьмём запрос /api/v1/users?id=42 и обычный префиксный блок. Без завершающего слеша у upstream останется /api/v1/users?id=42:
location /api/ {
proxy_pass http://backend;
}Если приложение объявляет маршрут /api/v1/users, это правильный контракт. Я предпочитаю такой вариант, когда backend сам владеет префиксом /api: путь виден одинаково клиенту, прокси и приложению, а трассировку проще читать.
Теперь добавим URI / после upstream:
location /api/ {
proxy_pass http://backend/;
}nginx берёт совпавшую часть /api/, заменяет её на / и отправляет /v1/users?id=42. Параметры запроса при обычной такой подстановке сохраняются; меняется именно путь. Если у backend нет маршрута /v1/users, честным результатом будет 404. Это не ошибка соединения и не проблема прав — приложение получило другой ресурс.
- `proxy_pass http://backend;` — сохранить `/api/` в передаваемом пути.
- `proxy_pass http://backend/;` — заменить `/api/` на `/`, то есть удалить внешний префикс.
- `proxy_pass http://backend/internal/;` — заменить `/api/` на `/internal/`.
- Слеш в конце `location /api/` определяет границу префикса; слеш после upstream определяет наличие URI в `proxy_pass`.
Таблица, которой достаточно для большинства конфигов
Я разбираю такую аварию на бумаге до перезагрузки nginx. Записываю три значения: URI клиента, строковый префикс location и URI в proxy_pass. Для запроса /api/orders/17 блок location /api/ совпадает с /api/; остаток равен orders/17. Если URI в proxy_pass есть, результат собирается как этот URI плюс остаток. Поэтому / даёт /orders/17, а /service/ — /service/orders/17.
Есть неприятные пограничные комбинации без согласованных слешей. Например, location /api/ вместе с proxy_pass http://backend/service; формально допустимы, но результатом для /api/orders станет /serviceorders: URI /service склеится с остатком orders. nginx не обязан угадывать, где разработчик хотел разделитель. Поэтому при замене я заканчиваю слешем и location, и URI назначения.
Сам запрос /api тоже требует внимания. Для префиксного location /api/ с проксированием nginx штатно вернёт 301 на /api/. Для браузерного GET это обычно нормально. Для POST я не люблю полагаться на поведение клиента при 301 и объявляю точное правило с 308, сохраняющим метод и тело:
location = /api {
return 308 /api/;
}- `/api/orders` + `proxy_pass http://backend;` → `/api/orders`.
- `/api/orders` + `proxy_pass http://backend/;` → `/orders`.
- `/api/orders` + `proxy_pass http://backend/v2/;` → `/v2/orders`.
- `/api/orders?paid=1` + `proxy_pass http://backend/;` → `/orders?paid=1`.
Почему regex-location живёт по другому правилу
В строковом location /api/ nginx знает точную длину заменяемого префикса. В регулярном выражении совпадение может зависеть от веток, групп и произвольной длины. Поэтому автоматическое правило «отрезать совпавший префикс и подставить URI из proxy_pass» становится неоднозначным. Официальная документация требует в regex-location и в именованном location указывать proxy_pass без URI.
Попытка написать такой блок не создаст скрытый неправильный маршрут — проверка конфигурации должна завершиться ошибкой:
location ~ ^/api/ {
proxy_pass http://backend/;
}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. Та же ограничивающая логика действует для именованного location, блока if и limit_except. Это хороший предохранитель. Плохо другое: администратор начинает добавлять rewrite, переменные и if, хотя для маршрута /api/ вообще не требовалось регулярное выражение.
Если regex действительно необходим, преобразование нужно описать явно. Например, этот вариант намеренно удаляет /api/, а именованный capture показывает, что именно попадёт в upstream:
location ~ ^/api/(?<api_path>.*)$ {
proxy_pass http://backend/$api_path$is_args$args;
}Для /api/v1/users?active=1 получится /v1/users?active=1. Здесь proxy_pass содержит переменные, поэтому nginx передаёт вычисленный URI как заданный, а не выполняет обычную префиксную подстановку. Учтите две детали. Capture берётся из нормализованного URI, где %2F и другие последовательности уже декодированы, поэтому закодированные символы в идентификаторах могут дойти до backend в изменённом виде. А если в proxy_pass с переменными указано не имя из блока upstream, а доменное имя, nginx понадобится директива resolver. Если же путь надо сохранить, проще и безопаснее оставить proxy_pass http://backend; без URI.
- Строковые locations сначала сравниваются по длине префикса.
- Затем nginx проверяет regex-locations в порядке записи и выбирает первое совпадение.
- Модификатор `^~` у выбранного префикса запрещает последующую проверку регулярных выражений.
- Точный `location = /api` имеет высший приоритет для URI `/api`.
Как один слеш остановил портал питомника «Дом чемпионов»
Ниже — обезличенный случай питомника собак «Дом чемпионов»: 25 рабочих мест у администраторов, кинологов, ветеринара и менеджеров по продаже щенков. Всё крутилось на одной виртуальной машине Ubuntu Server 24.04 LTS с 2 vCPU и 4 Гбайт RAM: nginx 1.30.4 и отдельный контейнер внутреннего API версии 3.8.2 на порту 8080. Производительности хватало с большим запасом: проблема никак не зависела от процессоров или памяти.
Внешний интерфейс обращался к /api/v1/session, /api/v1/dogs и /api/v1/litters. Backend был спроектирован с теми же маршрутами, включая /api. Рабочий конфиг содержал proxy_pass http://champions_api;. Во время приведения конфигураций к «единому стилю» после имени upstream добавили /. Проверка nginx -t прошла: для префиксного location это совершенно корректный синтаксис. После reload TCP-соединение с контейнером устанавливалось, health-check самого контейнера был зелёным, но авторизация на всех 25 местах перестала работать.
За первые четыре минуты в журнал попали 37 ответов 404. На фронтенде это выглядело как недоступность сервиса, хотя nginx и приложение были живы. Клиент отправлял POST /api/v1/session, а backend регистрировал POST /v1/session. У него такого маршрута не было. Именно запись access log приложения сняла спор: фронтенд nginx показывает входящий URI, но только backend достоверно показывает полученную request target.
Мы вернули директиву без URI, выполнили проверку и reload:
nginx -t
systemctl reload nginx
curl -i 'https://portal.example.test/api/v1/session'Сервис восстановился сразу; перезапуск контейнера и всей виртуальной машины не понадобился. Затем мы добавили контрактные проверки трёх маршрутов в процедуру выкладки. Инцидент занял 18 минут, а исправление — один удалённый символ. Это характерная история: больше времени съедает ложная уверенность, что слеш косметический.
- До изменения upstream видел `/api/v1/session`.
- После добавления `/` upstream видел `/v1/session`.
- `$upstream_status` был равен 404: ответ сформировал backend.
- После возврата `proxy_pass http://champions_api;` все три контрольных маршрута снова отвечали ожидаемо.
Конфигурация, которую я оставил в продакшене
В «Доме чемпионов» префикс /api был частью контракта приложения, поэтому я сохранил его. Дополнительно поставил ^~, чтобы будущий общий regex для файлов или служебных путей не перехватил запрос API. Это не обязательный символ для простого конфига, но полезная страховка в развивающемся server-блоке.
Итоговая сокращённая конфигурация выглядела так:
upstream champions_api {
server 127.0.0.1:8080;
keepalive 16;
}
server {
listen 443 ssl;
server_name portal.example.test;
location = /api {
return 308 /api/;
}
location ^~ /api/ {
proxy_pass http://champions_api;
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_connect_timeout 3s;
proxy_read_timeout 30s;
}
}Здесь отсутствие слеша после champions_api — осознанное требование, а не вопрос оформления.
На середину сентября 2026 года официальный сайт nginx публикует 1.30.4 как stable и 1.31.5 как mainline. Описанная семантика proxy_pass одинакова в этих ветках и существует давно; обновление пакета не должно «починить» неверный контракт пути. После изменения я всегда выполняю nginx -t, затем reload и несколько запросов к реальным endpoint. Одной синтаксической проверки мало: оба варианта со слешем синтаксически правильны.
- Сначала согласовать, должен ли backend видеть `/api`.
- Для сохранения префикса оставить `proxy_pass` без URI.
- Защитить дерево API с помощью `location ^~ /api/`, если в server есть regex-locations.
- Отдельно обработать `/api`, если возможны запросы без завершающего слеша.
- Проверить GET и небезопасный метод, например POST, через публичный адрес.
Двойные слеши, merge_slashes и 301 от backend
Слеш в конце proxy_pass влияет не только на префикс, но и на то, в каком виде уходит весь путь. Документация модуля различает два режима. Без URI в директиве исходный запрос передаётся upstream «в том же виде, в каком его прислал клиент». С URI nginx берёт нормализованный URI: декодирует %XX, разрешает . и .. и, при включённой по умолчанию merge_slashes on, склеивает подряд идущие слеши, а потом заменяет совпавшую с location часть. Для запроса /api//v1/users это выглядит так:
# клиент: GET /api//v1/users
location /api/ {
proxy_pass http://backend; # upstream получит /api//v1/users
}
location /api/ {
proxy_pass http://backend/; # upstream получит /v1/users
}На практике это важно для API, которые передают в пути base64 или закодированный %2F: в режиме замены backend получит уже декодированный и перекодированный путь, и подпись запроса или идентификатор объекта может перестать совпадать.
merge_slashes задаётся в контексте http или server и влияет на сопоставление с location: без неё /api//v1 не нашёл бы нужный префикс. Отключать её ради одного API я не советую — документация прямо предупреждает, что из соображений безопасности лучше этого избегать: правила вида location /admin/ перестают ловить //admin/. Если backend действительно нужен исходный вид пути, выгоднее оставить proxy_pass без URI и перенести снятие префикса в само приложение.
Второй частый симптом после «уборки» слешей — не 404, а 301 или 302 от backend на путь без /api. Приложение, получив /v1/users, строит редирект на свой собственный адрес, например http://backend/v1/users/. По умолчанию действует proxy_redirect default, который для location /api/ и proxy_pass http://backend/; переписывает в заголовке Location префикс http://backend/ обратно на /api/. Но только если backend вернул именно такой адрес. Если приложение собирает ссылку из заголовка Host клиента, замена не сработает, и браузер уйдёт на https://portal.example.test/v1/users/, где его ждёт уже 404 от фронтенда. Для proxy_pass с переменными параметр default вообще недопустим — правило нужно писать явно:
location /api/ {
proxy_pass http://backend/;
proxy_redirect https://portal.example.test/ /api/;
}- Без URI в `proxy_pass` путь уходит в исходном виде: двойные слеши и `%2F` сохраняются.
- С URI уходит нормализованный путь: слеши склеены, escape-последовательности декодированы и заново закодированы.
- `merge_slashes off` ослабляет защиту префиксных location — не выключайте её без аудита всех правил доступа.
- Для `rewrite ... break` внутри location URI из `proxy_pass` игнорируется, и upstream получает изменённый URI целиком.
- 301 от backend на путь без префикса проверяйте через `curl -i` и заголовок `Location`, затем правьте `proxy_redirect` или базовый URL приложения.
Как найти источник 404 за десять минут
Сначала определите, кто вернул 404. Я временно использую отдельный формат access log с входным $request_uri, нормализованным $uri, адресом upstream и его статусом:
log_format proxy_diag '$remote_addr "$request" request_uri="$request_uri" '
'uri="$uri" status=$status upstream="$upstream_addr" '
'upstream_status="$upstream_status"';
access_log /var/log/nginx/api_diag.log proxy_diag;Если upstream_status=404 и адрес заполнен, запрос дошёл до приложения. Этот журнал не показывает с абсолютной гарантией весь путь, отправленный upstream, поэтому рядом нужно смотреть access log backend.
Затем выгрузите фактически загруженную конфигурацию командой nginx -T. Она полезнее просмотра одного файла: директива могла прийти из include, а запрос — попасть в другой server или regex-location. Найдите все location, proxy_pass и rewrite, способные обработать URI. Помните: nginx сопоставляет locations с нормализованным URI, а $request_uri хранит исходную строку запроса вместе с аргументами и не изменяется.
Последний шаг — короткая матрица проверок. Я прогоняю /api, /api/, реальный вложенный маршрут и тот же маршрут с query string. Для API с записью добавляю тестовый POST на безопасном стенде. После этого фиксирую ожидаемый upstream path рядом с конфигурационным тестом. Так следующий человек увидит не просто «слеш нужен», а проверяемый контракт между внешним и внутренним URI.
- `nginx -T` — увидеть объединённую активную конфигурацию.
- `nginx -t` — проверить синтаксис перед reload.
- `curl -i` — увидеть статус, redirect и заголовок `Location`.
- Access log backend — подтвердить путь, реально полученный приложением.
- `$upstream_status` — отличить ответ приложения от локального ответа nginx.
Частые вопросы
Сохраняет ли proxy_pass без слеша префикс /api/?
Да, если после адреса upstream нет URI: `proxy_pass http://backend;`. Для обычного исходного запроса backend получит путь вместе с `/api/`.
Почему proxy_pass с одним слешем удаляет /api/?
Потому что `/` после адреса является URI директивы. nginx заменяет им часть нормализованного URI, совпавшую с префиксным `location /api/`.
Можно ли указать proxy_pass http://backend/ в regex-location?
Нет: в regex-location автоматическая граница заменяемого префикса не определена. Используйте `proxy_pass` без URI либо явно соберите нужный URI из capture-переменных.
Как понять, что 404 вернул backend, а не nginx?
Проверьте `$upstream_status` и `$upstream_addr` в access log. Если upstream вернул 404, подтвердите переданный путь по access log самого приложения.
Нужен ли rewrite, чтобы удалить /api/?
Обычно нет. Для префиксного `location /api/` достаточно `proxy_pass http://backend/;`. Я добавляю `rewrite` только при более сложном и явно документированном преобразовании.
Что будет с двойными слешами в URI при proxy_pass?
Без URI в `proxy_pass` nginx передаёт путь в том виде, в каком его прислал клиент, и `//` сохраняются. С URI передаётся нормализованный путь: при `merge_slashes on` (по умолчанию) слеши склеены, а закодированные символы декодированы.
Почему после среза /api/ backend редиректит на адрес без префикса?
Приложение строит `Location` из пути, который получило. `proxy_redirect default` исправит только редирект на адрес из `proxy_pass`; если backend берёт имя из `Host`, задайте `proxy_redirect` явно или настройте базовый URL приложения.
Источники
- Официальная документация nginx: ngx_http_proxy_module — Директива proxy_pass: замена части нормализованного URI, передача без URI в исходном виде, случаи regex/named location, rewrite ... break и переменных. https://nginx.org/ru/docs/http/ngx_http_proxy_module.html#proxy_pass
- Официальная документация nginx: proxy_redirect — Параметр default строится из location и proxy_pass и недопустим при переменных в proxy_pass. https://nginx.org/ru/docs/http/ngx_http_proxy_module.html#proxy_redirect
- Официальная документация nginx: ngx_http_core_module — Директивы location и merge_slashes: порядок выбора prefix и regex locations, модификаторы = и ^~, перенаправление 301 для префикса со слешем, склейка слешей. https://nginx.org/ru/docs/http/ngx_http_core_module.html#location
- Официальная документация nginx: ngx_http_rewrite_module — Директива return: коды перенаправления, 308 обрабатывается как перенаправление начиная с nginx 1.13.0. https://nginx.org/ru/docs/http/ngx_http_rewrite_module.html#return
- Официальная документация nginx: ngx_http_log_module — Директивы log_format и access_log. https://nginx.org/ru/docs/http/ngx_http_log_module.html
- Официальная страница загрузки nginx — Версии на сентябрь 2026 года: mainline 1.31.5, stable 1.30.4. https://nginx.org/ru/download.html
