Перейти к содержимому
Поиск
Разработка

Nginx: reverse proxy, кеш и диагностика — мастерская и рабочие примеры

Настраиваем вход в приложение, разбираем маршруты и кеш, находим причины ошибок. Собираем конфигурацию в мастерской и проверяем её на настоящем Nginx из готового проекта.

Александр Некрасов
Nginx — сервер под контролем: маршруты, кеш и диагностика

Nginx помогает направить запрос в нужное приложение, уменьшить повторную нагрузку и понять причину сбоя. Но полезный результат появляется не от количества директив. Нужно видеть, какой URL получил backend, почему ответ взят из кеша, где возникла задержка и что изменилось после reload. Именно такие повседневные задачи разберём на работающем проекте.

В статье есть конструктор nginx.conf, восемь прикладных ситуаций, настоящая локальная лаборатория и путь публикации на Linux VPS или выделенном сервере. Будут API, MISS/HIT/BYPASS, исключение личных ответов из кеша, gzip, загрузки файлов, заголовки, лимиты, SSE и диагностика ошибок. Команды для Windows и Linux приведены отдельно, а серверный вариант доведён до домена, сертификата, автозапуска и обновления.

Два уровня практики. На странице работает генератор конфигурации и объяснение URI. Он не запускает Nginx и не рисует якобы измеренный HIT. В архиве — настоящий Nginx с Node.js: кнопки вызывают HTTP, читают заголовки и показывают реальные статусы. Публичный HTTPS требует вашего домена и сервера; внешний выпуск сертификата при подготовке статьи не выполнялся.

Где Nginx полезен и какой результат можно получить

Типичный проект постепенно обрастает компонентами: интерфейс, API, PHP-приложение, файлы, обновления состояния. Nginx можно поставить перед ними как внешний HTTP-вход. Пользователь работает с одним понятным доменом, а внутренние сервисы получают согласованные маршруты. При этом прокси не становится автором бизнес-логики: права, заказы и проверка данных остаются в приложении.

Задача Настройка Практический результат
Опубликовать API reverse proxy и заголовки Приложение доступно по домену, внутренний порт скрыт
Уменьшить повторные вычисления Кеш только публичного ответа Повторный запрос может не дойти до backend
Показывать прогресс Отдельный поток SSE Обновления приходят без перезагрузки
Обслужить frontend Статика и ограниченный SPA fallback Вложенная страница открывается после обновления
Найти причину сбоя Request ID, access log и error log Ошибка связывается с конкретным участком запроса
Защитить тяжёлую операцию от всплеска Лимит частоты и размера Избыточный запрос получает понятный отказ

Если вы читали мастерскую Caddy, общая роль входного сервера уже знакома. Здесь акцент на более детальном управлении кешем, маршрутом и эксплуатацией. Выбор инструмента имеет смысл привязывать к команде и окружению: уже работающий сервер не нужно менять лишь ради нового названия.

Официальные источники: Начало работы, Руководство по reverse proxy.

Мини-мастерская: маршрут, кеш и готовый nginx.conf

Задайте префикс API, выберите сохранение или удаление пути, измените срок кеша и параметры ограничителя. Схема покажет совпавшее правило и URI у обработчика. Можно отключить gzip или журнал, скачать полный конфиг и применить его в проекте. Под конструктором находятся восемь объяснений: где полезен рецепт, что нажать и какой результат искать.

Начните с /api/echo?x=1, затем включите удаление префикса. Отдельно попробуйте /api без слеша: у этого префиксного proxy-location Nginx делает перенаправление к /api/. Конструктор намеренно ограничивает сложные закодированные пути, чтобы наглядная схема не выдавала себя за полный разборщик Nginx.

Открыть мастерскую на весь экран ↗Скачать Nginx, Node.js и исходники ZIP ↓

Копирование файла не применяет его к серверу. Сохраните результат в config/nginx.conf, выполните nginx -t, затем отдельной командой reload. После изменения пути API приложение должно поддерживать новый URI. Проверка полей, проверка синтаксиса и настоящий HTTP-ответ подтверждают разные вещи.

Лаборатория на Linux и Windows: первый запуск

Распакуйте архив и перейдите в каталог nginx-project. Нужны Docker Engine с Linux-контейнерами и Compose plugin; на Windows можно использовать Docker Desktop с WSL2. Node.js и Nginx отдельно устанавливать не требуется. При первом старте загрузятся образы. Если устройство сервисов, сетей и томов пока непривычно, поможет руководство по Docker Compose.

Linux: команды из корня проекта

cp .env.example .env
docker compose config --quiet
docker compose up -d --build --wait
docker compose ps
curl -i http://127.0.0.1:8089/api/health

Windows PowerShell: Linux containers в Docker Desktop

Copy-Item .env.example .env
docker compose config --quiet
docker compose up -d --build --wait
docker compose ps
curl.exe -i http://127.0.0.1:8089/api/health
Start-Process http://127.0.0.1:8089

Откройте http://127.0.0.1:8089. В браузере появятся каталог, сравнение маршрутов, ограничитель и поток событий. Наружу опубликован только loopback-порт Nginx; Node.js доступен внутри сети Compose. Если порт занят, измените APP_PORT в .env и используйте новый адрес. Внутри контейнера Nginx по-прежнему слушает 80.

В проекте закреплены Nginx 1.30.6 stable и Node.js 24.22.2. Версия stable проверена по официальной странице 7 ноября 2026 года, образ получен и запущен. Это позволяет воспроизвести конкретные примеры, включая новые правила наследования заголовков. При будущем обновлении сначала повторите проверки, затем меняйте рабочий стенд.

