nginx берёт на себя TLS, роутинг по домену и защиту от битого конфига через nginx -t; ufw закрывает всё, кроме SSH и 80/443 - но только если приложение не спрятано за Cloudflare Tunnel. Разбираем конфиг, заголовки прокси и самую частую дыру в периметре - открытый порт приложения в обход nginx.
Если вы разворачивали сайт по первой статье цикла, приложение живёт под PM2 на 127.0.0.1:3000 и наружу торчит только через Cloudflare Tunnel - открытых портов на сервере нет вообще. Но рано или поздно у вас появляется причина выйти за эти рамки: подключить второй домен на том же VPS, отказаться от туннеля и отдавать сайт напрямую по IP или домену, или просто до конца понять, что именно открыто наружу на сервере, который уже что-то раздаёт. Все три сценария сводятся к одному вопросу - как поставить nginx reverse proxy перед Node.js правильно и закрыть периметр через ufw так, чтобы не осталось случайных дыр.
Коротко. nginx принимает соединения снаружи и передаёт запросы вашему приложению на 127.0.0.1:3000 - это разгружает Node, даёт единую точку для TLS и позволяет держать несколько сайтов на одном IP. ufw при этом остаётся закрытым: SSH плюс 80/443, и то только если nginx реально должен быть виден снаружи - за Cloudflare Tunnel эти порты не нужны вовсе. Самая частая ошибка - забытое старое правило вроде `ufw allow 3000`, которое пускает трафик прямо в приложение мимо nginx и обнуляет весь смысл прокси.
Node действительно умеет слушать 80 и 443 напрямую - через `setcap` или запуск от root. Вопрос не в том, может ли он, а в том, чем он в это время занят. Event loop - однопоточный механизм, на котором Node обрабатывает задачи, - в идеале должен тратить время на рендер страниц и вашу бизнес-логику, а не на бухгалтерию TCP-соединений: keep-alive от мобильного клиента с плохой сетью, медленный аплоад, десятки открытых, но простаивающих сокетов. nginx написан именно под эту задачу и держит тысячи параллельных соединений почти без накладных расходов, а приложению отдаёт уже готовый, буферизованный запрос ровно тогда, когда тот реально нужен.
Дальше - практические причины, которые обычно и подталкивают к переходу на reverse proxy - обратный прокси, сервер, который принимает запрос снаружи и перенаправляет его нужному внутреннему сервису:
Если nginx ещё не стоит - проверьте и поставьте:
nginx -v
sudo apt update && sudo apt install -y nginx
nginx -v - печатает установленную версию; если пакета нет, команда просто вернёт ошибку "command not found", и вы ставите nginx второй строкой.Дальше создаём конфиг сайта. Если вы уже проходили открывающую статью цикла, файл `/etc/nginx/sites-available/app` у вас, скорее всего, есть - его и правим. Если нет, создаём с нуля:
sudo nano /etc/nginx/sites-available/app
server {
listen 80;
server_name example.com www.example.com;
# раскомментируйте, если в приложении есть загрузка файлов крупнее 1 МБ
# client_max_body_size 20m;
location / {
proxy_pass http://127.0.0.1:3000;
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;
}
}
Разберём, зачем нужна каждая строка:
server_name - домен, на который откликается этот блок; без совпадения по имени nginx отдаст запрос дефолтному серверу или вернёт 444/400 в зависимости от конфигурации. Если хотите, чтобы этот же блок отвечал и на прямые запросы по IP, а не только по домену - добавьте `default_server` к `listen`: `listen 80 default_server;`. Без этого запрос на `http://IP_СЕРВЕРА/` может попасть не в ваш блок, а в стандартный сайт `/etc/nginx/sites-enabled/default`, который Ubuntu ставит вместе с nginx - и вместо приложения посетитель увидит заглушку "Welcome to nginx". `default_server` можно указать только у одного блока на сервере.client_max_body_size 20m (закомментировано в примере) - по умолчанию nginx режет тело запроса на 1 МБ. Раскомментируйте строку, только если в приложении реально есть загрузка файлов - без неё пользователи получат `413 Request Entity Too Large` на любой картинке крупнее мегабайта. Для сайта без аплоадов эта строка не нужна, задирать лимит "про запас" смысла нет; и учтите, что у самого приложения (например, API-роута) может быть свой, меньший лимит тела запроса, который сработает раньше, чем ограничение nginx.proxy_pass http://127.0.0.1:3000 - собственно передача запроса локальному процессу, тому самому, которым управляет PM2.proxy_set_header Host $host - без этой строки приложение видит в заголовке Host не домен посетителя, а "127.0.0.1", и любая логика, завязанная на домен (мультитенантность, редиректы, абсолютные ссылки), ломается.proxy_set_header X-Real-IP и X-Forwarded-For - передают приложению реальный IP посетителя в заголовках. Без них в логах nginx и в этих заголовках будет один и тот же адрес - 127.0.0.1 - для каждого запроса, потому что для Node клиентом технически является сам nginx. Учтите: сами по себе заголовки ещё не делают `req.ip` в приложении правильным. Например, в Express по умолчанию `trust proxy` выключен, и `req.ip` продолжает браться из TCP-сокета, то есть из nginx, даже если заголовок X-Forwarded-For уже приходит верный - фреймворку нужно явно сказать, какому прокси доверять: `app.set('trust proxy', '127.0.0.1')`. Заодно `$proxy_add_x_forwarded_for` не заменяет заголовок безусловно, а дописывает `$remote_addr` к тому, что уже пришло в X-Forwarded-For - для одиночного nginx перед приложением разницы нет, но если запросы идут через ещё один прокси выше по цепочке, в заголовке будет цепочка адресов, а не один.proxy_set_header X-Forwarded-Proto $scheme - говорит приложению, был исходный запрос по http или https. Без этого заголовка приложение за TLS-терминирующим nginx может решить, что соединение небезопасное, и начать редиректить на http или ставить cookie без флага Secure. Важно для схемы с Cloudflare Tunnel: если nginx стоит МЕЖДУ cloudflared и приложением, `$scheme` тут всегда будет `http` - cloudflared обращается к nginx локально, без TLS, независимо от того, каким протоколом пришёл посетитель. Эта строка в таком случае молча перезатирает корректный заголовок, который уже прислал сам Cloudflare, значением `http`. Для связки Tunnel -> nginx -> приложение замените строку на `proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;`, чтобы nginx передавал дальше протокол, определённый Cloudflare, а не свой локальный. Если nginx в этой схеме нужен только ради нескольких доменов на одном IP, а TLS всё равно на стороне Cloudflare, часто проще не пускать через него сам туннель - направить cloudflared сразу на порт приложения, Cloudflare официально поддерживает такой маршрут.Если приложение использует WebSocket (Next.js HMR в dev-режиме, Socket.io, любые realtime-фичи в проде) - базового блока выше недостаточно. Стоковый Ubuntu 24.04 несёт nginx 1.24.x, который по умолчанию проксирует по HTTP/1.0, а WebSocket требует явного апгрейда соединения:
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
Добавляйте эти три строки в `location`, только если приложение реально использует WebSocket - лишние директивы в конфиге усложняют чтение без пользы.
Включаем сайт симлинком в `sites-enabled` - именно эта папка реально читается nginx при старте:
sudo ln -s /etc/nginx/sites-available/app /etc/nginx/sites-enabled/app
Перед тем как применить конфиг, проверяем его на синтаксические ошибки:
sudo nginx -t
При исправном конфиге вы увидите ровно две строки:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
Если, например, забыть точку с запятой после `listen 80`, парсер решит, что следующая строка - это ещё один параметр той же директивы, и споткнётся на ней же, а не на самой пропущенной точке с запятой:
nginx: [emerg] invalid parameter "server_name" in /etc/nginx/sites-enabled/app:3
nginx: configuration file /etc/nginx/nginx.conf test failed
Конкретный текст ошибки зависит от того, где именно пропущена точка с запятой - иногда это `unexpected "}"`, иногда `invalid parameter`, как здесь. Общее у обоих - всегда указан файл и номер строки, с которой стоит начинать искать опечатку.
Это и есть смысл шага: `nginx -t` парсит конфиг в отдельном процессе и ничего не трогает в работающем сервере. Дальше применяем изменения:
sudo systemctl reload nginx
Разница с `systemctl restart nginx` принципиальна. `reload` посылает мастер-процессу сигнал перечитать конфиг и поднимает новые worker-процессы, а старые доживают уже открытые соединения и закрываются сами - сам master-процесс при этом не перезапускается, и для посетителей это незаметно. `restart` вместо этого проходит полный цикл остановки и запуска: пакетный `nginx.service` в Ubuntu при остановке посылает воркерам сигнал на корректное завершение, а не убивает их мгновенно, но между stop и start всегда есть окно, когда nginx вообще не слушает порты - и именно тут риск. Если конфиг битый, а `nginx -t` не проверили заранее, `restart` может закончиться тем, что nginx после `stop` просто не поднимется обратно - и сайт лежит, пока вы не найдёте опечатку руками. Поэтому рабочий порядок один: `nginx -t && systemctl reload nginx`.
Приведённый server-блок слушает только 80 порт, без шифрования. Дальше два варианта, и они не смешиваются:
Базовую защиту свежего сервера - отдельный пользователь, SSH-ключи, `default deny incoming` - вы уже настроили по инструкции о защите свежего VPS. "Deny incoming, allow outgoing" на практике означает: сервер сам может достучаться куда угодно (обновления, npm install, исходящие webhook-и), но никто не может достучаться до сервера, пока вы явно не разрешили конкретный порт. Дальше - развилка, и она реально зависит от того, как именно приложение смотрит в интернет.
Cloudflare Tunnel (по первой статье цикла) | Прямой доступ по IP/домену | |
|---|---|---|
nginx нужен | Опционально - удобен для нескольких сайтов на одном IP, но не обязателен | Да, это единственная точка входа |
Порты 80/443 в ufw | Не открывать вообще - cloudflared сам инициирует исходящее соединение | Открыть оба, если только не уверены, что нужен исключительно http или исключительно https |
TLS-сертификат на сервере | Не нужен, терминируется на Cloudflare | Нужен, ставится через certbot |
Что видит приложение как IP посетителя | Заголовок CF-Connecting-IP от Cloudflare | Заголовок X-Real-IP от вашего nginx |
Если вы за туннелем - на этом раздел для вас закончен, открывать ничего не нужно, идите сразу к разделу про частую ошибку. Если отдаёте сайт напрямую, минимальный набор правил такой:
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw status verbose
Открывайте только то, чем реально пользуетесь прямо сейчас. Если TLS ещё не настроен и nginx пока не слушает 443 - certbot допишет `listen 443 ssl` отдельным шагом позже - на этом этапе достаточно `sudo ufw allow 80/tcp`, а `443/tcp` добавите второй командой уже после certbot. Открытый, но ничем не занятый порт - не дыра сама по себе, но и открывать его заранее «про запас» смысла нет.
В выводе должны остаться три разрешённых входящих направления - SSH-порт, который вы открыли при первичной защите сервера (по умолчанию 22, если не меняли), плюс теперь 80 и 443:
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
New profiles: skip
To Action From
-- ------ ----
22/tcp ALLOW IN Anywhere
80/tcp ALLOW IN Anywhere
443/tcp ALLOW IN Anywhere
22/tcp (v6) ALLOW IN Anywhere (v6)
80/tcp (v6) ALLOW IN Anywhere (v6)
443/tcp (v6) ALLOW IN Anywhere (v6)
Ничего третьего порта здесь быть не должно. Если видите ещё одну строку - переходим к следующему разделу.
Частая ошибка: порт приложения всё ещё открыт наружу в обход nginx. На этапе первого деплоя многие открывают порт 3000 явно - чтобы быстро проверить, что приложение вообще отвечает, ещё до того, как дошли руки до nginx: `sudo ufw allow 3000/tcp`. Дальше появляется nginx, появляются заголовки, rate-limiting - а старое правило остаётся в списке. В итоге любой, кто знает или перебирает порты, стучится прямо в http://ВАШ_IP:3000, полностью минуя nginx вместе со всем, что вы там настроили - лимитами частоты запросов, будущим TLS, логикой по Host.
Проверяем список правил с номерами и убираем лишнее:
sudo ufw status numbered
sudo ufw delete N
N - номер строки с правилом `3000/tcp`, который вы увидели в выводе предыдущей команды.Вторая линия защиты - не полагаться только на firewall, а сразу не пускать приложение слушать интерфейс, доступный снаружи. По умолчанию `next start` слушает 0.0.0.0 - все интерфейсы сервера, включая внешний. В `ecosystem.config.js`, которым PM2 запускает приложение, добавьте явный флаг хоста:
args: 'start -p 3000 -H 127.0.0.1'
-H 127.0.0.1 - ограничивает Next только локальным интерфейсом; даже если кто-то потом по ошибке добавит `ufw allow 3000` во время очередной отладки, порт снаружи всё равно будет не виден, потому что приложение физически не слушает внешний IP.После правки перезапускаем процесс - но не командой `pm2 restart web`: она обновляет переменные окружения текущего процесса, а не перечитывает сам файл `ecosystem.config.js`, так что новый `args` рискует не примениться. Нужно явно указать PM2 сам файл конфигурации:
pm2 restart ecosystem.config.js --only web --update-env
Так PM2 заново читает весь файл и применяет новый `args` к процессу `web`, а не просто перезапускает его со старыми настройками.
Сначала убедитесь, что приложение вообще живо локально - это разделяет проблемы "приложение упало" и "приложение живо, но не достучаться снаружи":
curl -I http://127.0.0.1:3000
Ожидаемый результат - успешный ответ с кодом состояния (обычно 200, но зависит от того, что именно отдаёт корневой маршрут вашего приложения) и заголовками вроде `X-Powered-By: Next.js`.
Дальше смотрим, на каком интерфейсе реально слушает процесс - это быстрее, чем гадать по конфигу:
sudo ss -tlnp | grep 3000
Строка вида `LISTEN 0 511 127.0.0.1:3000 0.0.0.0:* users:(("next-server",...))` - это то, что нужно: адрес слева от порта - 127.0.0.1, не 0.0.0.0. Если там стоит `0.0.0.0:3000` или `*:3000` - приложение слушает все интерфейсы, и единственное, что стоит между ним и интернетом - правила ufw.
Финальная проверка - со своего компьютера, не с самого VPS. Через порт 80, который слушает nginx, запрос должен пройти:
curl -I http://example.com/
А прямой запрос на порт приложения, если периметр настроен верно, должен зависнуть и оборваться по таймауту - соединение до сервера просто не устанавливается, потому что ufw отбрасывает пакет ещё до того, как он доходит до порта 3000:
curl -m 5 http://ВАШ_IP:3000/
Если вместо таймаута вы получаете ответ приложения - значит совпали сразу два условия: приложение слушает внешний интерфейс И firewall пропускает этот трафик, одного из двух недостаточно (в этом и сила «второй линии защиты» выше). Проверьте обе: `sudo ss -tlnp | grep ПОРТ` покажет, не на 0.0.0.0 ли биндинг, `sudo ufw status numbered` - нет ли лишнего правила `allow ПОРТ`.
Создайте файл в `/etc/nginx/sites-available/`, пропишите в нём `server_name`, `listen 80` и `location / { proxy_pass http://127.0.0.1:3000; }` с четырьмя заголовками - Host, X-Real-IP, X-Forwarded-For, X-Forwarded-Proto. Включите сайт симлинком в `sites-enabled`, проверьте конфиг командой `nginx -t` и примените через `systemctl reload nginx`.
`sudo ufw allow 80/tcp` и `sudo ufw allow 443/tcp`, затем `sudo ufw status verbose`, чтобы убедиться, что правила действительно применились. Открывать эти порты нужно, только если nginx реально должен быть доступен снаружи напрямую - если весь трафик идёт через Cloudflare Tunnel, эти порты не нужны вовсе.
Без них приложение получает от nginx запрос, в котором клиентом всегда выглядит сам nginx на 127.0.0.1: в логах будет один и тот же IP для всех посетителей, а любая логика, завязанная на домен или на протокол (http/https), может сломаться. Заголовки Host, X-Real-IP, X-Forwarded-For и X-Forwarded-Proto передают приложению реальные данные о запросе, которые сам nginx уже знает - но само приложение ещё должно быть настроено доверять этому конкретному прокси и разбирать заголовки (например, `trust proxy` в Express), иначе оно продолжит брать данные из сокета, а не из заголовков.
`nginx -t` проверяет синтаксис конфига в отдельном процессе, не трогая работающий сервер, и сразу показывает файл и строку с ошибкой, если она есть. Если применить битый конфиг без проверки через `restart`, есть риск получить сервер, на котором nginx вообще не запустится, а сайт полностью ляжет до ручного исправления.
С другого компьютера, не с самого VPS, выполните `curl -m 5 http://ВАШ_IP:ПОРТ/` (с реальным IP сервера и портом приложения вместо плейсхолдеров) - если запрос отвечает вместо того, чтобы упасть по таймауту, значит совпали сразу оба условия: приложение слушает внешний интерфейс, и firewall это пропускает. На самом сервере команда `sudo ss -tlnp | grep ПОРТ` покажет, слушает ли приложение только 127.0.0.1 или все интерфейсы через 0.0.0.0, а `sudo ufw status numbered` - нет ли лишнего разрешающего правила.
Периметр закрыт, nginx маршрутизирует трафик, ufw пускает только нужное. Следующий логичный шаг в этом цикле - как выкатывать новую версию приложения без секунды простоя, пока nginx и PM2 уже держат прод: об этом расскажем в отдельной статье про zero-downtime деплой. А пока весь путь целиком - от первого деплоя до правильного периметра - можно пройти по ссылкам: общий разбор деплоя сервиса на сервере, разворачивание Next.js на VPS, диагностика утечки памяти, healthcheck, который правда перезапускает и базовая защита свежего VPS.