HTTP это не только тело ответа. Половина смысла каждого запроса и ответа живёт в заголовках: кто клиент, по какому протоколу пришёл, можно ли кэшировать, какой Content-Type, какие политики безопасности применить. nginx стоит ровно посередине между клиентом и бэкендом, и именно он решает, что отдать клиенту в ответ и что рассказать бэкенду о клиенте. Два инструмента для этого: add_header (добавить заголовок в ответ клиенту) и proxy_set_header (переписать заголовок запроса, который уйдёт на бэкенд).
Звучит просто. На практике nginx заголовки это минное поле. Самая частая боль звучит так: "я прописал X-Frame-Options и Content-Security-Policy в server, всё работало, потом добавил один add_header внутри location /api/ - и половина security-заголовков пропала на этом location". Человек ничего не ломал специально, а сайт стал уязвимее. Это не баг, это документированное и крайне контринтуитивное поведение наследования. Разберём его до винтика, потому что на нём горят даже опытные. И заодно научимся аккуратно пробрасывать контекст клиента на бэкенд - X-Real-IP, X-Forwarded-For, X-Forwarded-Proto и сквозные трассирующие заголовки, как это сделано в эталонном gateway.
Ключевое разделение, которое надо держать в голове с первой секунды: add_header правит ОТВЕТ клиенту, proxy_set_header правит ЗАПРОС к бэкенду. Это разные стороны nginx и разные модули. Путать их - классика для новичка.

