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

Nginx помогает направить запрос в нужное приложение, уменьшить повторную нагрузку и понять причину сбоя. Но полезный результат появляется не от количества директив. Нужно видеть, какой URL получил backend, почему ответ взят из кеша, где возникла задержка и что изменилось после reload. Именно такие повседневные задачи разберём на работающем проекте.
В статье есть конструктор nginx.conf, восемь прикладных ситуаций, настоящая локальная лаборатория и путь публикации на Linux VPS или выделенном сервере. Будут API, MISS/HIT/BYPASS, исключение личных ответов из кеша, gzip, загрузки файлов, заголовки, лимиты, SSE и диагностика ошибок. Команды для Windows и Linux приведены отдельно, а серверный вариант доведён до домена, сертификата, автозапуска и обновления.
Где 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-пакеты.


