Nginx reverse proxy на Ubuntu: Docker-приложение, HTTPS и WebSocket

Защищённый Nginx reverse proxy направляет HTTPS-трафик к Docker-контейнерам на VPS

Nginx reverse proxy принимает запросы к домену, завершает HTTPS-соединение и передаёт трафик приложению, которое работает на внутреннем порту. Такая схема полезна для Laravel, Node.js, Python, Go и других сервисов в Docker: наружу открыты только порты 80 и 443, а приложение не обязано самостоятельно управлять сертификатами. На одном IP-адресе можно разместить несколько сервисов и направлять запросы по разным доменам.

В этом руководстве настроим Nginx на Ubuntu как обратный прокси для Docker-приложения на 127.0.0.1:3000, подключим домен, выпустим сертификат Let’s Encrypt через Certbot, добавим поддержку WebSocket и разберём ошибки 502, циклические редиректы и потерю IP-адреса клиента. Команды рассчитаны на современную Ubuntu с Docker Compose v2. Перед изменениями сохраните копии действующих конфигураций и выполняйте работу сначала на тестовом сервисе.

Как устроена схема Nginx reverse proxy

Публичный запрос проходит четыре этапа:

  1. DNS-запись домена указывает на публичный IP VPS.
  2. Nginx принимает HTTP или HTTPS-запрос на портах 80 и 443.
  3. Nginx выбирает блок server по имени домена и передаёт запрос локальному приложению.
  4. Приложение формирует ответ, а Nginx возвращает его пользователю.

Внешний клиент не должен обращаться к порту приложения напрямую. Если контейнер опубликован как 0.0.0.0:3000:3000, порт 3000 доступен через все сетевые интерфейсы, если его не блокирует внешний firewall. В стандартной bridge-сети Docker с режимом gateway nat порт следует привязать к loopback: 127.0.0.1:3000:80. Однако это не повод пропускать внешнюю проверку: документация Docker предупреждает об исторической доступности опубликованного loopback-порта из того же L2-сегмента, а в версиях 28.0.0–28.3.2 изоляция могла нарушаться после перезагрузки firewalld. Поэтому далее требуется Engine 28.3.3 или новее. Нестандартные параметры direct routing и gateway mode также меняют модель доступа.

Где разместить проект

Для сайтов, ботов и pet-проектов можно использовать Beget: виртуальный хостинг от 420 ₽/мес., VPS от 330 ₽/мес. и тестовый период.

Перейти на Beget →

Что нужно подготовить

  • VPS с Ubuntu и пользователем с правами sudo;
  • Docker Engine 28.3.3 или новее со стандартной bridge-сетью, либо отдельно проверенная сетевая политика;
  • работающее приложение или контейнер, отвечающий по HTTP;
  • домен или поддомен, например app.example.com;
  • A-запись на IPv4 сервера и, если используется IPv6, корректная AAAA-запись;
  • доступные извне TCP-порты 80 и 443;
  • резервная копия текущих файлов Nginx.

До выпуска сертификата проверьте DNS. Команды должны возвращать адрес именно этого VPS:

dig +short A app.example.com
dig +short AAAA app.example.com
curl -4 ifconfig.me
ip -6 addr show scope global

Версию серверной части Docker проверьте отдельно: версия клиента Compose не заменяет эту проверку.

docker version --format '{{.Server.Version}}'
docker network inspect bridge

Если AAAA-запись существует, но IPv6 на сервере не настроен, проверка домена может завершиться ошибкой. Исправьте запись, а не пытайтесь обойти проверку сертификата. Подробный разбор записей и команд есть в статье о DNS и диагностике через dig.

Закрываем Docker-порт от внешней сети

Сначала можно воспроизвести схему на официальном образе Nginx, который играет роль тестового приложения. В compose.yaml опубликуйте порт контейнера 80 только на loopback-интерфейсе:

services:
  app:
    image: nginx:1.28.0-alpine
    restart: unless-stopped
    ports:
      - "127.0.0.1:3000:80"