Официальные источники: Версии Nginx, Сеть Compose, HTTP-сервер Node.js.

Как читать конфигурацию: http, server и location

Nginx выбирает виртуальный сервер по адресу прослушивания и имени, затем обработчик пути. Блок http содержит общие настройки, зоны кеша, map и upstream. Блок server описывает сайт. В location находится поведение конкретной ветки. Поэтому фрагмент с proxy_cache_path нельзя просто вставить в location: у каждой директивы есть допустимый контекст.

В архиве используется полный nginx.conf, смонтированный вместо основного файла контейнера. На обычном Linux-сервере часто встречаются include и каталоги conf.d или sites-enabled. Смысл определяется тем, куда файл подключён. Перед переносом рецепта посмотрите действующую структуру через nginx -T и не добавляйте второй http внутрь уже открытого http.

Небольшой самостоятельный nginx.conf

events { worker_connections 1024; }
http {
    server {
        listen 80;
        server_name demo.example.com;
        location / { return 200 "Hello from Nginx\n"; }
    }
}

Точное совпадение location = /health и префикс location /api/ решают разные задачи. Регулярные выражения добавляют ещё один этап выбора и могут неожиданно перехватить запрос. В учебном конфиге маршруты намеренно разделены, а сложные rewrite-цепочки не требуются. Так легче проверить каждую ветку отдельно и объяснить чужому разработчику её назначение.

Официальные источники: Выбор server и location, Основные HTTP-директивы.

Reverse proxy: один домен и внутренний Node.js

Для Node.js Nginx является HTTP-клиентом: принимает внешний запрос, открывает соединение к backend и возвращает его ответ. Браузер продолжает видеть прежний домен. Это отличается от return 301, при котором клиент получает новый адрес и сам выполняет следующий запрос. Прокси удобен, когда интерфейс и API должны работать в рамках одного origin.

Рецепт для Node.js на том же Linux-хосте

# Внутри server. Backend должен быть запущен отдельно.
location /api/ {
    proxy_pass http://127.0.0.1:3000;
    proxy_set_header Host $host;
    proxy_set_header X-Request-ID $request_id;
    proxy_set_header X-Forwarded-Proto $scheme;
}

В Compose вместо хостового loopback используется имя сервиса. Адрес 127.0.0.1 внутри контейнера указывал бы на сам контейнер Nginx. В проекте именованный upstream обращается к node:3000 и использует Docker DNS. Значение resolver 127.0.0.11 относится именно к этому окружению; переносить его в native-конфигурацию обычного сервера не нужно.

Официальные источники: Руководство по reverse proxy, Upstream и его переменные.

Слеш в proxy_pass: сохраняем или удаляем префикс

Самая заметная разница проявляется в URI-части proxy_pass. В простом префиксном location адрес без завершающего пути сохраняет исходный URI. Если после upstream указан /, совпавший префикс заменяется этим /. Для внешнего /strip/echo backend получит /echo. Query string в наших примерах сохраняется.

Две разные договорённости с приложением; контекст server

location /api/ {
    proxy_pass http://127.0.0.1:3000;
}
location /strip/ {
    proxy_pass http://127.0.0.1:3000/;
}

Сравните url в JSON и Location в перенаправлении

curl 'http://127.0.0.1:8089/api/echo?x=1'
curl 'http://127.0.0.1:8089/strip/echo?x=1'
curl -I 'http://127.0.0.1:8089/api?x=1'

У префиксного location со слешем и proxy_pass есть автоматическое перенаправление с точного адреса без слеша. В Docker внешний 8089 отличается от внутреннего 80, поэтому в стенде включён absolute_redirect off: Location остаётся относительным. Изменение запроса не переписывает автоматически ссылки, cookies и redirects в ответе приложения. Если сервис не умеет base path, отдельный поддомен может быть проще подпапки.

Официальные источники: proxy_pass, кеш и тайм-ауты, Основные HTTP-директивы.

Кеш публичного ответа: что означают MISS, HIT и EXPIRED

Представьте каталог услуг, который одинаков для всех посетителей и меняется редко. Получать его от приложения на каждое открытие страницы может быть лишней работой. Короткий кеш на прокси позволяет повторно отдать готовый ответ. Но допустимую свежесть определяет продукт: цены в общем каталоге и остаток личного счёта требуют разных решений.

В лаборатории нажмите «Новая серия», затем дважды запросите каталог. Новый URL даёт MISS: Nginx обращается к Node.js. Повторный запрос в пределах 10 секунд получает HIT и ту же backendVersion. После истечения срока следующий запрос обновляет запись, обычно со статусом EXPIRED. Счётчик принадлежит backend, а не анимации страницы, поэтому результат легко проверить через curl.

Минимальный пример для общего публичного каталога; личные запросы исключим следующим шагом

# Контекст http:
proxy_cache_path /var/cache/nginx/demo levels=1:2
    keys_zone=demo:10m max_size=20m inactive=1m;

server {
    listen 80;
    location /catalog/ {
        proxy_pass http://127.0.0.1:3000/;
        proxy_cache demo;
        proxy_cache_key "$scheme|$proxy_host|$request_uri";
        proxy_cache_valid 200 10s;
        proxy_cache_lock on;
        add_header X-Cache $upstream_cache_status always;
    }
}

Ключ определяет, какие запросы считаются одинаковыми. В примере учитывается URI с query string. Заголовки upstream, например Cache-Control и Set-Cookie, также влияют на кеширование. Не добавляйте proxy_ignore_headers только ради появления HIT: можно случайно отменить смысл private или no-store. Кеш должен ускорять разрешённый ответ, сохраняя его контракт.

