nginx и ufw перед вашим Node.js-приложением: правильный периметр

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 и обнуляет весь смысл прокси.

Зачем ставить nginx reverse proxy перед Node.js-приложением

Node действительно умеет слушать 80 и 443 напрямую - через `setcap` или запуск от root. Вопрос не в том, может ли он, а в том, чем он в это время занят. Event loop - однопоточный механизм, на котором Node обрабатывает задачи, - в идеале должен тратить время на рендер страниц и вашу бизнес-логику, а не на бухгалтерию TCP-соединений: keep-alive от мобильного клиента с плохой сетью, медленный аплоад, десятки открытых, но простаивающих сокетов. nginx написан именно под эту задачу и держит тысячи параллельных соединений почти без накладных расходов, а приложению отдаёт уже готовый, буферизованный запрос ровно тогда, когда тот реально нужен.

Дальше - практические причины, которые обычно и подталкивают к переходу на reverse proxy - обратный прокси, сервер, который принимает запрос снаружи и перенаправляет его нужному внутреннему сервису:

  • TLS в одном месте. Если вы не за Cloudflare Tunnel (там TLS уже терминируется на стороне Cloudflare), сертификат и его продление логичнее держать на nginx, а не тащить в код приложения.
  • Несколько сайтов на одном IP. nginx смотрит на заголовок Host и решает, какому приложению отдать запрос - можно держать app.example.com и blog.example.com на одном сервере с двумя процессами PM2 на разных портах.
  • Точка для gzip, статики и rate-limiting. Сжатие ответов, отдача файлов из `.next/static` напрямую с диска, ограничение частоты запросов с одного IP - всё это настраивается в конфиге nginx и не требует правок в коде приложения.

Server-блок: proxy_pass на локальный порт приложения

Если 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

Сначала nginx -t, потом reload - а не restart

Перед тем как применить конфиг, проверяем его на синтаксические ошибки:

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`.

TLS - отдельный вопрос, здесь только на что смотреть

Приведённый server-блок слушает только 80 порт, без шифрования. Дальше два варианта, и они не смешиваются:

  • Если сайт идёт через Cloudflare Tunnel из открывающей статьи цикла - TLS уже терминируется на стороне Cloudflare, а между cloudflared и nginx на вашем сервере трафик идёт локально; отдельный сертификат на сервере не нужен вообще.
  • Если вы отдаёте сайт напрямую по домену без туннеля - ставите сертификат Let's Encrypt через certbot, который сам допишет в конфиг блок `listen 443 ssl` и редирект с 80 на 443. Это отдельная механика с обновлением сертификата раз в 90 дней, и в объёме этой статьи мы её не разбираем - только фиксируем, что она идёт поверх уже готового server-блока, а не вместо него.

ufw: минимальный набор правил именно для этой схемы

Базовую защиту свежего сервера - отдельный пользователь, 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 ПОРТ`.

Часто спрашивают

Как настроить nginx как reverse proxy для Node.js?

Создайте файл в `/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`.

Какой командой открыть порты 80 и 443 в ufw?

`sudo ufw allow 80/tcp` и `sudo ufw allow 443/tcp`, затем `sudo ufw status verbose`, чтобы убедиться, что правила действительно применились. Открывать эти порты нужно, только если nginx реально должен быть доступен снаружи напрямую - если весь трафик идёт через Cloudflare Tunnel, эти порты не нужны вовсе.

Зачем в конфиге nginx нужны заголовки proxy_set_header?

Без них приложение получает от 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 нужно запускать перед reload?

`nginx -t` проверяет синтаксис конфига в отдельном процессе, не трогая работающий сервер, и сразу показывает файл и строку с ошибкой, если она есть. Если применить битый конфиг без проверки через `restart`, есть риск получить сервер, на котором nginx вообще не запустится, а сайт полностью ляжет до ручного исправления.

Как проверить, что порт приложения открыт наружу мимо 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 не заменяет Node, а разгружает его от управления сырыми TCP-соединениями и берёт на себя TLS, роутинг по домену и отдачу статики.
  • Четыре заголовка - Host, X-Real-IP, X-Forwarded-For, X-Forwarded-Proto - без них приложение либо не видит реального посетителя, либо путает http с https; но приложение (Express и т.п.) должно ещё явно доверять этому прокси, иначе `req.ip` всё равно берётся из сокета.
  • За Cloudflare Tunnel c nginx посередине не перезатирайте X-Forwarded-Proto через `$scheme` - используйте `$http_x_forwarded_proto`, иначе протокол всегда будет "http".
  • Всегда `nginx -t` перед `systemctl reload nginx`: reload не перезапускает master и незаметно подхватывает только валидный конфиг; restart проходит полный цикл stop/start и рискует не подняться после битого конфига.
  • ufw открывает 80/443 только если nginx реально должен быть виден снаружи, и только те порты, которые уже реально заняты (443 - после того как появится TLS); за Cloudflare Tunnel эти порты не нужны вовсе.
  • Самая частая дыра в периметре - забытое правило `ufw allow 3000` с этапа первой отладки или приложение, слушающее 0.0.0.0 вместо 127.0.0.1.

Что дальше

Периметр закрыт, nginx маршрутизирует трафик, ufw пускает только нужное. Следующий логичный шаг в этом цикле - как выкатывать новую версию приложения без секунды простоя, пока nginx и PM2 уже держат прод: об этом расскажем в отдельной статье про zero-downtime деплой. А пока весь путь целиком - от первого деплоя до правильного периметра - можно пройти по ссылкам: общий разбор деплоя сервиса на сервере, разворачивание Next.js на VPS, диагностика утечки памяти, healthcheck, который правда перезапускает и базовая защита свежего VPS.

PUBLISHED
AUTHOR
HIP-HOSTING
LANGUAGES
EN · RU