Для реального проекта замените образ и внутренний порт на свои. В production фиксируйте проверенный тег или digest образа, а не используйте latest.

После запуска убедитесь, что приложение отвечает локально и порт не слушает публичный интерфейс:

docker compose up -d
docker compose ps
curl -I http://127.0.0.1:3000/
sudo ss -lntp | grep ':3000'

В выводе ss ожидается 127.0.0.1:3000, а не 0.0.0.0:3000 и не [::]:3000. Затем с другого компьютера замените SERVER_IP на публичный адрес VPS: соединение с портом 3000 должно завершиться ошибкой, тогда как сайт через Nginx должен открываться.

curl --connect-timeout 3 http://SERVER_IP:3000/
curl -I https://app.example.com/

Если прямое соединение устанавливается, не продолжайте публикацию сервиса: проверьте версию Engine, docker network inspect, параметры daemon и сетевые правила хостинга. Не рассчитывайте только на UFW: правила публикации портов Docker обрабатываются через сетевой стек Docker и могут вести себя не так, как ожидается от обычного входящего трафика.

Если приложение ещё не развёрнуто, начните с руководства по деплою Docker-приложения на VPS. Для создания основы compose.yaml также доступен генератор Docker Compose.

Устанавливаем Nginx и проверяем firewall

sudo apt update
sudo apt install nginx
sudo systemctl enable --now nginx
sudo nginx -t
systemctl status nginx --no-pager

Если UFW активен, сначала убедитесь, что SSH уже разрешён, и только затем включайте профиль Nginx:

sudo ufw status verbose
sudo ufw allow 22/tcp  # замените 22 на фактический порт SSH
sudo ufw allow 'Nginx Full'
sudo ufw status numbered

Профиль Nginx Full открывает 80/tcp и 443/tcp. Не закрывайте SSH-сессию, пока не проверите новый вход в отдельном терминале. Для защиты административного доступа используйте рекомендации из руководства по безопасной настройке SSH.

Создаём конфигурацию reverse proxy

Для WebSocket понадобится переменная, которая корректно формирует заголовок Connection. Директива map разрешена в контексте http, поэтому создайте отдельный файл /etc/nginx/conf.d/websocket-map.conf:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

Затем создайте /etc/nginx/sites-available/app.example.com:

server {
    listen 80;
    listen [::]:80;

    server_name app.example.com;

    access_log /var/log/nginx/app.example.com.access.log;
    error_log  /var/log/nginx/app.example.com.error.log warn;

    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_connect_timeout 5s;
        proxy_read_timeout 60s;
        proxy_send_timeout 60s;
    }
}

Активируйте сайт символической ссылкой и проверьте синтаксис до перезагрузки конфигурации:

sudo ln -s /etc/nginx/sites-available/app.example.com 
  /etc/nginx/sites-enabled/app.example.com

sudo nginx -t
sudo systemctl reload nginx
curl -I http://app.example.com

Если ссылка уже существует, повторно создавать её не нужно. Не удаляйте default до проверки нового virtual host: сначала добейтесь корректного ответа по домену.

Готовый блок можно получить через генератор Nginx reverse proxy, но перед установкой всё равно замените домен и порт, проверьте nginx -t и сопоставьте заголовки с требованиями приложения.

Зачем нужны proxy_set_header

  • Host $host передаёт приложению имя запрошенного домена.
  • X-Real-IP передаёт адрес непосредственного клиента, увиденного Nginx.
  • X-Forwarded-For сохраняет цепочку прокси и добавляет адрес клиента.
  • X-Forwarded-Proto сообщает приложению исходную схему: HTTP или HTTPS.
  • Upgrade и Connection необходимы для переключения протокола WebSocket.

Само наличие заголовков не заставляет приложение им доверять. В Laravel, Express, Django и других фреймворках отдельно задаётся список доверенных прокси. Не включайте безусловное доверие ко всем источникам, если к backend можно обратиться в обход Nginx: злоумышленник сможет подставить X-Forwarded-For или X-Forwarded-Proto.

