Настройка Nginx reverse proxy нужна, когда на одном сервере работает несколько приложений: сайт, API, панель администратора. Каждое слушает свой порт на 127.0.0.1, а Nginx принимает все запросы на портах 80 и 443 и по имени домена передаёт их нужному приложению. В итоге у каждого сайта свой сертификат Let's Encrypt, HTTP переадресуется на HTTPS, работают WebSocket и загрузка больших файлов, а сертификаты продлеваются сами.

Проверено на Ubuntu 24.04 и 26.04: Nginx 1.24.0 и 1.28.3, certbot 2.9.0 и 4.0.0 из репозиториев Ubuntu. Команды в статье получают сертификаты у настоящего Let's Encrypt. В тестовой среде нет публичного домена, поэтому выпуск, продление и пробное продление мы проверили на Pebble — тестовом ACME-сервере от Let's Encrypt, который так же проверяет домен запросом на порт 80. Вместо 203.0.113.10 подставьте адрес своего сервера, вместо example.com и api.example.com — свои домены.

Что нужно заранее

  • Сервер с Ubuntu 24.04 или 26.04, пользователь admin с sudo, открытые в файрволе порты 80 и 443 — см. первую настройку сервера.
  • Домены, у которых A-записи указывают на сервер. В примере их три: example.com, www.example.com и api.example.com.
  • Приложения, которые слушают только локальный адрес: сайт — 127.0.0.1:3000, API — 127.0.0.1:8000. Как так запустить приложение на Node.js — в статье о деплое Next.js, контейнер Docker — с публикацией порта -p 127.0.0.1:3000:3000.

Как устроен обратный прокси

https://example.com      ─┐                 ┌─> 127.0.0.1:3000  сайт
                          ├─> Nginx :80 :443 ┤
https://api.example.com  ─┘                 └─> 127.0.0.1:8000  API

Обратный прокси (reverse proxy) — сервер, который принимает запросы вместо приложения и пересылает их ему. Nginx выбирает, куда отправить запрос, по заголовку Host и директиве server_name. Он же держит сертификаты, переадресует HTTP на HTTPS и ограничивает размер запросов. Приложения снаружи недоступны: они слушают 127.0.0.1, и попасть к ним можно только через Nginx.

Шаг 1. Установить Nginx

sudo apt update
sudo apt install -y nginx
nginx -v

Nginx из репозитория Ubuntu сразу запускается и включается в автозагрузку. Версия — nginx/1.24.0 в Ubuntu 24.04 и nginx/1.28.3 в 26.04; обновления безопасности приходят вместе с системными.

Проверка: systemctl is-active nginx выводит active, а curl -sI http://203.0.113.10/ | head -1 с вашего компьютера — HTTP/1.1 200 OK (стандартная страница Nginx).

Шаг 2. Общие настройки прокси: заголовки и WebSocket

Настройки, одинаковые для всех сайтов, вынесем в два файла. Первый — для WebSocket:

sudo tee /etc/nginx/conf.d/websocket.conf > /dev/null <<'EOF'
# Для WebSocket передать приложению Connection: upgrade,
# для обычных запросов — Connection: close
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}
EOF

Второй — заголовки, которые Nginx передаёт приложению:

sudo tee /etc/nginx/snippets/proxy.conf > /dev/null <<'EOF'
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;
EOF

Что здесь важно:

  • Host — без этой строки приложение получит Host: 127.0.0.1:3000 вместо домена и может строить неверные ссылки.
  • X-Real-IP и X-Forwarded-For — адрес посетителя. Без них приложение видит все запросы как пришедшие с 127.0.0.1, и не работают ограничения по IP и журналы.
  • X-Forwarded-Proto — протокол, по которому пришёл посетитель (http или https). Приложение по нему понимает, что страница открыта по HTTPS, хотя от Nginx к нему запрос идёт по обычному HTTP.
  • proxy_http_version 1.1, Upgrade, Connection — нужны для WebSocket. По умолчанию Nginx ходит к приложению по HTTP/1.0 и не пересылает заголовки Upgrade и Connection, поэтому без этих строк соединение WebSocket не установится. Так рекомендует документация Nginx.