Официальные источники: Практика кеширования, proxy_pass, кеш и тайм-ауты.

BYPASS и no_cache: личные ответы требуют отдельных правил

Публичный кеш становится опасной ошибкой, если в него попадает персональная информация. Наш консервативный рецепт исключает любой Authorization, любую Cookie и методы кроме GET/HEAD. Это удобно для обучения: человек сразу видит границу. В реальном проекте можно точнее различать служебную cookie аналитики и сессию, но такое решение требует знания приложения.

Условия исключения; строки location добавляются рядом с proxy_cache

# Контекст http:
map $http_authorization $has_auth { default 1; "" 0; }
map $http_cookie $has_cookie { default 1; "" 0; }
map $request_method $skip_method { default 1; GET 0; HEAD 0; }
# В кешируемом location:
# proxy_cache_bypass $has_auth $has_cookie $skip_method;
# proxy_no_cache $has_auth $has_cookie $skip_method $upstream_http_set_cookie;

proxy_cache_bypass запрещает читать сохранённый ответ, а proxy_no_cache запрещает записывать новый. Обычно нужны оба действия. В лаборатории обычный запрос после учебной авторизации снова получает прежний публичный HIT: личный ответ его не перезаписал. Сам backend дополнительно помечает персональный ответ private, no-store. Эти уровни согласуются между собой.

Обычный браузерный опыт использует credentials:omit. Это важно для loopback: cookie от другого проекта на 127.0.0.1 может действовать и на соседнем порту. Кнопка cookie намеренно отправляет учебную cookie. POST не участвует в разрешённых методах кеша; его X-Cache может отсутствовать, а не обязательно показывать BYPASS.

Официальные источники: Условия через map, Практика кеширования.

Статика: долгий кеш только у файлов с версией в имени

Кеш браузера и proxy_cache решают разные задачи. CSS можно загрузить один раз и долго использовать на устройстве посетителя. Но если под прежним app.css появляется другое содержимое, очень долгий срок мешает доставить обновление. Поэтому immutable удобно применять к файлу с fingerprint в имени: новая сборка создаёт новый URL.

Годовой кеш только для имён с хешем

# Контекст server; для проверенной версии Nginx 1.30.6.
location ~ "^/assets/[a-zA-Z0-9_-]+[.][a-f0-9]{8,}[.](css|js)$" {
    add_header_inherit merge;
    add_header Cache-Control "public, max-age=31536000, immutable";
    try_files $uri =404;
}

Проверьте Cache-Control и сохранённый X-Request-ID

curl -I http://127.0.0.1:8089/assets/app.28c8e0ac.css

В архиве fingerprint соответствует содержимому CSS. HTML-оболочке такой режим обычно не нужен: именно она должна быстро сослаться на новую сборку. Серверный исходник Node.js вообще находится вне webroot. На сайте уже есть короткий пример ограничения доступа к серверному JS; здесь задача решена самой структурой каталогов. JavaScript, который должен исполняться браузером, неизбежно доступен этому браузеру.

Официальные источники: Заголовки и наследование, Начало работы.

Gzip: уменьшаем текстовый ответ и проверяем заголовок

Для HTML, CSS, JSON и JavaScript сжатие часто уменьшает передачу. Уже сжатые изображения и архивы обычно не требуют повторной обработки. Настройте подходящие MIME-типы и минимальную длину, затем проверьте достаточно большой ответ с Accept-Encoding. Маленький JSON без Content-Encoding сам по себе не доказывает поломку gzip.

Сжатие текстовых ресурсов

# Контекст http или server:
gzip on;
gzip_vary on;
gzip_min_length 512;
gzip_types text/plain text/css application/json application/javascript;

Ищите Content-Encoding: gzip и Vary: Accept-Encoding

curl -I -H "Accept-Encoding: gzip" http://127.0.0.1:8089/assets/example.txt

Оценивать пользу лучше на реальном типе ответа и трафике. Увеличение уровня сжатия не бесплатно для CPU. Для чувствительных динамических ответов решение учитывает состав данных и модель приложения; не стоит включать все типы подряд. В отдельном SSE-маршруте стенда gzip выключен, чтобы опыт с маленькими событиями оставался наглядным.

Официальные источники: Модуль gzip, Сжатие ответов.

Заголовки: always, наследование и версия Nginx

Диагностический заголовок нужен и у ошибки: иначе именно неудачный запрос трудно связать с журналом. Поэтому X-Request-ID добавлен с always. Другой тонкий момент — наследование add_header. В привычном поведении собственный add_header во вложенном location может отменить набор из родительского уровня, хотя разработчик хотел лишь добавить одну строку.

В Nginx 1.29.3 появилась директива add_header_inherit, а проверенная stable 1.30.6 её поддерживает. В рецепте статики используется merge, поэтому Cache-Control добавляется к родительским заголовкам. На старой версии этот пример не пройдёт nginx -t: там нужно явно повторить согласованный набор или организовать include. Проверка версии здесь полезнее слепого копирования.

Два заголовка для обычных ответов и ошибок

# Контекст server:
add_header X-Request-ID $request_id always;
add_header X-Content-Type-Options nosniff always;

Официальные источники: Заголовки и наследование.

Файлы и 413: согласуем размер на всех уровнях