Обратите внимание на завершающий слеш у proxy_pass. В блоке location / варианты без URI обычно сохраняют исходный путь. При проксировании подпути, например location /api/, наличие или отсутствие URI в proxy_pass меняет правила замены пути. Это частая причина неожиданных 404, поэтому сверяйте поведение с документацией Nginx и тестируйте несколько реальных URL.

Подключаем HTTPS через Certbot

До запроса сертификата сайт должен отвечать по HTTP на порту 80. Сначала выясните, установлен ли Certbot и доступен ли snap:

command -v certbot || true
certbot --version 2>/dev/null || true
snap version

Если действующая версия Certbot уже обслуживает сертификаты, оставьте её и используйте sudo certbot --nginx -d app.example.com. Не устанавливайте параллельно вторую копию. Для новой установки официальная инструкция рекомендует большинству пользователей snap; на минимальной Ubuntu сначала может потребоваться sudo apt install snapd. При осознанной миграции с apt-пакета сохраните конфигурацию и следуйте официальной процедуре удаления старого пакета до установки snap.

sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/local/bin/certbot
sudo certbot --nginx -d app.example.com

Certbot проверит владение доменом, получит сертификат и предложит настроить Nginx. После этого выполните проверки:

sudo nginx -t
sudo systemctl reload nginx
curl -I https://app.example.com
sudo certbot certificates
sudo certbot renew --dry-run

renew --dry-run проверяет процедуру автоматического продления. Пакет Certbot устанавливает задачу продления через cron или systemd timer; её расположение зависит от способа установки. Проверить таймеры можно командой systemctl list-timers.

Не копируйте закрытый ключ сертификата в каталог проекта и не добавляйте /etc/letsencrypt в Docker-образ. Доступ к ключам должен быть ограничен. Для wildcard-сертификатов нужна DNS-проверка и API-учётные данные DNS-провайдера; такие токены нельзя публиковать в репозитории или передавать в команды через общедоступную историю.

WebSocket и длительные соединения

Заголовки Upgrade и Connection не передаются обратным прокси автоматически, потому что относятся к hop-by-hop заголовкам. Именно поэтому они добавлены явно. Для обычных HTTP-запросов карта устанавливает Connection: close, а при запросе обновления протокола — upgrade.

По умолчанию proxy_read_timeout равен 60 секундам между последовательными операциями чтения. Если WebSocket-сервер долго не передаёт данные, Nginx может закрыть соединение. Лучше настроить heartbeat или ping на уровне приложения. Увеличивать таймаут до нескольких часов без наблюдения за ресурсами не стоит.

Проверяйте WebSocket отдельным клиентом, а не только открытием главной страницы. В журнале браузера обычно видны коды 400, 403 или преждевременное закрытие соединения. Если приложение проверяет Origin, добавьте домен в его разрешённый список вместо отключения проверки.

Если у приложения есть WebSocket endpoint /ws, базовый handshake можно проверить командой ниже. Замените путь на фактический. Рабочий endpoint отвечает статусом 101 Switching Protocols; ответы 404, 400 или 403 означают, что нужно проверить маршрут, заголовки или политику Origin.

curl --http1.1 -i -N 
  -H 'Connection: Upgrade' 
  -H 'Upgrade: websocket' 
  -H 'Sec-WebSocket-Version: 13' 
  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' 
  https://app.example.com/ws

Загрузка больших файлов и потоковые ответы

Для загрузки файлов Nginx по умолчанию может ограничить размер тела запроса. Устанавливайте лимит только там, где он действительно нужен:

