Reverse proxy — сервер, который принимает запросы клиентов вместо приложения: Nginx слушает порты 80 и 443, завершает HTTPS, сам отдаёт статику и передаёт остальное в Node.js через proxy_pass, добавляя заголовки Host и X-Forwarded-*. Ниже — рабочий конфиг для Nginx 1.30 с редиректом на HTTPS, кэшем статики, gzip, лимитом тела, таймаутами и заголовками безопасности, проверенный nginx -t и curl в Docker.
После материала вы сможете
- Настроить
server-блоки сproxy_passи передачейHost,X-Real-IPиX-Forwarded-*в Node.js-приложение - Отдавать статику средствами Nginx с кэш-заголовками и gzip, ограничить размер тела запроса и таймауты
- Включить HTTPS с редиректом с HTTP и заголовками безопасности, не потеряв их из-за наследования
add_header - Проверить конфиг
nginx -t, перезагрузить его без простоя и найти причину 413 и 504 в логах
Что знать заранее
Проверено на
Nginx 1.30.5 (stable)Node.js 24 LTSDocker Compose v5
Сверено с документацией: 11 октября 2026 г.
Содержание
Что делает reverse proxy
Node.js-приложение умеет отвечать на HTTP само, но выставлять его порт прямо в интернет неудобно и рискованно. Перед ним ставят reverse proxy (обратный прокси) — сервер, который принимает соединения клиентов от имени приложения и пересылает запросы дальше. Клиент видит только Nginx; адрес и порт приложения остаются внутри сервера или Docker-сети.
- 01Браузер
- 02Nginx :443
- 03Node.js :3000
- TLS: Nginx хранит сертификат и расшифровывает HTTPS, приложение работает по обычному HTTP во внутренней сети
- Статика: файлы из папки отдаются без участия Node.js — с кэш-заголовками и сжатием
- Защита на входе: лимит размера запроса, таймауты, заголовки безопасности, редирект с HTTP на HTTPS
- Маршрутизация: несколько приложений и доменов на одном сервере, переключение между версиями при деплое
Стенд: Nginx и Node.js в одной Docker-сети
Конфиг удобно отлаживать локально, не трогая сервер. Нужны Docker с Compose и curl. Стенд — два контейнера в одной сети Compose: официальный образ nginx:stable-alpine (на момент проверки — Nginx 1.30.5 из ветки stable) и node:24-alpine с маленьким приложением без зависимостей. Внутри сети контейнеры находят друг друга по имени сервиса: Nginx обращается к приложению как к app:3000, а наружу открыты только порты прокси.
name: kb-nginx
services:
app:
image: node:24-alpine
command: ["node", "/app/server.mjs"]
volumes:
- ./app:/app:ro
environment:
APP_VERSION: v1
proxy:
image: nginx:stable-alpine
depends_on:
- app
ports:
- "8080:80"
- "8443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./nginx/snippets:/etc/nginx/snippets:ro
- ./nginx/certs:/etc/nginx/certs:ro
- ./static:/var/www/app:roimport { createServer } from "node:http";
const PORT = Number(process.env.PORT ?? 3000);
const VERSION = process.env.APP_VERSION ?? "v1";
function send(res, status, body) {
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(body));
}
const server = createServer((req, res) => {
const url = new URL(req.url, "http://internal");
if (url.pathname === "/healthz") return send(res, 200, { ok: true, version: VERSION });
if (url.pathname === "/api/whoami") {
// what the app sees behind the proxy: the original Host, client IP and scheme
return send(res, 200, {
version: VERSION,
host: req.headers.host,
realIp: req.headers["x-real-ip"],
forwardedFor: req.headers["x-forwarded-for"],
forwardedProto: req.headers["x-forwarded-proto"],
peer: req.socket.remoteAddress,
});
}
if (url.pathname === "/api/slow") {
const ms = Number(url.searchParams.get("ms") ?? 1000);
return setTimeout(() => send(res, 200, { waitedMs: ms }), ms);
}
if (url.pathname === "/api/upload" && req.method === "POST") {
let bytes = 0;
req.on("data", (chunk) => (bytes += chunk.length));
req.on("end", () => send(res, 200, { receivedBytes: bytes }));
return;
}
send(res, 404, { error: "not found" });
});
server.listen(PORT, () => console.log(`app ${VERSION} on :${PORT}`));
// docker stop sends SIGTERM: stop accepting new connections,
// let in-flight requests finish, then exit
process.on("SIGTERM", () => {
console.log(`app ${VERSION}: SIGTERM, closing`);
server.close(() => process.exit(0));
});/api/whoami показывает заголовки, дошедшие до Node.js, /api/slow отвечает с задержкой, /api/upload считает байты телаДля HTTPS на стенде нужен сертификат. Let’s Encrypt не выпускает сертификаты для локальных имён, поэтому здесь создаём самоподписанный: браузер ему не доверяет, а curl примет с флагом -k. На сервере его место займёт сертификат от certbot.
mkdir -p nginx/certs static/assets
openssl req -x509 -nodes -newkey rsa:2048 -days 30 \
-subj "/CN=example.com" \
-addext "subjectAltName=DNS:example.com,DNS:www.example.com" \
-keyout nginx/certs/privkey.pem -out nginx/certs/fullchain.pemexample.com и www.example.com — только для локальной проверкиВ static/assets/ положите любой JS-файл больше 1 КБ, например собранный бандл app.3f9c2b.js: на нём проверим кэш и сжатие. Конфиг Nginx и сниппет с заголовками — в следующих разделах.
Конфиг: server, location и proxy_pass
Конфиг Nginx состоит из вложенных блоков. В блоке http — в образе он уже есть в /etc/nginx/nginx.conf и подключает все файлы conf.d/*.conf — описываются виртуальные серверы server, а внутри них location, правила для путей. Nginx выбирает server по порту и имени из заголовка Host (директива server_name), затем подходящий location по пути запроса.
upstream app_backend {
server app:3000;
}
server_tokens off;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/css application/javascript application/json image/svg+xml;
# HTTP: only the Let's Encrypt challenge and a redirect to HTTPS
server {
listen 80;
server_name example.com www.example.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
http2 on;
server_name example.com www.example.com;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
client_max_body_size 5m;
include /etc/nginx/snippets/security-headers.conf;
# hashed static files: served by Nginx itself, cached for a year
location /assets/ {
root /var/www/app;
try_files $uri =404;
include /etc/nginx/snippets/security-headers.conf;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}
# everything else goes to the Node.js app
location / {
proxy_pass http://app_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_connect_timeout 5s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
}
}upstream app_backend
- Что делает
- Группа серверов приложения
- Почему так
- Сюда добавляют второй экземпляр или новую версию при деплое
proxy_pass http://app_backend
- Что делает
- Передаёт запрос приложению
- Почему так
- Без URI в конце путь уходит в приложение в исходном виде
proxy_set_header Host $host
- Что делает
- Исходное имя сайта
- Почему так
- По умолчанию Nginx подставляет
$proxy_host— имя изproxy_pass, то естьapp_backend
X-Real-IP, X-Forwarded-For
- Что делает
- IP клиента
- Почему так
- Иначе приложение видит адрес самого Nginx
X-Forwarded-Proto $scheme
- Что делает
- Схема запроса, здесь
https - Почему так
- Нужна приложению для абсолютных ссылок, редиректов и cookie с флагом
Secure
proxy_connect_timeout, proxy_read_timeout
- Что делает
- Ожидание соединения и ответа
- Почему так
- По умолчанию по 60 секунд; чтение считается между двумя операциями, а не на весь ответ
С версии 1.29.7 Nginx по умолчанию разговаривает с приложением по HTTP/1.1 (раньше — 1.0), так что в ветке 1.30 строка proxy_http_version 1.1 больше не обязательна. Для WebSocket всё равно нужна отдельная настройка заголовков Upgrade и Connection — она описана в документации Nginx.
Статика, кэш-заголовки и gzip
Отдавать файлы Nginx умеет быстрее и дешевле, чем Node.js. В location /assets/ директива root указывает папку, а try_files $uri =404 отдаёт файл или честный 404, не пропуская запрос в приложение. Файлы с хешем в имени (app.3f9c2b.js) можно кэшировать на год с immutable: при новой сборке меняется имя файла, а не содержимое по старому адресу. HTML и ответы API так кэшировать нельзя.
Параметр always у Cache-Control ставить не стоит. В первой версии стенда он был, и Nginx пометил «кэшировать на год» даже ответ 404 на несуществующий файл. Без always заголовок добавляется только к успешным ответам и редиректам, а у заголовков безопасности always нужен как раз затем, чтобы они были и на ошибках.
Gzip включается в контексте http, а файлы из conf.d попадают именно туда. text/html сжимается всегда, остальные типы перечисляются в gzip_types. gzip_min_length 1024 не тратит процессор на мелкие ответы, gzip_vary on добавляет Vary: Accept-Encoding, чтобы промежуточный кэш не отдал сжатую версию клиенту без поддержки gzip. Тестовый файл при передаче уменьшился с 9801 до 1108 байт — степень сжатия зависит от содержимого.
HTTPS: сертификат Let’s Encrypt, certbot и редирект
Блок server на порту 443 включает TLS параметром ssl у listen и путями к сертификату и ключу. Протоколы и шифры можно не указывать: по документации Nginx по умолчанию использует TLSv1.2 TLSv1.3 и HIGH:!aNULL:!MD5. HTTP/2 включается директивой http2 on; (с версии 1.25.1); параметр http2 у listen устарел.
DNS и порт 80
Домен указывает на сервер, порт 80 открыт. Let’s Encrypt проверяет владение доменом HTTP-запросом к
/.well-known/acme-challenge/, поэтому этот путь в конфиге выведен из редиректа.Установка certbot
Инструкция certbot для Nginx рекомендует snap-пакет; если certbot уже стоит из apt или dnf, его сначала удаляют. Команды для своей ОС берите на certbot.eff.org.
Выпуск
certbot --nginxвыпускает сертификат и сам правит конфиг;certbot certonly --nginxтолько выпускает — пути вы прописываете вручную.Пути к файлам
Certbot кладёт сертификаты в
/etc/letsencrypt/live/<домен>/:fullchain.pem— вssl_certificate,privkey.pem— вssl_certificate_key.Продление
Пакеты certbot ставят cron или systemd timer, проверка —
certbot renew --dry-run. С Certbot 4.0 сертификат продлевается, когда осталось меньше трети срока.
sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/local/bin/certbot
sudo certbot certonly --nginx -d example.com -d www.example.com
sudo certbot renew --dry-runРедирект — отдельный server на порту 80 с return 301 https://$host$request_uri;: путь и параметры сохраняются. Проверка показала Location: https://example.com/api/whoami, а путь проверки Let’s Encrypt ответил 404 из папки вместо редиректа — так и должно быть, пока certbot не положил туда файл-токен.
Заголовки безопасности
# always: also add the headers to error responses (4xx/5xx)
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), camera=(), microphone=()" always;server и в каждом location со своими add_headerStrict-Transport-Security
- Значение
max-age=31536000- Что даёт
- Браузер год ходит на домен только по HTTPS, даже если ввести
http://
X-Content-Type-Options
- Значение
nosniff- Что даёт
- Браузер не угадывает тип файла вопреки
Content-Type
X-Frame-Options
- Значение
DENY- Что даёт
- Страницу нельзя встроить во фрейм — защита от кликджекинга; современная альтернатива —
frame-ancestorsв CSP
Referrer-Policy
- Значение
strict-origin-when-cross-origin- Что даёт
- На чужие сайты уходит только origin, без пути и параметров
Permissions-Policy
- Значение
geolocation=(), camera=(), microphone=()- Что даёт
- Отключает возможности браузера, которые сайту не нужны
server_tokens off убирает версию Nginx из заголовка Server и страниц ошибок — в проверке виден Server: nginx без номера. Content-Security-Policy здесь не задан намеренно: политика зависит от скриптов и стилей конкретного приложения, и её обычно формирует само приложение. Заголовок X-XSS-Protection OWASP советует не ставить или выключать значением 0.
Проверка: nginx -t, curl и логи
Запускаем стенд и проверяем по шагам. Флаг --resolve направляет имя example.com на локальный адрес, чтобы сработал server_name, а -k разрешает самоподписанный сертификат. Ниже — реальный вывод прогона; вывод docker compose up опущен.
$ docker compose up -d
$ docker compose exec proxy nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
$ R="--resolve example.com:8080:127.0.0.1 --resolve example.com:8443:127.0.0.1"
$ curl -sI $R http://example.com:8080/api/whoami | grep -iE '^(HTTP|Server|Location)'
HTTP/1.1 301 Moved Permanently
Server: nginx
Location: https://example.com/api/whoami
$ curl -sk -D - $R https://example.com:8443/api/whoami
HTTP/1.1 200 OK
Server: nginx
Date: Mon, 12 Oct 2026 01:40:15 GMT
Content-Type: application/json; charset=utf-8
Transfer-Encoding: chunked
Connection: keep-alive
Vary: Accept-Encoding
Strict-Transport-Security: max-age=31536000
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), camera=(), microphone=()
{"version":"v1","host":"example.com","realIp":"192.168.32.1","forwardedFor":"192.168.32.1","forwardedProto":"https","peer":"::ffff:192.168.32.3"}
$ curl -sk $R -H "X-Forwarded-For: 203.0.113.7" https://example.com:8443/api/whoami
{"version":"v1","host":"example.com","realIp":"192.168.32.1","forwardedFor":"203.0.113.7, 192.168.32.1","forwardedProto":"https","peer":"::ffff:192.168.32.3"}Host, IP и схему, заголовки безопасности на месте$ curl -sk -o /dev/null -D - -H "Accept-Encoding: gzip" $R https://example.com:8443/assets/app.3f9c2b.js | grep -iE '^(HTTP|Content-Type|Content-Encoding|Cache-Control|Vary|Strict|X-Frame)'
HTTP/1.1 200 OK
Content-Type: application/javascript
Vary: Accept-Encoding
Strict-Transport-Security: max-age=31536000
X-Frame-Options: DENY
Cache-Control: public, max-age=31536000, immutable
Content-Encoding: gzip
$ curl -sk -o /dev/null -w "plain %{size_download} bytes\n" $R https://example.com:8443/assets/app.3f9c2b.js
plain 9801 bytes
$ curl -sk -o /dev/null -w "gzip %{size_download} bytes\n" -H "Accept-Encoding: gzip" $R https://example.com:8443/assets/app.3f9c2b.js
gzip 1108 bytes
$ curl -sk -o /dev/null -D - $R https://example.com:8443/assets/missing.js | grep -iE '^(HTTP|Cache-Control|Strict)'
HTTP/1.1 404 Not Found
Strict-Transport-Security: max-age=31536000$ head -c 6291456 /dev/zero > big.bin
$ curl -sk -o /dev/null -w "HTTP %{http_code}\n" $R --data-binary @big.bin https://example.com:8443/api/upload
HTTP 413
$ curl -sk -o /dev/null -w "HTTP %{http_code} after %{time_total}s\n" $R "https://example.com:8443/api/slow?ms=35000"
HTTP 504 after 30.038574s
$ docker compose exec proxy curl -sk --resolve example.com:443:127.0.0.1 -o /dev/null -w "HTTP/%{http_version} %{http_code}\n" https://example.com/api/whoami
HTTP/2 200
$ docker compose logs proxy 2>&1 | grep -E '\[error\]' | tail -2
proxy-1 | 2026/10/12 01:40:15 [error] 25#25: *11 client intended to send too large body: 6291456 bytes, client: 192.168.32.1, server: example.com, request: "POST /api/upload HTTP/1.1", host: "example.com:8443"
proxy-1 | 2026/10/12 01:40:45 [error] 28#28: *12 upstream timed out (110: Operation timed out) while reading response header from upstream, client: 192.168.32.1, server: example.com, request: "GET /api/slow?ms=35000 HTTP/1.1", upstream: "http://192.168.32.2:3000/api/slow?ms=35000", host: "example.com:8443"proxy_read_timeout — 504 ровно через 30 секунд; причины видны в логе ошибокЛюбую правку сначала проверяйте nginx -t и только потом перезагружайте. Перезагрузка мягкая: мастер-процесс перечитывает конфиг, при ошибке остаётся на старом, при успехе запускает новые рабочие процессы, а старые дообслуживают текущие соединения. В официальном образе логи идут в stdout и stderr контейнера, поэтому их читают через docker compose logs; на сервере без Docker они по умолчанию лежат в /var/log/nginx/.
$ docker compose exec proxy nginx -t
2026/10/12 01:40:47 [emerg] 60#60: unexpected "}" in /etc/nginx/conf.d/app.conf:59
nginx: [emerg] unexpected "}" in /etc/nginx/conf.d/app.conf:59
nginx: configuration file /etc/nginx/nginx.conf test failed
$ docker compose exec proxy nginx -t && docker compose exec proxy nginx -s reload
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
2026/10/12 01:40:48 [notice] 70#70: signal process startednginx -t называет файл и строку; после исправления — проверка и мягкая перезагрузка502 Bad Gateway— приложение не запущено или слушает не тот порт; в контейнере оно должно слушать все интерфейсы, а не только127.0.0.1504 Gateway Time-out— ответ дольшеproxy_read_timeout; долгие операции лучше уносить в фоновые задачи, чем поднимать таймаут всем413 Request Entity Too Large— тело большеclient_max_body_size, по умолчанию 1 МБ- Бесконечный редирект — приложение само перенаправляет на HTTPS, потому что не получает
X-Forwarded-Proto - Заголовки безопасности есть не у всех ответов — сработало наследование
add_headerили забытalways - Приложение видит IP из внутренней сети вместо клиента — не переданы
X-Real-IPиX-Forwarded-For
Как проверено5 · пройдено1 · не запускалосьсверено 11 октября 2026 г.
- запущено, работает
compose.yaml + app/server.mjs + nginx/conf.d/app.conf + nginx/snippets/security-headers.conf
docker compose (проект kb-nginx), nginx:stable-alpine = nginx 1.30.5, node:24-alpine = v24.20.0; nginx -t successful; curl с хоста: 301 на https с сохранением пути, путь /.well-known/acme-challenge/ не редиректится (404), /api/whoami получает Host, X-Real-IP, X-Forwarded-For, X-Forwarded-Proto=https; все 5 заголовков безопасности; статика: gzip 9801→1108 байт, Cache-Control immutable, Vary; 404 статики без Cache-Control; 6 МБ → 413; ответ 35 с → 504 через 30 с; HTTP/2 200 (curl внутри контейнера); записи в error log; стенд удалён (docker compose down)
- запущено, работает
openssl req (самоподписанный сертификат)
OpenSSL 3.5.7, сертификат с SAN example.com и www.example.com, принят nginx
- запущено, работает
Выводы shellsession (4 блока)
реальный вывод scratchpad/kb/verify/nginx-reverse-proxy/verify.sh (results.txt); вывод docker compose up опущен
- запущено, работает
Ловушка наследования add_header и add_header_inherit merge
без include в location /assets/ заголовки безопасности пропали (только Cache-Control); с add_header_inherit merge на уровне server в 1.30.5 — вернулись
- запущено, работает
Решение упражнения (location = /api/upload)
nginx -t successful; 6 МБ на /api/upload — 200 (receivedBytes 6291456), на /api/other — 413, 24 МБ на /api/upload — 413
- не запускалось
Команды certbot (snap install, certonly --nginx, renew --dry-run)
сверено с инструкцией certbot.eff.org (Nginx, snap) и User Guide; выпуск требует публичного домена и порта 80 — на локальном стенде не запускалось, в статье это указано в подписи
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.