Форма загрузки может принять файл в браузере, но Nginx отвергнет тело ещё до приложения. client_max_body_size задаёт предел HTTP-тела. Для multipart к размеру самого файла добавляются служебные данные формы, поэтому лимиты нельзя сравнивать как абсолютно одинаковые числа. Сообщение 413 должно помогать человеку выбрать допустимый файл, а не выглядеть как неизвестный сбой.

Пример лимита тела; в лаборатории специально установлено 32k

# Контекст server. Пример для приложения, принимающего файлы.
client_max_body_size 20m;
client_body_timeout 30s;

Согласуйте этот предел с PHP post_max_size и upload_max_filesize либо лимитом парсера Node.js. Значения выбираются под реальные задачи, доступное место и время обработки. Лаборатория использует маленький предел 32 КБ для воспроизводимой проверки: scripts/smoke.py отправляет 40 КБ и получает настоящий 413. Это проверка входного слоя, а не готовый сервис хранения файлов.

Официальные источники: Основные HTTP-директивы, PHP-FPM.

502 и 504: что именно не дождалось ответа

Прокси может не установить соединение, не передать запрос или не дождаться следующей порции ответа. Эти этапы имеют разные настройки. proxy_connect_timeout касается соединения, proxy_send_timeout — промежутков при отправке запроса upstream, proxy_read_timeout — промежутков чтения ответа. Последние два не являются универсальным таймером всей бизнес-операции.

Пример раздельных ограничений ожидания

# Контекст location или server:
proxy_connect_timeout 2s;
proxy_send_timeout 10s;
proxy_read_timeout 10s;

В стенде /slow?wait=1600 задерживает начало ответа на 1,6 секунды, а специальный location ждёт только секунду: получается 504. /unavailable направлен к заведомо отсутствующему процессу и даёт 502. Так можно увидеть реальные ошибки без остановки соседних проектов. В публичном профиле обе диагностические ветки отключены.

Сопоставьте ошибку с request ID и журналом

curl -i 'http://127.0.0.1:8089/slow?wait=1600'
curl -i http://127.0.0.1:8089/unavailable
docker compose logs --tail=30 nginx

Если формирование отчёта действительно занимает минуты, повышение всех тайм-аутов часто лишь удерживает соединения дольше. Фоновая задача с отдельным статусом может быть удобнее для человека; такую схему разбирали в мастерской BullMQ. Для обычного API сначала измерьте, где уходит время, и проверьте приложение.

Официальные источники: proxy_pass, кеш и тайм-ауты, Upstream и его переменные.

Ограничение частоты: rate, burst, nodelay и 429

У тяжёлого поиска или публичного API полезно ограничивать всплески обращений. В примере ключом служит адрес клиента, средняя скорость — два запроса в секунду, а burst позволяет небольшой краткий всплеск. nodelay пропускает допустимый burst сразу, но не отменяет учёт лимита. Избыточные обращения получают выбранный статус 429.

Ограничитель одной ветки API

# Контекст http:
limit_req_zone $binary_remote_addr zone=per_ip:10m rate=2r/s;
server {
    listen 80;
    location /limited/ {
        limit_req zone=per_ip burst=2 nodelay;
        limit_req_status 429;
        proxy_pass http://127.0.0.1:3000/;
    }
}

В лаборатории кнопка отправляет восемь коротких запросов и показывает каждый статус. Точное число отказов зависит от времени и прошлых обращений, поэтому интерфейс не обещает фиксированную цифру. За общим NAT несколько пользователей могут иметь один адрес. Аккаунтные квоты, ограничение бизнес-действий и защита всей инфраструктуры требуют дополнительных решений; IP-лимит не заменяет их.

Официальные источники: Ограничение частоты.

Реальный IP за балансировщиком: кому можно доверять

Если перед Nginx находится CDN или другой proxy, непосредственный адрес соединения может принадлежать ему. Тогда журнал и rate limit будут видеть общий узел. Модуль realip позволяет восстановить адрес из согласованного заголовка, но только от известного доверенного отправителя. Иначе клиент сможет сам прислать X-Forwarded-For и выбрать себе адрес.

Схема доверия одному proxy; 192.0.2.10 — документационный адрес

# Контекст http или server. Замените адрес своим доверенным proxy.
set_real_ip_from 192.0.2.10;
real_ip_header X-Forwarded-For;
real_ip_recursive on;

Не подставляйте доверие ко всему интернету. Для CDN используйте актуальные диапазоны из его официальной документации и ограничьте обход фронтового узла. Проверьте поддержку модуля через nginx -V и сравните $remote_addr с $realip_remote_addr. В нашем прямом loopback-стенде дополнительного доверенного proxy нет: он задаёт upstream X-Forwarded-For из фактического remote_addr.

Как увидеть восстановленный адрес и непосредственного отправителя

# Контекст http; временный формат для проверки своей цепочки proxy.
log_format addresses 'client=$remote_addr peer=$realip_remote_addr';
access_log /dev/stdout addresses;

Прямой недоверенный запрос не должен сам назначать себе адрес клиента

curl -i -H "X-Forwarded-For: 203.0.113.8" http://127.0.0.1:8089/api/echo

Для проверки доверенной цепочки повторите запрос через свой настроенный фронтовой proxy и посмотрите журнал с форматом addresses. В прямом запросе без доверенного посредника подставленный заголовок не должен менять remote_addr. Учебный IP 203.0.113.8 служит только контрольным значением.

Официальные источники: Доверенный адрес клиента.

Request ID и JSON-журнал: находим конкретный запрос

Сообщение «иногда медленно» трудно разобрать без привязки к запросу. X-Request-ID позволяет скопировать идентификатор из ответа и найти строку access log. В ней важны итоговый статус, статус upstream, полное время, время приложения, состояние кеша и ограничителя. При HIT нового обращения к backend нет, поэтому upstream-поля могут содержать дефис.