В Ubuntu уже есть похожий файл /etc/nginx/proxy_params, но в нём нет настроек для WebSocket, поэтому мы сделали свой.

Шаг 3. Server block для каждого сайта

Server block — блок server { … } с настройками одного сайта. Создадим по файлу на сайт. Основной сайт с WebSocket по адресу /ws/:

sudo tee /etc/nginx/sites-available/example.com > /dev/null <<'EOF'
server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;

    client_max_body_size 20m;
    proxy_connect_timeout 5s;
    proxy_read_timeout 60s;
    proxy_send_timeout 60s;

    location / {
        include snippets/proxy.conf;
        proxy_pass http://127.0.0.1:3000;
    }

    location /ws/ {
        include snippets/proxy.conf;
        proxy_pass http://127.0.0.1:3000;
        proxy_read_timeout 1h;
        proxy_send_timeout 1h;
    }
}
EOF

API — с большими загрузками и долгими запросами:

sudo tee /etc/nginx/sites-available/api.example.com > /dev/null <<'EOF'
server {
    listen 80;
    listen [::]:80;
    server_name api.example.com;

    client_max_body_size 100m;
    proxy_read_timeout 300s;

    location / {
        include snippets/proxy.conf;
        proxy_pass http://127.0.0.1:8000;
    }
}
EOF

Включите сайты, отключите стандартную страницу и примените настройки:

sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/api.example.com /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx

nginx -t проверяет синтаксис и пишет test is successful; только после этого reload применяет настройки. При reload Nginx не обрывает текущие соединения: в нашей проверке 600 запросов подряд во время перезагрузки настроек получили ответ 200, а открытое соединение WebSocket продолжило работать.

Что значат лимиты:

  • client_max_body_size — наибольший размер запроса, например загружаемого файла. По умолчанию 1 МБ; больше — Nginx отвечает 413 Request Entity Too Large. В нашей проверке файл 30 МБ на example.com (лимит 20 МБ) получил 413, а на api.example.com дошёл до приложения.
  • proxy_read_timeout — сколько ждать ответа приложения (точнее, паузы между частями ответа). По умолчанию 60 секунд, потом Nginx отвечает 504 Gateway Time-out. Запрос, на который приложение отвечало 65 секунд, на example.com получил 504 ровно через 60 секунд, а на api.example.com с лимитом 300 секунд — ответ 200.
  • proxy_connect_timeout 5s — сколько ждать подключения к приложению. Приложение на том же сервере подключается мгновенно; если оно не запущено, лучше быстро отдать ошибку, чем ждать 60 секунд по умолчанию.
  • /ws/ с таймаутом 1 час — для WebSocket proxy_read_timeout означает, сколько соединение может молчать. С 60 секундами Nginx закрывал бы каждое соединение, по которому минуту не было сообщений. Соединение, молчавшее 70 секунд, в нашей проверке продолжило работать.