Главная грабля: nginx add_header отменяет родительские заголовки
Открываем официальную формулировку из ngx_http_headers_module и читаем медленно:
Переведём на человеческий. add_header наследуется с уровня выше (http -> server -> location) только если на текущем уровне нет НИ ОДНОГО add_header. Стоит написать хотя бы один add_header в location - и все add_header из server и http на этом location перестают применяться. Не дополняются, а просто исчезают. Наследование тут не кумулятивное, а "всё или ничего".These directives are inherited from the previous configuration level if and only if there are no add_header directives defined on the current level.
Смотрим, как это убивает безопасность. Конфиг, который выглядит правильным:
Код: Выделить всё
server {
listen 443 ssl;
server_name shop.example.com;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Content-Security-Policy "default-src 'self'" always;
location / {
proxy_pass http://frontend;
}
location /api/ {
# хотели просто добавить CORS-заголовок
add_header Access-Control-Allow-Origin "https://shop.example.com" always;
proxy_pass http://backend;
}
}
Проверяется элементарно, не на глаз:
Код: Выделить всё
$ curl -sI https://shop.example.com/ | grep -i -E 'x-frame|content-security|x-content'
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN
content-security-policy: default-src 'self'
$ curl -sI https://shop.example.com/api/ | grep -i -E 'x-frame|content-security|x-content'
$
Маленькое, но важное про параметр always. Без always заголовок добавляется только к ответам 200, 201, 204, 206, 301, 302, 303, 304, 307, 308. Ответы вроде 404, 500, 502 уйдут без этого заголовка. Для security-заголовков это плохо: страница ошибки тоже должна быть защищена. Поэтому security-заголовки почти всегда пишут с always. Но always не меняет правило наследования - оно про другое.
Как с этим жить: три рабочих решения
Решение 1. Повторять весь набор там, где добавляешь новый заголовок. Грубо, но работает на ванильном nginx без модулей. На /api/ просто перечисляешь заново всё:
Код: Выделить всё
location /api/ {
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Content-Security-Policy "default-src 'self'" always;
add_header Access-Control-Allow-Origin "https://shop.example.com" always;
proxy_pass http://backend;
}
Код: Выделить всё
# /etc/nginx/snippets/security_headers.conf
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Content-Security-Policy "default-src 'self'" always;
# в location
location /api/ {
include snippets/security_headers.conf;
add_header Access-Control-Allow-Origin "https://shop.example.com" always;
proxy_pass http://backend;
}
Решение 2. add_header_inherit merge - нативно, начиная с nginx 1.29.3. Свежий ответ команды nginx на эту боль. Директива add_header_inherit меняет правило наследования: параметр merge заставляет дочерний уровень дополнять родительский набор, а не затирать его. Параметр off, наоборот, отключает наследование. Указываешь merge один раз на верхнем уровне - и дальше всё наследуется рекурсивно:
Код: Выделить всё
http {
add_header_inherit merge; # nginx 1.29.3+
server {
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
location /api/ {
# теперь родительские НЕ теряются - они смержены
add_header Access-Control-Allow-Origin "https://shop.example.com" always;
proxy_pass http://backend;
}
}
}
Решение 3. more_set_headers из модуля headers-more - кумулятивен по природе. Сторонний динамический модуль (ngx_http_headers_more_filter_module от той же команды, что и OpenResty), в эталонном проекте на OpenResty он доступен из коробки. Его директивы more_set_headers наследуются нормально: заголовки с родительских уровней не исчезают при добавлении нового в дочернем. more_set_headers вдобавок умеет то, чего add_header не может в принципе:
- заменять заголовок, а не добавлять второй с тем же именем (add_header всегда плодит дубли);
- ставить заголовки на ЛЮБОЙ статус ответа без плясок с always;
- условно ставить по статусу: more_set_headers -s 404 ...;
- удалять заголовки через more_clear_headers (об этом ниже).
Код: Выделить всё
server {
more_set_headers "X-Content-Type-Options: nosniff";
more_set_headers "X-Frame-Options: SAMEORIGIN";
location /api/ {
# родительские more_set_headers остаются, этот добавляется сверху
more_set_headers "Access-Control-Allow-Origin: https://shop.example.com";
proxy_pass http://backend;
}
}
Итог по граблям: на ванильном свежем nginx бери add_header_inherit merge; если модуль headers-more уже в сборке (OpenResty, многие дистрибутивные пакеты) - more_set_headers удобнее и мощнее; на совсем старом nginx без модулей - повторяй набор через include.
proxy_set_header: пробрасываем контекст клиента бэкенду
Теперь вторая сторона. Когда nginx проксирует запрос, по умолчанию бэкенд видит nginx, а не клиента. С точки зрения бэкенда соединение пришло с адреса nginx, заголовок Host может быть не тем, протокол неизвестен. Бэкенду нужно рассказать правду о клиенте - это и делает proxy_set_header. Без модуля realip (ему посвящён следующий урок) бэкенд иначе вообще не узнает реальный IP клиента: $remote_addr на бэкенде будет адресом nginx.
Канонический набор для проксирования, ровно как в global_proxy эталонного gateway:
Код: Выделить всё
location / {
proxy_pass http://backend;
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_set_header X-Forwarded-Host $host;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
Host $host - бэкенду уходит имя хоста из запроса клиента, а не имя upstream-сервера. Многие приложения по Host выбирают виртуальный хост, генерируют абсолютные ссылки, разделяют тенантов. Если не поставить, в Host попадёт значение из proxy_pass (имя апстрима), и приложение запутается. Тонкость: $host это имя из строки запроса или из заголовка Host, приведённое к нижнему регистру и без порта. Есть ещё $http_host (ровно как прислал клиент, с портом) и $server_name. В 90 процентах случаев нужен именно $host.
X-Real-IP $remote_addr - один реальный IP-адрес клиента (точнее, того, кто подключился к nginx). Это де-факто стандарт nginx для "адреса клиента". Приложение читает X-Real-IP, чтобы знать, кто пришёл. Это и есть тот самый nginx x-real-ip, который ищут в каждом гайде.
X-Forwarded-For $proxy_add_x_forwarded_for - история проксей. nginx x-forwarded-for это список IP через запятую: каждый прокси дописывает в конец адрес, с которого к нему пришёл запрос. Переменная $proxy_add_x_forwarded_for сама делает магию - берёт значение X-Forwarded-For, которое прислал клиент, и добавляет к нему $remote_addr. Если перед nginx стоит ещё CDN или балансировщик, цепочка нарастает: 203.0.113.7, 10.0.0.5. Грабля безопасности: клиент может прислать поддельный X-Forwarded-For, и $proxy_add_x_forwarded_for честно припишет подделку в начало цепочки. Поэтому доверять можно только тем позициям в цепочке, что добавили твои собственные прокси. Как корректно вычислять реальный IP из этой цепочки - тема следующего урока про realip и set_real_ip_from.
X-Forwarded-Proto $scheme - http или https. Критично: если TLS терминируется на nginx, а до бэкенда идёт чистый http, бэкенд думает что соединение незащищённое и может генерировать http-ссылки, ломать редиректы, городить бесконечный цикл "редирект на https". $scheme = реальная схема, по которой клиент пришёл к nginx. Без этого заголовка фреймворки вроде Laravel, Django, Rails не понимают, что снаружи был https.
Про proxy_http_version 1.1 и Connection "" - это про keepalive к бэкенду. На современном nginx (с 1.29.7, вошло в stable 1.30) соединение к бэкенду по умолчанию уже HTTP/1.1 с keepalive, раньше был HTTP/1.0 с close на каждый запрос. Но для совместимости и явности привычка прописывать proxy_http_version 1.1 и пустой Connection (чтобы не утёк хоп-бай-хоп заголовок Connection: close) остаётся хорошим тоном. Это включает переиспользование соединений к апстриму и снимает оверхед на постоянные TCP+TLS-рукопожатия.
Сквозная трассировка: трассирующие nginx заголовки через цепочку хопов
Когда запрос идёт через несколько nginx-узлов и бэкендов, без сквозного идентификатора отладка превращается в гадание. Эталонный gateway решает это связкой proxy_set_header и map-цепочки. Идея: на входе подхватить X-Request-ID от клиента, если его нет - сгенерировать из встроенной $request_id, и протащить дальше вместе с цепочкой пройденных хопов.
Код: Выделить всё
# global_trace: вычисляем сквозной идентификатор
map $http_x_request_id $trace_request_id {
default $http_x_request_id; # пришёл извне - уважаем
"" $request_id; # нет - генерим (16 hex-байт от nginx)
}
# в global_proxy пробрасываем трассировку бэкенду и следующему хопу
proxy_set_header X-Request-ID $trace_request_id;
proxy_set_header X-HOP-Name $hop_name;
proxy_set_header X-Request-Chain $request_hops_chain;
Условные заголовки, удаление и проброс: тонкая работа
Условный заголовок через map. add_header не умеет if по-человечески (if в nginx коварен), зато отлично дружит с map. Хитрость: если значение переменной пустая строка, add_header такой заголовок вообще не отправляет. Этим и пользуемся - включаем заголовок только при нужном условии:
Код: Выделить всё
map $sent_http_content_type $cache_control_value {
default "no-store";
"~*application/json" "no-cache";
"~*text/css" "public, max-age=2592000";
"~*application/javascript" "public, max-age=2592000";
}
server {
add_header Cache-Control $cache_control_value always;
}
Удаление заголовков. Два инструмента под разные задачи.
proxy_hide_header убирает заголовок, который прислал бэкенд, до того как ответ уйдёт клиенту. Классика - спрятать палево о бэкенде:
Код: Выделить всё
location /api/ {
proxy_pass http://backend;
proxy_hide_header X-Powered-By; # PHP/Express любят это слать
proxy_hide_header Server; # версия бэкенда
proxy_hide_header X-AspNet-Version;
}
more_clear_headers (из headers-more) сильнее: чистит любой заголовок ответа независимо от того, добавил его бэкенд, nginx или сам модуль, и умеет шаблоны:
Код: Выделить всё
more_clear_headers "X-Powered-By";
more_clear_headers "Server";
more_clear_headers -s 404 "Cache-Control"; # только на 404
И сразу про устаревшее, чтобы не тащить из старых гайдов: X-XSS-Protection в 2026 считается мёртвым. OWASP прямо рекомендует его не слать (или слать значение 0) и полагаться на Content-Security-Policy. Если видишь X-XSS-Protection в чужом конфиге - это легаси, не копируй.
Мини-лаба: безопасные дефолты плюс проброс контекста
Соберём руками рабочий фрагмент и проверим оба эффекта - и ответные заголовки, и то, что уходит бэкенду. Понадобится nginx (лучше 1.30+, чтобы был add_header_inherit) и любой эхо-бэкенд, который покажет полученные заголовки. Подойдёт mendhak/http-https-echo или python -m http.server для статики, но эхо нагляднее.
- Шаг 1. Создай /etc/nginx/snippets/security_headers.conf с тремя строками: add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header Referrer-Policy "no-referrer" always;
- Шаг 2. В server подключи include snippets/security_headers.conf; и подними два location: / и /api/. Во втором добавь свой add_header (например Access-Control-Allow-Origin) И не забудь снова include тот же сниппет - воспроизведи правильное решение грабли.
- Шаг 3. В обоих location пропиши блок proxy_set_header: Host $host, X-Real-IP $remote_addr, X-Forwarded-For $proxy_add_x_forwarded_for, X-Forwarded-Proto $scheme, плюс proxy_http_version 1.1 и Connection "".
- Шаг 4. nginx -t, затем nginx -s reload. Никаких правок без проверки конфига - это рефлекс.
- Шаг 5. Проверь ответные заголовки на ОБОИХ путях: curl -sI http://localhost/ и curl -sI http://localhost/api/. Убедись, что security-набор присутствует в обоих. Потом ради эксперимента убери include из /api/, перезапусти и посмотри, как заголовки исчезли - почувствуй грабли руками.
- Шаг 6. Проверь, что уходит бэкенду: curl -s http://localhost/api/ и найди в эхо-ответе X-Real-IP, X-Forwarded-For, X-Forwarded-Proto. Пошли curl с подставным заголовком: curl -s -H "X-Forwarded-For: 1.2.3.4" http://localhost/api/ - и увидь, как твоя подделка приклеилась в начало цепочки. Это и есть та дыра, которую закроет realip в следующем уроке.
- Ты добавил один add_header внутри location, и часть заголовков из server пропала именно на этом location. Почему так и какие три способа это починить ты знаешь?
- В чём разница между $host, $http_host и значением, которое попадёт в Host без proxy_set_header Host?
- Зачем бэкенду X-Forwarded-Proto $scheme и что сломается без него при TLS-терминации на nginx?
- Чем proxy_hide_header отличается от more_clear_headers, и какой из них уберёт заголовок, добавленный самим nginx?
add_header правит ответ клиенту и наследуется по принципу "всё или ничего": один add_header в дочернем контексте отменяет весь родительский набор. Лечится повтором набора через include, нативной add_header_inherit merge (nginx 1.29.3+) или кумулятивным more_set_headers из headers-more. proxy_set_header правит запрос к бэкенду: Host $host, X-Real-IP, X-Forwarded-For, X-Forwarded-Proto $scheme плюс трассирующие X-Request-ID и цепочка хопов - так бэкенд узнаёт правду о клиенте и так строится сквозная трассировка. Удаление - proxy_hide_header для заголовков апстрима и more_clear_headers как универсальный ластик. Главное, что ты унёс: после любой правки заголовков проверяй curl -I по разным location, не верь конфигу на слово. И помни про дыру X-Forwarded-For - её мы закроем в следующем уроке про realip.