Компактный JSON access log

# Контекст http:
log_format diagnostic escape=json
  '{"id":"$request_id","uri":"$uri","status":$status,'
  '"time":"$request_time","upstream":"$upstream_response_time",'
  '"cache":"$upstream_cache_status"}';
access_log /dev/stdout diagnostic;

escape=json нужен для корректной записи строковых значений. В учебном формате намеренно отсутствуют Authorization, Cookie и query string: они не нужны для показанного разбора. Не публикуйте полный nginx -T и журналы без проверки содержимого. На Linux наблюдайте также ошибки процесса и рост файлов; в production Compose предусмотрена ограниченная ротация Docker JSON-логов.

Официальные источники: Журнал запросов, Ротация Docker-журналов.

SPA fallback: HTML только там, где его ждёт интерфейс

Маршрут /app/reports/week может существовать только у клиентского роутера. После обновления браузера сервер получает этот адрес впервые и должен вернуть оболочку интерфейса. Но тот же fallback нельзя бездумно применить к API: JSON-клиент получит HTML со статусом 200 и покажет ошибку разбора вместо ясного 404.

Интерфейс и API имеют отдельные ветки

# Контекст server:
location /app/ {
    try_files $uri $uri/ /app/index.html;
}
location /api/ {
    proxy_pass http://127.0.0.1:3000;
}

В архиве это минимальная демонстрация серверного fallback, а не полноценный SPA-фреймворк. Откройте вложенный путь, обновите страницу, затем запросите /api/missing. Первый покажет учебную оболочку, второй сохранит JSON 404. Такой короткий опыт полезнее обещания, что одна строка try_files подходит всем приложениям.

Официальные источники: Выбор server и location, Основные HTTP-директивы.

SSE и WebSocket: два разных вида живого интерфейса

SSE подходит для событий от сервера к браузеру: прогресс, статусы, новые показатели. В рабочем стенде Node.js отправляет пять событий, Nginx передаёт их постепенно, а интерфейс закрывает соединение после завершения. Для этой ветки proxy_buffering выключен. Это можно увидеть и командой curl -N, которая не накапливает вывод клиента.

Поток SSE от существующего HTTP-приложения

# Контекст server:
location = /events {
    proxy_buffering off;
    proxy_read_timeout 10s;
    proxy_pass http://127.0.0.1:3000;
}

WebSocket обеспечивает двусторонний обмен и требует отдельной передачи Upgrade/Connection. Следующий рецепт предназначен для приложения, которое действительно поддерживает WebSocket по /socket/. В этом архиве такого backend нет: проверен SSE, а конфигурация WebSocket приведена как переносимый пример с отдельной проверкой синтаксиса.

WebSocket: необходим совместимый backend

# Контекст http:
map $http_upgrade $connection_upgrade {
    default upgrade;
    "" close;
}
server {
    listen 80;
    location /socket/ {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_read_timeout 60s;
    }
}

В DevTools для WebSocket ищите успешный handshake и обмен сообщениями, а для SSE — последовательные события. В рабочем интерфейсе дополнительно нужны heartbeat и восстановление состояния после reconnect. Само открытое соединение не гарантирует, что пользователь получил все события во время обновления приложения.

Официальные источники: Проксирование WebSocket, Руководство по reverse proxy.

Несколько сайтов, поддомены и канонический адрес

Два server-блока могут слушать один порт и направлять разные имена к разным приложениям. Это часто проще публикации чужого сервиса в подпапке: backend продолжает считать свой корень корнем сайта. DNS каждого имени должен указывать на нужный сервер, а сертификат — покрывать имена, по которым устанавливается HTTPS-соединение.

API и документация на отдельных поддоменах

