Открой логи любого живого сайта и посмотри, по каким URL приходят люди и боты. Ты увидишь зоопарк: site.ru/about, site.ru/about/, site.ru/About, http://site.ru/about, https://www.site.ru/about, site.ru/about?, site.ru//about, site.ru/index.php. Для человека это один и тот же раздел. Для поискового робота это разные документы с одинаковым содержимым - дубли. Дубли размазывают вес страницы, плодят мусор в индексе, ломают аналитику и открывают дыры безопасности (вспомни про подмену Host или протаскивание лишних слешей в путь). Задача шлюза - привести любой входящий адрес к ОДНОЙ канонической форме и отдать всё остальное через редирект. Это и называется канонизация URL.
В nginx за управление адресами отвечают две директивы: return и rewrite. Большинство людей по привычке тащат rewrite с регэкспами туда, где хватило бы одной строки return. В этом уроке разберём, почему return почти всегда лучше rewrite, чем отличаются коды 301/302/307/308, как сделать nginx 301 редирект правильно и как собрать канонизацию по образцу эталонного gateway - через map $needs_slash и return, БЕЗ леса if. И отдельно - почему в 2026 году минимизация rewrite-правил это не вкусовщина, а прямой ответ на CVE-2026-42945.

return: самый быстрый nginx редирект
return делает ровно одно: немедленно завершает обработку и отдаёт клиенту указанный код (и опционально тело или URL). Никаких регэкспов, никакого внутреннего цикла. Синтаксис прост.
Код: Выделить всё
# редирект на HTTPS - классика
server {
listen 80;
server_name cyberlake.ru www.cyberlake.ru;
return 301 https://cyberlake.ru$request_uri;
}
# отдать код без тела
location = /old-api { return 410; }
# отдать готовый ответ прямо из конфига
location = /healthz { return 200 "ok\n"; }
Когда в return указан полный URL со схемой (https://...), nginx понимает, что это внешний редирект, и ставит код 301/302 с заголовком Location. Если URL начинается с /, тоже Location, но относительный. А вот return с одним только кодом (return 403) отдаёт ответ на месте, не редиректит.
rewrite: когда регэксп всё-таки нужен
rewrite меняет URI по регулярному выражению. Он нужен там, где адрес надо РАЗОБРАТЬ и собрать заново: перенос целой ветки путей, ЧПУ поверх старого скрипта, склейка групп. Синтаксис: rewrite регэксп замена [флаг].
Код: Выделить всё
# перенос раздела: /blog/2020/foo -> /articles/foo, внешний 301
rewrite ^/blog/\d+/(.+)$ /articles/$1 permanent;
# внутреннее переписывание под скрипт, клиент адрес НЕ видит
rewrite ^/news/(\d+)$ /index.php?id=$1 last;
- permanent - внешний редирект 301, клиент получает Location и новый адрес в строке браузера.
- redirect - внешний редирект 302 (временный).
- last - внутреннее переписывание, после него nginx заново ищет location для нового $uri и обработка продолжается там. URL в браузере не меняется.
- break - внутреннее переписывание, но поиск location НЕ повторяется, обработка остаётся в текущем блоке. Дальнейшие rewrite в этом блоке игнорируются.
Порядок выполнения тоже неинтуитивный. rewrite, return, if, set, break - это директивы модуля ngx_http_rewrite_module, и они выполняются СВЕРХУ ВНИЗ на этапе rewrite-фазы, ДО выбора финального location-обработчика. Сначала отрабатывают директивы на уровне server, потом nginx выбирает location, потом отрабатывают директивы внутри него. Поэтому return на уровне server сработает раньше, чем любой location, - это и делает редирект на HTTPS таким дешёвым.
Почему return предпочтительнее rewrite (и при чём тут безопасность)
Три причины, по нарастанию важности.
Производительность. return не запускает регэксп-движок и не инициирует внутренний редирект - он просто пишет ответ. rewrite на каждый запрос прогоняет PCRE, а с флагом last ещё и заставляет nginx заново проходить фазу выбора location. На горячем пути шлюза это лишние такты на ровном месте.
Читаемость. return 301 https://host$request_uri; читается за секунду. Цепочка из пяти rewrite с группами $1, $2 и флагами требует медитации, чтобы понять, что и куда уедет, и не словит ли цикл.
Безопасность - и это главное в 2026. В мае 2026 в реальных атаках эксплуатировался CVE-2026-42945 (его прозвали "NGINX Rift") - heap buffer overflow прямо в ngx_http_rewrite_module, затрагивающий версии от 0.6.27 до 1.30.0. Неаутентифицированный злоумышленник специально сформированным запросом мог уронить worker, а в худшем сценарии добиться удалённого выполнения кода. Особенно опасны конфигурации с rewrite/if/set, использующими БЕЗЫМЯННЫЕ захваты $1, $2. Выводов два. Первый: держи nginx и модули обновлёнными (фикс в 1.30.1 / 1.31.x). Второй, архитектурный: минимизируй rewrite-правила. Чем меньше регэксп-логики в конфиге, тем меньше поверхность атаки. Каждый rewrite, который можно заменить на return или на map, надо заменить. Это ровно та практика, что зашита в эталонном gateway ngx-trace-gateway: вся канонизация там сделана через map и return, а не через лес if с регэкспами.
301 vs 302 vs 307 vs 308: метод имеет значение
Код редиректа - не косметика. Он говорит браузеру и роботу две вещи: насколько это навсегда и можно ли менять HTTP-метод. Запутаешься тут - сломаешь либо SEO, либо POST-формы.
- 301 Moved Permanently - навсегда. Поисковики переносят вес страницы на новый адрес, браузеры АГРЕССИВНО кешируют 301 (иногда навсегда). Ставь только когда уверен, что не передумаешь. Историческая тонкость: при 301/302 браузеры могут поменять POST на GET.
- 302 Found - временно. Вес не переносится, кеш мягче. Для временных заглушек, A/B, обслуживания.
- 307 Temporary Redirect - временно, но с ГАРАНТИЕЙ сохранения метода и тела. POST остаётся POST. Это и есть единственное отличие от 302.
- 308 Permanent Redirect - навсегда И с сохранением метода. POST на старый адрес уедет POST-ом на новый.
Код: Выделить всё
# API переехал, метод обязан сохраниться
location = /v1/orders { return 308 https://api.cyberlake.ru/v2/orders; }
Теперь соберём боевую канонизацию по образцу ngx-trace-gateway. Идея: один map вычисляет, нужен ли пути завершающий слеш, а дальше короткие return разруливают остальное - без единого if с регэкспом в горячем пути.
Сначала на уровне http определяем, каким путям слеш нужен, а каким нет. Файлам с расширением (.css, .js, .png), корню, путям, уже оканчивающимся на слеш, и служебным эндпойнтам слеш не нужен. Чистым "директорным" путям - нужен.
Код: Выделить всё
# в http-контексте (globals/default по образцу эталона)
map $uri $needs_slash {
default 1; # чистый путь без слеша -> добавить
~\.[a-zA-Z0-9]+$ 0; # есть расширение -> файл, не трогаем
~/$ 0; # уже оканчивается слешем
/ 0; # корень
/nginx_status 0; # служебное
}
Код: Выделить всё
server {
listen 443 ssl;
http2 on;
server_name cyberlake.ru www.cyberlake.ru;
# 1. www -> без www, всегда на канонический хост и схему
if ($host = www.cyberlake.ru) {
return 301 https://cyberlake.ru$request_uri;
}
# 2. добавить завершающий слеш чистым путям (значение из map)
location / {
if ($needs_slash) {
return 301 https://cyberlake.ru$uri/;
}
try_files $uri $uri/ =404;
}
# 3. убрать index.php из адреса
location = /index.php {
return 301 https://cyberlake.ru/;
}
}
error_page и internal redirect: красивые ошибки без дублей
Канонизация - частный случай внутренних редиректов, и тот же механизм работает для страниц ошибок. Когда nginx ловит 404 или 502, директива error_page инициирует ПОЛНОСТЬЮ НОВЫЙ внутренний запрос на указанный URI - это и есть internal redirect. По образцу http_server.conf из эталона ошибки отдаём через именованные @-локации, помеченные internal, чтобы снаружи на них нельзя было постучаться напрямую.
Код: Выделить всё
recursive_error_pages on;
error_page 404 = @error404;
error_page 403 = @error403;
error_page 500 502 503 504 = @error50x;
location @error404 {
internal;
root /var/www/errors;
try_files /404.html =404;
}
location @error50x {
internal;
root /var/www/errors;
try_files /50x.html =500;
}
Типичные грабли
- $uri вместо $request_uri в редиректе на хост. Потеряешь query string. На канонический хост/схему - всегда $request_uri.
- Циклы в rewrite. rewrite ... last, который снова попадает в location, переписывающий обратно, - бесконечный виток. nginx оборвёт его после 10 итераций с 500 и записью "rewrite or internal redirection cycle" в error_log. Лечится переходом на break или на return.
- if как швейцарский нож. "If is evil" - if с любыми директивами кроме return/rewrite ведёт себя непредсказуемо (особенно с proxy_pass и add_header внутри). В канонизации используй if ТОЛЬКО с return, а ветвление выноси в map.
- Безымянные захваты $1/$2 в 2026. Кроме общей рекомендации минимизировать rewrite, для оставшихся правил предпочитай ИМЕНОВАННЫЕ группы (?<name>...) - это снижает риск по CVE-2026-42945 и просто читается лучше.
- 301 поставил - не откатишь. Браузер закеширует 301 надолго. Пока проверяешь логику, ставь 302/307, и только убедившись - меняй на 301/308.
- Забыл add_header после редиректа. return завершает обработку, заголовки из родительского контекста на ответ редиректа могут не попасть - не рассчитывай на HSTS внутри 80-го server, который только редиректит.
- Шаг 1. Подними тестовый server на 8081 с server_name localhost; добавь блок: location = /index.php { return 301 /; }. Проверь: curl -i http://localhost:8081/index.php - жди 301 и Location: /.
- Шаг 2. Добавь map $uri $needs_slash из урока в http-контекст и location / с if ($needs_slash) { return 301 /$uri/; } (для теста относительный путь). Запрос curl -i http://localhost:8081/about должен отдать 301 на /about/, а curl -i http://localhost:8081/style.css - НЕ должен (расширение). nginx -t перед каждым reload.
- Шаг 3. Сделай server на 80 с единственной строкой return 301 https://$host$request_uri; и проверь curl -i "http://localhost/page?x=1" - в Location обязан быть ?x=1. Убери из строки $request_uri, замени на $uri, повтори - увидишь, как пропал query string.
- Шаг 4. Подключи error_page 404 = @error404; recursive_error_pages on; и internal @-локацию с try_files /404.html =404. Запроси несуществующий URL и убедись, что отдаётся твоя страница с кодом 404 (curl -i, смотри первую строку статуса).
- Шаг 5. Намеренно создай цикл: rewrite ^/loop /loop last; в location /. Сделай запрос, открой error_log и найди строку про "rewrite or internal redirection cycle" и 500 - чтобы знать этот симптом в лицо. Потом убери.
- Почему для редиректа на канонический хост в return пишут $request_uri, а не $uri? Что сломается при подмене?
- Клиент шлёт POST на старый адрес эндпойнта, который переехал. Какой код редиректа поставишь и почему НЕ 301?
- Чем отличаются флаги last и break у rewrite и в каком случае last приводит к циклу?
- Зачем @-локации для ошибок помечают internal и что даёт recursive_error_pages on?
return - твой основной инструмент управления URL: быстрый, читаемый и с минимальной поверхностью атаки. rewrite оставляй только там, где адрес реально надо разобрать регэкспом, и тогда - именованные группы и обновлённый nginx (помни про CVE-2026-42945). Канонизацию строй декларативно: map $needs_slash плюс короткие return, без if-леса, ровно как в эталонном gateway. Различай 301/302/307/308 по двум осям - навсегда ли и сохраняется ли метод: GET-страницы канонизируй 301-м, POST-эндпойнты переноси 308/307-м. Ошибки отдавай через internal @-локации с recursive_error_pages on. Один адрес - одна каноническая форма: чисто для SEO, безопасно для шлюза.