nginx reverse proxy для Node: рабочий конфиг с TLS
На моём проекте Node-приложение слушает 127.0.0.1:3000, а наружу торчит nginx на 443. Так живёт уже несколько лет: обновления Node не трогают сертификаты, а правила лимитов и редиректов лежат рядом, в одном месте.
Покажу конфиг, который реально стоит у меня на бою, и проговорю, что в нём важно, а что я туда добавил после первой же ночной паги.
Базовый расклад
Структура простая:
- Node слушает только локальный интерфейс:
127.0.0.1:3000; - nginx терминирует TLS, проксирует на Node, отдаёт статику;
- сертификат — Let's Encrypt через certbot, лежит в
/etc/letsencrypt/live/example.com/.
Файл — /etc/nginx/sites-available/example.com.conf, потом симлинк в sites-enabled/.
Сам конфиг
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://example.com$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
add_header Strict-Transport-Security "max-age=63072000" always;
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options SAMEORIGIN;
add_header Referrer-Policy strict-origin-when-cross-origin;
client_max_body_size 10m;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
gzip_min_length 1024;
location /_static/ {
alias /var/www/example.com/dist/;
expires 30d;
access_log off;
}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
}
server {
listen 443 ssl http2;
server_name www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
return 301 https://example.com$request_uri;
}Что здесь действительно важно
map для Upgrade
Блок map $http_upgrade $connection_upgrade в самом верху — без него WebSocket-соединения от Node-приложения работают как повезёт. С ним — стабильно: nginx отдаёт Connection: upgrade, когда клиент его запросил, и close, когда нет.
Перенаправление с www и с http
У меня одна каноническая форма — https://example.com. Всё остальное (http://, www.) уходит в 301. Это сразу решает половину будущих проблем с куками и SEO.
X-Forwarded-Proto
Express и большинство Node-фреймворков по умолчанию не знают, что они стоят за прокси. Из-за этого req.protocol отдаёт http, и тогда res.redirect('/dashboard') может уехать на http://. Лечится двумя вещами:
- nginx ставит
X-Forwarded-Proto $scheme(он у меня уже есть); - Express зовёт
app.set('trust proxy', 1).
Без этой пары увидишь mixed content и редиректы в неожиданные места.
client_max_body_size
Дефолт у nginx — 1 МБ. Я однажды три часа искал, почему не загружается аватарка 1.5 МБ. Помни про этот параметр, если в Node-приложении есть аплоад файлов.
Статика прямо из nginx
Node-сервер отдаёт HTML/JSON, а ассеты — пусть забирает nginx сам. У меня /_static/ мапится на dist/, который собирает фронт. Это:
- снимает с Node нагрузку по отдаче js/css;
- включает кеш на 30 дней;
- гасит логи в access — иначе там быстро будет каша.
В Node-приложении тогда ставлю префикс ассетов /_static/, чтобы они не путались с роутами.
HSTS, gzip, заголовки безопасности
HSTS включаю не сразу. Сначала проверяю, что HTTPS реально едет везде, и только потом ставлю max-age=63072000. Если включить с самого начала, а потом сертификат сломается — пользователи не смогут зайти даже по http.
Gzip даёт ощутимый профит на JSON-ответах от API и на html. Brotli — ещё чуть лучше, но требует отдельного модуля; если его нет в дистрибутиве, оставляю gzip и сплю спокойно.
Как я это раскатываю и проверяю
sudo nginx -t
sudo systemctl reload nginxПеред reload я всегда смотрю на nginx -t. Один раз я случайно положил тестовый сервер, потому что ребутнул сразу. Теперь — только через тест.
Дальше — чек снаружи:
curl -I https://example.com
curl -I https://www.example.com
curl -I http://example.comХочется убедиться, что:
- https-ответ — 200 (или то, что должен отдавать Node);
- www уходит в 301 на голый домен;
- http тоже уходит в 301 на https.
Что я бы добавил, если проект чуть больше
Когда сервис вырастает за один сервер, я обычно тащу:
limit_req_zoneиlimit_req— на эндпоинты логина и регистрации, чтобы не позволить перебор;- отдельный
upstreamс несколькими backend-инстансами Node; - отдельный лог-формат с временем ответа upstream — это спасает при разборе медленных запросов.
Если хочется готовых cipher-настроек, я смотрю ssl-config от Mozilla в режиме intermediate и оттуда копирую блок шифров и параметры протокола под мою версию nginx.