# Контекст http; схема HTTP для двух имён.
server {
    listen 80;
    server_name app.example.com;
    location / { proxy_pass http://127.0.0.1:3000; }
}
server {
    listen 80;
    server_name docs.example.com;
    root /srv/docs;
    location / { try_files $uri $uri/ =404; }
}

Для www и адреса без www выберите одно каноническое имя и перенаправляйте второе явно. Фиксированный целевой домен лучше выражает намерение, чем слепое доверие входному Host. Если человек приходит на https://www.example.com, сертификат для www нужен до редиректа: TLS устанавливается раньше, чем браузер прочитает HTTP-ответ.

Каноническое имя; HTTPS-вход дополнительно требует подходящего сертификата

# Контекст http. Пример HTTP-редиректа.
server {
    listen 80;
    server_name www.example.com;
    location / { return 301 https://example.com$request_uri; }
}

Проверяйте curl -I и значение Location, включая query string и вложенный путь. На одном сервере полезно отдельно определить поведение неизвестного имени, чтобы случайный Host не открывал чужой сайт. Публичный профиль ниже использует один конкретный домен и двухэтапное включение HTTPS.

Оба варианта должны привести к одному каноническому URL; замените домены своими

# После настройки своих имён и сертификатов:
curl -I 'http://www.example.com/reports?period=week'
curl -I 'https://www.example.com/reports?period=week'

Официальные источники: Имена виртуальных серверов, return и перенаправления, Настройка HTTPS.

PHP-FPM: тот же вход, другой протокол

Nginx сам не исполняет PHP. Он передаёт запрос PHP-FPM через FastCGI, поэтому proxy_pass по HTTP на порт 9000 не заменяет fastcgi_pass. Для приложения с единым index.php важно согласовать путь к скрипту и правило fallback. В контейнерах файл должен существовать по ожидаемому абсолютному пути у PHP-процесса.

Схема PHP front controller; PHP-FPM не входит в Nginx-архив этой публикации

# Контекст server; PHP-FPM и файлы должны быть подготовлены отдельно.
root /srv/php/public;
index index.php;
location / { try_files $uri $uri/ /index.php?$query_string; }
location ~ [.]php$ {
    try_files $uri =404;
    include /etc/nginx/fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    fastcgi_pass 127.0.0.1:9000;
}

Публичным root должна быть папка public, а не весь репозиторий с .env и исходниками. Проверяйте обычный маршрут, существующий CSS, несуществующий PHP-файл и лимиты загрузки. Отдельный работающий PHP-FPM-пример есть в мастерской Caddy; здесь основная лаборатория посвящена HTTP-прокси и кешу, чтобы не смешивать два разных протокола.

Официальные источники: PHP через FastCGI, PHP-FPM.

Проверка и reload: как применить изменение без догадок

Сначала сохраните рабочий конфиг и сформулируйте запрос, который должен измениться. После правки выполните nginx -t. Если проверка не прошла, исправьте файл до reload. Успешный reload запускает новые worker-процессы с новой конфигурацией и завершает старые штатным образом; это не то же самое, что остановить сервер и надеяться на новый старт.

Проверка, отдельное применение и контрольный запрос

docker compose exec nginx nginx -t
docker compose exec nginx nginx -s reload
curl -i http://127.0.0.1:8089/api/health
docker compose logs --tail=30 nginx

В native-установке через systemd применяются sudo nginx -t и sudo systemctl reload nginx. В нашем варианте процессом внутри контейнера управляет Docker, поэтому команды не нужно смешивать. Если после reload ничего не изменилось, проверьте смонтированный путь, выбранный Compose-файл и фактическую конфигурацию, а затем повторите один изменённый и один соседний рабочий маршрут.

Официальные источники: Reload и управление процессами, Параметры командной строки.

Linux VPS или выделенный сервер: от архива до работающего домена

Далее приведён самостоятельный серверный сценарий, а не замена одной команды PowerShell на Bash. Он использует ту же версию Nginx и то же приложение, но отдельный публичный Compose и отдельный webroot. Установка на реальный VPS в этой работе не выполнялась: файлы проверены локально, а DNS, firewall и ACME проверяются на вашем сервере.

Ниже выбран новый Ubuntu 24.04 LTS. Официальные Docker-пакеты поддерживают эту версию. Это отдельный рецепт развёртывания, не команда миграции действующего сервера. На хосте с установленными панелью, Nginx или Docker сначала согласуйте занятые 80/443, существующие сайты и пакеты. Не запускайте две системы управления одним портом.

Понадобятся SSH с sudo, ваш домен и публичный IP. В панели DNS создайте A к IPv4 сервера; AAAA добавляйте только при реально настроенном IPv6. Во внешнем firewall провайдера разрешите TCP 80/443 и сохраните SSH-доступ. Docker published ports имеют отдельные правила и могут обходить UFW: одного ufw deny недостаточно. В production Compose опубликованы только 80/443, backend остаётся внутри сети.

1. Docker Engine и каталог проекта

Скопируйте ZIP на сервер, распакуйте так, чтобы compose.production.yaml оказался в /opt/aen-nginx. Например, архив уже загружен в домашний каталог:

sudo apt-get update
sudo apt-get install -y unzip python3
unzip nginx-project.zip
sudo install -d -m0755 /opt/aen-nginx
sudo cp -a nginx-project/. /opt/aen-nginx/
sudo chown -R root:root /opt/aen-nginx
cd /opt/aen-nginx

Изучите scripts/install-docker-ubuntu.sh: он добавляет официальный apt-репозиторий Docker и устанавливает docker-ce, CLI, containerd и Compose plugin. На новом Ubuntu 24.04:

bash scripts/install-docker-ubuntu.sh
sudo systemctl is-enabled docker
sudo systemctl is-active docker
sudo docker compose version

Скрипт не удаляет существующие конфликтующие пакеты. Если они есть, следуйте официальной инструкции Docker и плану миграции. Docker запускается через systemd; restart:unless-stopped возвращает контейнеры после обычной перезагрузки хоста. Дополнительный конкурирующий systemd-unit для Compose в этом рецепте не нужен.

2. Подготовка имени, конфигураций и HTTP

Подставьте свой домен вместо app.example.com. Файл .env.production содержит только путь конфига, не сертификаты и не пароли.

cd /opt/aen-nginx
DOMAIN=app.example.com
sudo python3 scripts/prepare_linux.py --domain "$DOMAIN"
sudo install -d -m0755 acme/.well-known/acme-challenge
sudo install -d -m0755 /etc/letsencrypt
sudo cp .env.production.example .env.production
sudo chmod 0600 .env.production
sudo docker compose --env-file .env.production -f compose.production.yaml config --quiet
sudo docker compose --env-file .env.production -f compose.production.yaml up -d --build
sudo docker compose --env-file .env.production -f compose.production.yaml exec -T nginx nginx -t

Каталоги сайта читаются Nginx; прав 777 нет. Каталог /etc/letsencrypt целиком смонтирован только для чтения, чтобы ссылки live→archive оставались рабочими. Пока сертификата нет, запущен HTTP bootstrap; файл TLS ещё не используется.

printf 'acme-ready\n' | sudo tee acme/.well-known/acme-challenge/probe >/dev/null
curl "http://$DOMAIN/.well-known/acme-challenge/probe"
curl "http://$DOMAIN/api/health"

Повторите первый URL с другого подключения, а не только на сервере. Если файл не открывается снаружи, выпуск сертификата ещё рано запускать. Публичная стартовая страница лежит в public-production; лаборатория с кнопками намеренных ошибок остаётся в public. Production-профиль отключает /slow и /unavailable. API каталога только возвращает учебные данные, не принимает личную информацию и не выполняет оплату. Для приложения с операциями записи нужно отдельно реализовать вход и права пользователей.

3. Сертификат через Certbot webroot

Установка Certbot через snap соответствует официальному способу для этой ОС. Используется абсолютный путь /snap/bin/certbot, чтобы не спутать его с другим пакетом.

sudo apt-get install -y snapd
sudo snap install --classic certbot
sudo /snap/bin/certbot certonly --webroot \
  -w /opt/aen-nginx/acme -d "$DOMAIN" \
  --email you@example.com --agree-tos
sudo /snap/bin/certbot certificates

Укажите настоящий email владельца. Certbot пишет challenge в общий webroot, а Nginx продолжает обслуживать HTTP. Успех команды создаёт fullchain.pem и privkey.pem под /etc/letsencrypt/live/ваш-домен. Ключи не кладутся в ZIP или публичный каталог.

4. Включение HTTPS

cd /opt/aen-nginx
sudo cp .env.production .env.production.http-backup
printf 'NGINX_CONFIG=./config/nginx.production-tls.conf\n' | sudo tee .env.production >/dev/null
sudo chmod 0600 .env.production
sudo docker compose --env-file .env.production -f compose.production.yaml run --rm --no-deps nginx nginx -t
sudo docker compose --env-file .env.production -f compose.production.yaml up -d nginx
curl -I "http://$DOMAIN/"
curl "https://$DOMAIN/api/health"

После успешного nginx -t контейнер пересоздаётся с TLS-конфигурацией. HTTP перенаправляет на HTTPS, но /.well-known/acme-challenge/ остаётся доступным для продления. Проверка HTTPS выполняется без -k. Откройте домен в браузере, каталог и вложенный /app/reports/week. Публичный ACME и DNS зависят от вашего сервера; в подготовке этой публикации выдача на внешний домен не выполнялась.

5. Автопродление и reload

sudo install -d -m0755 /etc/letsencrypt/renewal-hooks/deploy
sudo install -m0755 scripts/reload-after-renew.sh /etc/letsencrypt/renewal-hooks/deploy/aen-nginx-reload
sudo systemctl list-timers --all | grep certbot
sudo /snap/bin/certbot renew --dry-run

Certbot snap устанавливает задачу продления; убедитесь, что она присутствует. Deploy hook после успешного обновления проверяет конфигурацию и делает nginx -s reload. Dry run проверяет процесс в тестовой ACME-среде; для проверки deploy hook используйте поддерживаемый Certbot флаг –run-deploy-hooks вместе с dry run и наблюдайте журнал. Если каталог проекта другой, исправьте путь в hook. Существующие renew hooks не удаляйте.

6. Журналы, обновление и откат

cd /opt/aen-nginx
sudo docker compose --env-file .env.production -f compose.production.yaml ps
sudo docker compose --env-file .env.production -f compose.production.yaml logs --tail=100 nginx
sudo journalctl -u docker --since '30 minutes ago' --no-pager
sudo journalctl -u snap.certbot.renew.service --since '7 days ago' --no-pager

Docker JSON-логи ограничены 10 МБ × 3 на контейнер. Статус 500 приложения и 502 самого proxy — разные причины. Для обновления сначала сохраните compose.production.yaml и конфигурацию, выберите новый проверенный tag образа, выполните pull и nginx -t через run –rm, затем up -d. Не заменяйте stable-тег бесконтрольно на latest. Проверьте HTTPS, каталог и один неизвестный API после обновления.

Перед новой версией можно сохранить образ: sudo docker image save nginx:1.30.6-alpine -o /root/nginx-before-update.tar. Для отката верните прежние конфиги и image tag, при необходимости docker image load, затем повторите проверку и up -d. Для отката только маршрутов обычно хватает прежнего config и reload. Откат HTTPS к HTTP — отдельное сознательное действие; штатный откат не удаляет рабочие сертификаты. Не удаляйте volumes как средство исправления ошибки конфигурации.

Официальные источники: Docker Engine на Ubuntu, Docker и firewall, Автозапуск контейнеров, Ротация Docker-журналов, Certbot: webroot и продление, Установка Certbot, Настройка HTTPS.

Шпаргалка: 403, 404, 413, 429, 502 и 504

Симптом Где искать Проверка результата
403 Права файлов, deny, индекс каталога Проверить нужный публичный файл и права чтения
404 server/location, путь, файл или маршрут приложения Сравнить ответ статики и JSON API
413 Лимит тела Nginx и приложения Проверить допустимый и превышающий размер
429 Ключ ограничителя, скорость, burst Посмотреть limit в журнале и адрес клиента
502 Соединение, протокол и ответ upstream Проверить процесс, порт и error log
504 Задержка нужного этапа upstream Сопоставить timeout, upstream_time и работу приложения
HTML вместо JSON Слишком широкий SPA fallback Неизвестный API должен остаться ошибкой API
Всегда BYPASS Authorization, Cookie, выбранная политика Проверить запрос без credentials и отдельный личный запрос
Не меняется конфиг Путь монтирования и reload Посмотреть действующую конфигурацию и повторить контрольный URL

Статус показывает направление поиска, но не единственную возможную причину. Разбирайте цепочку: клиент, внешний вход, совпавший маршрут, upstream, приложение. Сначала воспроизведите один запрос, затем сравните его request ID с журналом. После исправления повторите исходный сценарий и соседний рабочий путь, чтобы не заменить одну проблему другой.

Исходники, проведённые проверки и следующий шаг

В архиве находятся локальный и серверный Compose, полный nginx.conf, генератор, интерфейс HTTP-опытов, Node.js backend, Linux-инструкции, скрипт проверки, лицензии и отчёты. Собственный код можно использовать и изменять по MIT. Примеры сторонних компонентов остаются под их лицензиями, указанными рядом с исходниками.

Реальные HTTP-проверки охватывают сохранение и удаление пути, MISS/HIT/BYPASS, запрет перезаписи публичного кеша личным ответом, истечение TTL, gzip, immutable-статику, 413, 429, 502, 504, SPA, JSON 404 и последовательный SSE. Отдельно проверены nginx -t для вариантов конструктора и серверных файлов, reload и соответствие request ID строке журнала. Серверный TLS-конфиг проверялся с временными тестовыми файлами сертификата; это не выпуск публичного сертификата.

Повторяемая проверка принимает только loopback-адрес

# Linux
python3 scripts/smoke.py http://127.0.0.1:8089
# Windows PowerShell
python scripts/smoke.py http://127.0.0.1:8089

Открыть мастерскую на весь экран ↗Скачать Nginx, Node.js и исходники ZIP ↓

Для собственного проекта начните с маршрута и ожидаемого ответа. Затем добавьте наблюдение, выберите действительно публичную ветку для кеша и согласуйте ограничения с приложением. После локальной проверки переходите к серверному сценарию: домен, HTTP bootstrap, сертификат, HTTPS, продление и контроль после обновления. Так каждая настройка отвечает на понятную задачу и имеет проверяемый результат.

Полный nginx.conf рабочего локального стенда

Конфигурация из архива

# nginx.conf целиком; Docker Compose публикует 127.0.0.1:8089 -> :80.
user nginx;
worker_processes auto;
error_log /dev/stderr warn;
pid /var/run/nginx.pid;
events { worker_connections 1024; }
http {
    include /etc/nginx/mime.types;
    default_type application/octet-stream;
    resolver 127.0.0.11 valid=10s ipv6=off;
    upstream demo_backend {
        zone demo_backend 64k;
        server node:3000 resolve;
        keepalive 16;
    }
    map $http_authorization $has_auth { default 1; "" 0; }
    map $http_cookie $has_cookie { default 1; "" 0; }
    map $request_method $skip_method { default 1; GET 0; HEAD 0; }
    proxy_cache_path /var/cache/nginx/demo levels=1:2 keys_zone=demo:10m
                     max_size=20m inactive=1m use_temp_path=off;
    limit_req_zone $binary_remote_addr zone=per_ip:10m rate=2r/s;
    log_format demo escape=json
      '{"time":"$time_iso8601","request_id":"$request_id",'
      '"method":"$request_method","uri":"$uri","status":$status,'
      '"request_time":"$request_time","upstream_time":"$upstream_response_time",'
      '"upstream_status":"$upstream_status","cache":"$upstream_cache_status",'
      '"limit":"$limit_req_status","bytes":$body_bytes_sent}';
    access_log /dev/stdout demo;
    gzip on;
    gzip_vary on;
    gzip_min_length 512;
    gzip_types text/plain text/css application/json application/javascript;
    server {
        listen 80;
        server_name _;
        absolute_redirect off;
        root /srv;
        client_max_body_size 32k;
        add_header X-Request-ID $request_id always;
        add_header X-Cache $upstream_cache_status always;
        add_header X-RateLimit $limit_req_status always;
        add_header X-Content-Type-Options nosniff always;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header Connection "";
        proxy_set_header X-Request-ID $request_id;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_connect_timeout 2s;
        proxy_read_timeout 5s;
        location = /health { default_type text/plain; return 200 "ok\n"; }
        location /api/ { proxy_pass http://demo_backend; }
        location /strip/ { proxy_pass http://demo_backend/; }
        location /cached/ {
            proxy_pass http://demo_backend/;
            proxy_cache demo;
            proxy_cache_key "$scheme|$proxy_host|$request_uri";
            proxy_cache_methods GET HEAD;
            proxy_cache_valid 200 10s;
            proxy_cache_lock on;
            proxy_cache_bypass $has_auth $has_cookie $skip_method;
            proxy_no_cache $has_auth $has_cookie $skip_method $upstream_http_set_cookie;
        }
        location /limited/ {
            limit_req zone=per_ip burst=2 nodelay;
            limit_req_status 429;
            proxy_pass http://demo_backend/;
        }
        location = /slow { proxy_read_timeout 1s; proxy_pass http://demo_backend; }
        location = /unavailable { proxy_pass http://127.0.0.1:9; }
        location = /events {
            proxy_buffering off;
            proxy_read_timeout 10s;
            gzip off;
            proxy_pass http://demo_backend;
        }
        location /app/ { try_files $uri $uri/ /app/index.html; }
        location ~ "^/assets/[a-zA-Z0-9_-]+[.][a-f0-9]{8,}[.](css|js)$" {
            add_header_inherit merge;
            add_header Cache-Control "public, max-age=31536000, immutable";
            try_files $uri =404;
        }
        location / { try_files $uri $uri/ =404; }
    }
}

Официальные источники: Начало работы, Версии Nginx, Официальные Linux-пакеты.