Как делать11 октября 2026 г.8 мин чтения
Docker и деплойШаг 6 из 8

Nginx как reverse proxy: HTTPS, статика, заголовки

Коротко

Reverse proxy — сервер, который принимает запросы клиентов вместо приложения: Nginx слушает порты 80 и 443, завершает HTTPS, сам отдаёт статику и передаёт остальное в Node.js через proxy_pass, добавляя заголовки Host и X-Forwarded-*. Ниже — рабочий конфиг для Nginx 1.30 с редиректом на HTTPS, кэшем статики, gzip, лимитом тела, таймаутами и заголовками безопасности, проверенный nginx -t и curl в Docker.

РуководствоУровень: СреднийNginxПрактика: ~1–2 ч

После материала вы сможете

  • Настроить 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 г.

Содержание
  1. 01Что делает reverse proxy
  2. 02Стенд: Nginx и Node.js в одной Docker-сети
  3. 03Конфиг: server, location и proxy_pass
  4. 04Статика, кэш-заголовки и gzip
  5. 05HTTPS: сертификат Let’s Encrypt, certbot и редирект
  6. 06Заголовки безопасности
  7. 07Проверка: nginx -t, curl и логи

Что делает reverse proxy

Node.js-приложение умеет отвечать на HTTP само, но выставлять его порт прямо в интернет неудобно и рискованно. Перед ним ставят reverse proxy (обратный прокси) — сервер, который принимает соединения клиентов от имени приложения и пересылает запросы дальше. Клиент видит только Nginx; адрес и порт приложения остаются внутри сервера или Docker-сети.

  1. 01Браузер
  2. 02Nginx :443
  3. 03Node.js :3000
Клиент говорит только с Nginx; приложение доступно лишь из внутренней сети.
  • 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, а наружу открыты только порты прокси.

compose.yaml
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:ro
Два сервиса: приложение без опубликованных портов и Nginx с портами 8080 и 8443 на машине разработчика
app/server.mjs
import { 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.

bash
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.pem
Самоподписанный сертификат на 30 дней для example.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 по пути запроса.

nginx/conf.d/app.conf
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;
    }
}
Полный конфиг стенда: редирект с HTTP, HTTPS-сервер, статика и проксирование в Node.js

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 устарел.

  1. DNS и порт 80

    Домен указывает на сервер, порт 80 открыт. Let’s Encrypt проверяет владение доменом HTTP-запросом к /.well-known/acme-challenge/, поэтому этот путь в конфиге выведен из редиректа.

  2. Установка certbot

    Инструкция certbot для Nginx рекомендует snap-пакет; если certbot уже стоит из apt или dnf, его сначала удаляют. Команды для своей ОС берите на certbot.eff.org.

  3. Выпуск

    certbot --nginx выпускает сертификат и сам правит конфиг; certbot certonly --nginx только выпускает — пути вы прописываете вручную.

  4. Пути к файлам

    Certbot кладёт сертификаты в /etc/letsencrypt/live/<домен>/: fullchain.pem — в ssl_certificate, privkey.pem — в ssl_certificate_key.

  5. Продление

    Пакеты certbot ставят cron или systemd timer, проверка — certbot renew --dry-run. С Certbot 4.0 сертификат продлевается, когда осталось меньше трети срока.

bash
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
Команды из инструкции certbot (Nginx, snap) для сервера с публичным доменом. На локальном стенде мы их не запускали: Let’s Encrypt проверяет реальный домен

Редирект — отдельный server на порту 80 с return 301 https://$host$request_uri;: путь и параметры сохраняются. Проверка показала Location: https://example.com/api/whoami, а путь проверки Let’s Encrypt ответил 404 из папки вместо редиректа — так и должно быть, пока certbot не положил туда файл-токен.

Заголовки безопасности

nginx/snippets/security-headers.conf
# 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_header

Strict-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 опущен.

terminal
$ 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"}
Конфиг валиден, HTTP уходит на HTTPS, приложение видит исходные Host, IP и схему, заголовки безопасности на месте
terminal
$ 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
Статику отдаёт Nginx: gzip, кэш на год и заголовки безопасности; на 404 кэш-заголовка нет
terminal
$ 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"
Лимиты работают: 6 МБ при лимите 5 МБ — 413, ответ дольше proxy_read_timeout — 504 ровно через 30 секунд; причины видны в логе ошибок

Любую правку сначала проверяйте nginx -t и только потом перезагружайте. Перезагрузка мягкая: мастер-процесс перечитывает конфиг, при ошибке остаётся на старом, при успехе запускает новые рабочие процессы, а старые дообслуживают текущие соединения. В официальном образе логи идут в stdout и stderr контейнера, поэтому их читают через docker compose logs; на сервере без Docker они по умолчанию лежат в /var/log/nginx/.

terminal
$ 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 started
Пропущенная точка с запятой: nginx -t называет файл и строку; после исправления — проверка и мягкая перезагрузка
  • 502 Bad Gateway — приложение не запущено или слушает не тот порт; в контейнере оно должно слушать все интерфейсы, а не только 127.0.0.1
  • 504 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.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.