location /upload/ {
    client_max_body_size 50m;
    proxy_pass http://127.0.0.1:3000;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

Согласуйте лимит с приложением, PHP, облачным хранилищем и свободным местом на диске. Бесконечный лимит облегчает исчерпание ресурсов. Для Server-Sent Events и некоторых потоковых ответов может потребоваться отключение буферизации через proxy_buffering off, но не применяйте это глобально: буферизация обычно защищает медленный клиент от прямого влияния на backend.

Несколько приложений на одном VPS

Для второго сервиса создайте отдельный контейнер с другим локальным портом и отдельный файл Nginx:

# compose второго приложения
ports:
  - "127.0.0.1:4000:4000"

# второй virtual host
server {
    listen 80;
    listen [::]:80;
    server_name api.example.com;

    location / {
        proxy_pass http://127.0.0.1:4000;
        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;
    }
}

Nginx выбирает серверный блок по server_name. Для каждого домена создайте DNS-запись, проверьте HTTP и затем выпускайте сертификат. Не используйте один случайный default virtual host как универсальный приёмник: неизвестные имена лучше отклонять или обслуживать отдельным безопасным блоком без доступа к приложению.

Безопасный деплой изменений

Изменение конфигурации Nginx не требует остановки сайта. Последовательность для каждого изменения:

  1. сохранить копию рабочего файла;
  2. изменить конфигурацию;
  3. выполнить sudo nginx -t;
  4. при успешной проверке выполнить sudo systemctl reload nginx;
  5. проверить HTTP, HTTPS, WebSocket и журналы.

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

Диагностика 502 Bad Gateway и других ошибок

502 Bad Gateway

Nginx не может подключиться к upstream или получает некорректный ответ. Проверяйте по цепочке:

docker compose ps
curl -v http://127.0.0.1:3000/health
sudo ss -lntp | grep ':3000'
sudo tail -n 100 /var/log/nginx/app.example.com.error.log
docker compose logs --tail=100 app

Частые причины: контейнер остановлен, указан неверный порт, приложение слушает только другой интерфейс внутри контейнера, health endpoint падает, либо Nginx запущен в отдельном контейнере и 127.0.0.1 указывает уже на него самого. В полностью контейнерной схеме используйте общую Docker-сеть и имя сервиса, а не loopback.

Циклический редирект HTTP ↔ HTTPS

Приложение не доверяет прокси и считает входящий запрос HTTP, хотя пользователь подключился по HTTPS. Проверьте X-Forwarded-Proto $scheme и настройку trusted proxies во фреймворке. Не исправляйте цикл отключением HTTPS-проверок или жёсткой подменой всех URL.

В логах приложения виден только 127.0.0.1

Убедитесь, что Nginx передаёт X-Forwarded-For, а приложение принимает его только от доверенного reverse proxy. Если перед Nginx находится CDN или балансировщик, $remote_addr будет адресом этого узла. Для восстановления IP клиента нужна отдельная настройка real IP с доверенными диапазонами провайдера; нельзя доверять заголовку от любого адреса.

Certbot не проходит проверку

Проверьте A/AAAA-записи, доступность порта 80, firewall и правильность server_name. Выполните внешний запрос curl -I http://app.example.com/ и одновременно проверьте, что он появился именно в /var/log/nginx/app.example.com.access.log. Ошибки Certbot смотрите в /var/log/letsencrypt/letsencrypt.log. При закрытом по политике порте 80 используйте DNS validation, а не временно направляйте домен на чужой узел.

WebSocket отключается через минуту

Проверьте заголовки Upgrade/Connection, код ответа 101, proxy_read_timeout и heartbeat приложения. Увеличение таймаута маскирует проблему, если сервер вообще не поддерживает ping или соединение закрывает внешний балансировщик.

Логи и минимальный мониторинг

Минимальный набор проверок после деплоя:

curl -fsS https://app.example.com/
sudo nginx -t
systemctl is-active nginx
docker compose ps
sudo journalctl -u nginx --since '30 minutes ago'
sudo tail -n 100 /var/log/nginx/app.example.com.error.log

Если приложение предоставляет отдельный health endpoint, замените / на его фактический путь. Такой endpoint должен проверять готовность приложения, но не раскрывать версии, переменные окружения, строки подключения и другие секреты. Для внешнего мониторинга достаточно статуса и короткого безопасного ответа. Настройте ротацию журналов и отслеживайте свободное место: переполненный диск способен остановить и Nginx, и контейнеры.

Чек-лист перед открытием сервиса

  • DNS указывает на правильные IPv4 и IPv6-адреса;
  • backend опубликован только на 127.0.0.1 либо находится в закрытой Docker-сети, а внешний тест служебного порта завершился отказом;
  • публично доступны только необходимые порты;
  • nginx -t завершается успешно;
  • HTTP перенаправляется на HTTPS после выпуска сертификата;
  • certbot renew --dry-run проходит без ошибок;
  • приложение правильно доверяет только известным прокси;
  • WebSocket, загрузка файлов и реальные маршруты проверены отдельно;
  • логи не содержат токены, cookies и персональные данные;
  • есть резервная копия конфигурации и понятный откат.

Нужен VPS для тестового reverse proxy? Сначала разверните копию приложения на отдельном сервере и проверьте DNS, сертификат, закрытие backend-порта и восстановление конфигурации. Подойдёт любой VPS с публичным IP; один из вариантов — Beget. Это партнёрская ссылка, она не увеличивает стоимость для пользователя и не является гарантией результата.

FAQ

Нужно ли запускать Nginx в Docker

Не обязательно. Nginx на хосте проще интегрировать с системным Certbot и локальными портами контейнеров. Полностью контейнерная схема удобна для переносимости, но в ней нужны общая сеть, постоянные тома сертификатов и отдельный процесс продления. Не смешивайте две схемы без необходимости.

Можно ли проксировать несколько доменов на один контейнер

Да, перечислите домены в server_name и добавьте их в сертификат, если приложение корректно обслуживает все имена. Для независимых сайтов понятнее создавать отдельные server blocks и отдельные журналы.

Нужен ли proxy_http_version 1.1

Для WebSocket и совместимости с распространёнными версиями Nginx его лучше указывать явно. В новых версиях Nginx значение по умолчанию менялось, но конфигурация VPS часто использует пакет из репозитория дистрибутива, поэтому явная директива делает поведение предсказуемым.

Можно ли открыть backend-порт только через UFW

Лучше дополнительно привязать опубликованный порт Docker к 127.0.0.1, использовать актуальный Engine и проверить порт с внешнего узла. Это уменьшает зависимость от порядка правил firewall, но при старом Engine или нестандартном direct routing одна только loopback-привязка не считается достаточным доказательством изоляции.

Итог

Надёжная схема reverse proxy начинается не с большого файла Nginx, а с правильной границы доступа: контейнер слушает локальный порт, Nginx единолично принимает публичный трафик, сертификат автоматически продлевается, а приложение доверяет заголовкам только от известного прокси. Проверяйте DNS до Certbot, запускайте nginx -t перед каждым reload и диагностируйте 502 от backend к внешнему слою. Так конфигурация остаётся понятной, расширяется на несколько доменов и не открывает служебные порты наружу.

Официальная документация

Хотите системно изучить программирование?

Подборка онлайн-курсов для разработчиков — Python, Java, веб и DevOps.

Смотреть курсы →

Бесплатные инструменты

Калькулятор подсетей · SSH-конфиг · Nginx · Docker Compose

Бесплатное руководство «Безопасный Linux/VPS»

14 практических страниц: SSH, firewall, обновления, резервные копии, WordPress, Docker и действия при признаках взлома.

Получить PDF →

Нужен аудит или безопасная настройка Linux/VPS?

Проверим SSH, firewall, обновления, резервные копии, Docker и журналы. Аудит — от 9 900 ₽.

Узнать состав и стоимость → 8 918 532 81 08

Статья может содержать партнёрские ссылки. Это не влияет на стоимость для вас, но помогает развивать блог.