Обратите внимание на косую черту в location /ws/: запрос к /ws без неё Nginx переадресует на /ws/ с кодом 301. Если клиент подключается к /ws, так и напишите: location /ws {.

Проверка — с вашего компьютера, домены уже указывают на сервер:

curl -sI http://example.com/ | head -1
curl -sI http://api.example.com/ | head -1

Оба ответа — от ваших приложений, а не стандартная страница Nginx.

Шаг 4. Установить certbot

Certbot — программа, которая получает сертификаты Let's Encrypt и продлевает их. Поставим её из репозитория Ubuntu вместе с модулем для Nginx:

sudo apt install -y certbot python3-certbot-nginx
certbot --version

Версия — certbot 2.9.0 в Ubuntu 24.04 и certbot 4.0.0 в 26.04. Пакет сам включает таймер продления certbot.timer. Свежую версию certbot (последняя — 5.8 от сентября 2026 года) можно поставить через snap по инструкции certbot — все команды ниже для него те же.

Шаг 5. Получить сертификаты: certbot --nginx

Выпустите сертификат для каждого сайта отдельно:

sudo certbot --nginx -d example.com -d www.example.com
sudo certbot --nginx -d api.example.com

При первом запуске certbot спросит почту и попросит принять условия Let's Encrypt. Писем об окончании срока сертификатов Let's Encrypt больше не присылает — эту рассылку закрыли в июне 2025 года, поэтому за продлением следит таймер (шаг 8). Затем certbot подтверждает владение доменом: кладёт временный файл, который Let's Encrypt скачивает по адресу http://example.com/.well-known/acme-challenge/…. Поэтому порт 80 должен быть открыт.

Отдельные сертификаты удобнее одного общего: если у одного домена сломается DNS, второй сертификат всё равно продлится.

Модуль --nginx сам правит файлы сайтов. В example.com он:

  • заменил listen 80 на listen 443 ssl и добавил пути к сертификату и общие настройки TLS (options-ssl-nginx.conf, ssl-dhparams.pem);
  • добавил второй блок server на порту 80, который отвечает переадресацией 301 на HTTPS. Отдельно настраивать переадресацию не нужно: в нашей проверке обе версии certbot добавили её без вопросов.

Посмотреть результат — cat /etc/nginx/sites-available/example.com; строки certbot помечены комментарием # managed by Certbot.

Проверка:

curl -sI http://example.com/page | grep -iE '^HTTP|^location'
curl -sI https://example.com/ | head -1
sudo certbot certificates

Первая команда показывает HTTP/1.1 301 Moved Permanently и Location: https://example.com/page, вторая — HTTP/1.1 200 OK. certbot certificates перечисляет оба сертификата с доменами, путями и сроком: сейчас Let's Encrypt выдаёт их на 90 дней.

Шаг 6. Включить HTTP/2

Certbot включает HTTPS, но не HTTP/2 — более быстрый протокол, который браузеры используют по HTTPS. Запись зависит от версии Nginx.

В Ubuntu 26.04 (Nginx 1.28) добавьте в блок server с listen 443 ssl строку:

http2 on;

В Ubuntu 24.04 (Nginx 1.24) директивы http2 on ещё нет — допишите http2 в обе строки listen:

listen [::]:443 ssl http2 ipv6only=on; # managed by Certbot
listen 443 ssl http2; # managed by Certbot

Примените: sudo nginx -t && sudo systemctl reload nginx. Проверка: curl -sI https://example.com/ | head -1 выводит HTTP/2 200.

Шаг 7. Заголовки безопасности

sudo tee /etc/nginx/snippets/security-headers.conf > /dev/null <<'EOF'
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
EOF

Подключите его в каждом блоке server с listen 443 — например, сразу после server_name:

    server_name example.com www.example.com;
    include snippets/security-headers.conf;

И скройте версию Nginx в ответах — в Ubuntu 24.04 эта строка в nginx.conf закомментирована, в 26.04 стоит server_tokens build;; команда исправляет оба случая:

sudo sed -i -E 's/^(\s*)#?\s*server_tokens .*/\1server_tokens off;/' /etc/nginx/nginx.conf
sudo nginx -t && sudo systemctl reload nginx

Что делают заголовки:

  • Strict-Transport-Security (HSTS) — браузер год открывает сайт только по HTTPS, даже если набрать http://. Включайте, когда HTTPS уже работает: отменить его для посетителей, которые уже заходили, быстро нельзя. includeSubDomains добавляйте, только если по HTTPS работают все поддомены.
  • X-Content-Type-Options: nosniff — браузер не пытается угадать тип файла по содержимому.
  • X-Frame-Options: SAMEORIGIN — сайт нельзя встроить во фрейм на чужой странице.
  • Referrer-Policy — на другие сайты уходит только домен, без полного адреса страницы.
  • always — заголовки добавляются и к ответам с ошибками.

У add_header есть особенность: если в каком-то location есть своя директива add_header, заголовки из блока server там не действуют. Тогда повторите include snippets/security-headers.conf; внутри этого location.

Проверка:

curl -sI https://example.com/ | grep -iE '^server|^strict|^x-|^referrer'

Должны быть все четыре заголовка и Server: nginx без номера версии.

Вариант: certbot не трогает настройки Nginx (webroot)

Если настройки Nginx хранятся в git или раскладываются Ansible, удобнее, чтобы certbot только получал сертификаты, а блоки server вы писали сами. Для этого есть режим webroot: certbot кладёт проверочный файл в каталог, а Nginx отдаёт его. Покажем на api.example.com — для нового сайта, ещё без сертификата.

Сначала блок на порту 80: проверочные файлы отдаём из каталога, остальное переадресуем на HTTPS:

sudo mkdir -p /var/www/letsencrypt
sudo tee /etc/nginx/sites-available/api.example.com > /dev/null <<'EOF'
server {
    listen 80;
    listen [::]:80;
    server_name api.example.com;

    location /.well-known/acme-challenge/ {
        root /var/www/letsencrypt;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}
EOF
sudo nginx -t && sudo systemctl reload nginx
sudo certbot certonly --webroot -w /var/www/letsencrypt -d api.example.com \
  --deploy-hook "systemctl reload nginx"

--deploy-hook certbot запомнит и будет выполнять после каждого продления: без перезагрузки Nginx продолжит отдавать старый сертификат. Модуль --nginx перезагружает Nginx сам, здесь — нет.

Блок для порта 443 добавляйте только после того, как сертификат получен: если файла сертификата ещё нет, nginx -t выдаёт ошибку cannot load certificate. Допишите в тот же файл:

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;    # в Ubuntu 24.04 вместо этой строки: listen 443 ssl http2;
    server_name api.example.com;

    ssl_certificate     /etc/letsencrypt/live/api.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
    include snippets/security-headers.conf;

    client_max_body_size 100m;
    proxy_read_timeout 300s;

    location / {
        include snippets/proxy.conf;
        proxy_pass http://127.0.0.1:8000;
    }
}

И примените: sudo nginx -t && sudo systemctl reload nginx. Версии TLS отдельно указывать не нужно: в нашей проверке на обеих версиях Ubuntu сервер принимал TLS 1.2 и 1.3 и отклонял устаревшие 1.0 и 1.1.

Проверка: curl -sI https://api.example.com/ | head -1 выводит HTTP/2 200, а в /etc/letsencrypt/renewal/api.example.com.conf есть строки authenticator = webroot и renew_hook = systemctl reload nginx.

Шаг 8. Автоматическое продление: certbot.timer и renew --dry-run

Пакет certbot запускает продление по таймеру systemd дважды в сутки, со случайной задержкой. Certbot продлевает только те сертификаты, срок которых подходит к концу: версия 2.9 — за 30 дней до окончания, версия 4.0 и новее — когда осталась треть срока (журнал изменений certbot).

systemctl list-timers certbot.timer
sudo certbot renew --dry-run

Первая команда показывает, когда таймер сработает в следующий раз. Вторая — пробное продление: certbot проходит все шаги с тестовым сервером Let's Encrypt (staging), не трогая настоящие сертификаты и не выполняя --deploy-hook. Ожидаемый результат:

Processing /etc/letsencrypt/renewal/api.example.com.conf
Simulating renewal of an existing certificate for api.example.com

Processing /etc/letsencrypt/renewal/example.com.conf
Simulating renewal of an existing certificate for example.com and www.example.com

Congratulations, all simulated renewals succeeded:
  /etc/letsencrypt/live/api.example.com/fullchain.pem (success)
  /etc/letsencrypt/live/example.com/fullchain.pem (success)

Запускайте --dry-run после любых изменений в настройках сайтов: если продление сломалось, вы узнаете об этом сразу, а не когда сертификат истечёт.

Сертификаты на 64 дня с 10 февраля 2027 года

Let's Encrypt объявил, что с 10 февраля 2027 года сертификаты будут выдаваться на 64 дня вместо 90. Тестовый сервер переходит на новый срок 14 октября 2026 года. Подтверждение владения доменом можно будет использовать повторно 10 дней вместо 30 — certbot всё равно проверяет домен при каждом продлении.

Если продлением занимается certbot.timer, делать ничего не нужно. Мы выпустили сертификаты на 64 дня на тестовом сервере: certbot 2.9 и 4.0 показали VALID: 63 days, сочли их не требующими продления (Certificate not yet due for renewal), а сертификат с истекающим сроком certbot продлил и сам перезагрузил Nginx. Проверьте свои скрипты, если продление запускается по расписанию с жёстко заданным числом дней или раз в два-три месяца с --force-renewal, — такую схему лучше заменить таймером. Let's Encrypt советует продлевать примерно на двух третях срока и готовиться к сертификатам на 45 дней в 2028 году.

Шаг 9. Проверить всё с помощью curl

С вашего компьютера:

curl -sI http://example.com/ | grep -iE '^HTTP|^location'
curl -sI https://www.example.com/ | head -1
curl -sI https://api.example.com/ | head -1
curl -svo /dev/null https://example.com/ 2>&1 | grep -E 'expire date|issuer'
curl -si --http1.1 -m 3 https://example.com/ws/ \
  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
  -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' | head -1

Ожидаемо: переадресация 301 на https://, ответы HTTP/2 200, издатель сертификата — O=Let's Encrypt с датой окончания через 90 дней, а для WebSocket — HTTP/1.1 101 Switching Protocols. Последняя команда завершается по таймауту через 3 секунды: соединение WebSocket осталось открытым, это нормально.

Приложение должно видеть настоящий адрес посетителя и протокол. Проверьте в его журнале или выведите заголовки X-Forwarded-For и X-Forwarded-Proto: там должны быть ваш IP и https.

Если не работает

Что видно Что делать
502 Bad Gateway Приложение не запущено или слушает другой порт. Проверьте ss -ltnp | grep -E ':3000|:8000' и sudo tail /var/log/nginx/error.log
504 Gateway Time-out ровно через 60 секунд Приложение отвечает дольше proxy_read_timeout. Увеличьте его для сайта или для одного location
413 Request Entity Too Large Запрос больше client_max_body_size (по умолчанию 1 МБ). Увеличьте его в блоке server
WebSocket не подключается, приложение отвечает 400 или 426 Нет заголовков Upgrade и Connection или proxy_http_version 1.1. Проверьте, что в location подключён snippets/proxy.conf
WebSocket обрывается примерно через минуту тишины В этом location действует proxy_read_timeout 60s. Увеличьте его или настройте в приложении ping раз в 30 секунд
Запрос к /ws получает 301 location /ws/ с косой чертой. Подключайтесь к /ws/ или замените на location /ws
Приложение строит ссылки http:// или уходит в бесконечную переадресацию Приложение не доверяет X-Forwarded-Proto. Включите доверие к прокси: в Express — app.set('trust proxy', 'loopback'), в Django — SECURE_PROXY_SSL_HEADER
nginx -t: cannot load certificate "/etc/letsencrypt/live/…" Блок на 443 добавлен раньше, чем получен сертификат. Уберите его, получите сертификат, верните блок
certbot: Timeout during connect или unauthorized A-запись не указывает на сервер или закрыт порт 80. На компьютере — dig +short example.com, на сервере — sudo ufw status
certbot: too many certificates (5) already issued for this exact set of identifiers Сработал лимит Let's Encrypt: не больше 5 сертификатов на один набор доменов за 7 дней. Для опытов используйте --dry-run
На части страниц нет заголовков безопасности В этом location есть свой add_header. Повторите в нём include snippets/security-headers.conf;

Что дальше