Перейти к основному содержимому

nginx

Обзор​

nginx ставится перед панелью XLADA и решает три задачи: принимает трафик на портах 80 и 443, завершает TLS и передаёт запросы панели на 127.0.0.1:3000, подставляя заголовки, по которым панель понимает, что соединение защищено и кто настоящий клиент.

Проксирование обязательно, а не желательно: в коде панели и страницы подписки есть middleware, который разрывает соединение, если запрос пришёл без заголовков X-Forwarded-For и X-Forwarded-Proto: https. Поэтому перед установкой nginx панель уже должна быть поднята — см. Установка панели, а для второго хоста — Страница подписки.

Внимание

Вам понадобится зарегистрированный домен и доступ к его DNS-записям. Без домена сертификат не выпустить, а без HTTPS панель работать не будет.

DNS: направляем домен на сервер​

Узнайте внешний адрес сервера. Лучше, если это постоянный IPv4-адрес. Затем создайте записи у регистратора или у DNS-провайдера:

ТипИмяЗначениеНазначение
ApanelIPv4-адрес серверапанель и API
AAAApanelIPv6-адрес серверато же по IPv6, если он есть
AsubIPv4-адрес серверастраница подписки

Если панель должна жить на самом домене, укажите в поле имени @. Записи AAAA создавайте только тогда, когда IPv6 действительно настроен на сервере: иначе клиенты уйдут в никуда и получат ошибку соединения.

Обновление записей занимает от нескольких минут до нескольких часов. Проверить результат можно командой:

dig +short panel.example.com

Серое и оранжевое облако​

В панели управления Cloudflare у записи есть переключатель проксирования:

  • серое облако — запись отдаёт клиенту адрес вашего сервера напрямую; сертификат выпускается и продлевается на сервере, ограничения по IP работают как обычно;
  • оранжевое облако — трафик идёт через Cloudflare, клиент видит адреса Cloudflare, а не ваш. Это скрывает сервер и сглаживает всплески, но требует отдельной настройки реального IP клиента (см. раздел ниже) и не даёт выпускать сертификат проверкой по порту 80 без дополнительных условий.

Для панели обычно выбирают оранжевое облако и обязательно настраивают доверие к заголовку CF-Connecting-IP. Подробности — в разделе Cloudflare.

Сертификат​

certbot — основной путь​

certbot выпускает сертификат Let's Encrypt и продлевает его по своему таймеру. Сначала создайте полный HTTP-хост: он отдаёт проверку владения доменом и перенаправляет остальные запросы на HTTPS.

sudo mkdir -p /var/www/certbot
sudo mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled
sudo nano /etc/nginx/sites-available/xlada-bootstrap
/etc/nginx/sites-available/xlada-bootstrap
# Временный хост для выпуска сертификатов панели и страницы подписки.
server {
listen 80;
listen [::]:80;
server_name panel.example.com sub.example.com;

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

location / {
return 301 https://$host$request_uri;
}
}
sudo ln -sf /etc/nginx/sites-available/xlada-bootstrap /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

sudo apt-get update
sudo apt-get install -y certbot python3-certbot-nginx
sudo certbot --nginx -d panel.example.com
sudo certbot --nginx -d sub.example.com

Certbot выпустит отдельный сертификат для каждого домена: они лягут в /etc/letsencrypt/live/panel.example.com/ и /etc/letsencrypt/live/sub.example.com/. После выпуска удалите временный хост и включите полные конфигурации из разделов ниже: пути к сертификатам в них уже указаны.

sudo rm -f /etc/nginx/sites-enabled/xlada-bootstrap

Продление certbot выполняет сам. Проверьте его так:

sudo certbot renew --dry-run
systemctl list-timers | grep certbot

acme.sh — альтернатива​

Если certbot недоступен или вы предпочитаете один инструмент для нескольких серверов, используйте acme.sh с проверкой владения доменом по ALPN. Этот способ не зависит от веб-сервера, но требует свободного порта 8443.

sudo apt-get install -y cron socat
curl https://get.acme.sh | sh -s email=admin@example.com
source ~/.bashrc

sudo mkdir -p /opt/xlada/nginx && cd /opt/xlada/nginx

# Один сертификат сразу на оба домена: выпуск и закрепление путей
acme.sh --issue --standalone -d 'panel.example.com' -d 'sub.example.com' \
--key-file /opt/xlada/nginx/privkey.key \
--fullchain-file /opt/xlada/nginx/fullchain.pem \
--alpn --tlsport 8443 \
--reloadcmd "systemctl reload nginx"

# Закрепление путей и команды перезагрузки для автопродления
acme.sh --install-cert -d 'panel.example.com' \
--key-file /opt/xlada/nginx/privkey.key \
--fullchain-file /opt/xlada/nginx/fullchain.pem \
--reloadcmd "systemctl reload nginx"

# Приводим имена файлов к тем, что указаны в полных конфигурациях ниже
sudo mkdir -p /etc/letsencrypt/live/panel.example.com
sudo mkdir -p /etc/letsencrypt/live/sub.example.com
sudo ln -sf /opt/xlada/nginx/fullchain.pem /etc/letsencrypt/live/panel.example.com/fullchain.pem
sudo ln -sf /opt/xlada/nginx/privkey.key /etc/letsencrypt/live/panel.example.com/privkey.pem
sudo ln -sf /opt/xlada/nginx/fullchain.pem /etc/letsencrypt/live/panel.example.com/chain.pem
sudo ln -sf /opt/xlada/nginx/fullchain.pem /etc/letsencrypt/live/sub.example.com/fullchain.pem
sudo ln -sf /opt/xlada/nginx/privkey.key /etc/letsencrypt/live/sub.example.com/privkey.pem
sudo ln -sf /opt/xlada/nginx/fullchain.pem /etc/letsencrypt/live/sub.example.com/chain.pem

Порт 8443 должен быть открыт и не занят другим процессом. acme.sh продлевает сертификат сам примерно каждые 60 дней через cron — проверьте, что служба cron запущена.

Примечание

Если nginx работает в контейнере (см. ниже), в --reloadcmd вместо перезагрузки службы укажите перезагрузку внутри контейнера: docker exec xlada-nginx nginx -s reload.

Внимание

Учтите ограничение центра сертификации: доменные зоны .ru, .su и .рф не поддерживаются при выпуске через ZeroSSL, который acme.sh использует по умолчанию. Для таких доменов выбирайте certbot или другой центр сертификации.

Конфигурация nginx для панели​

Создайте файл виртуального хоста и включите его:

sudo nano /etc/nginx/sites-available/xlada-panel
sudo ln -sf /etc/nginx/sites-available/xlada-panel /etc/nginx/sites-enabled/
/etc/nginx/sites-available/xlada-panel
# Виртуальный хост панели XLADA.

upstream xlada_panel {
server 127.0.0.1:3000;
keepalive 32;
}

# Для WebSocket нужно значение upgrade, для обычных запросов — close.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}

# ─── HTTP: проверка владения доменом и редирект на HTTPS ─────────────
server {
listen 80;
listen [::]:80;
server_name panel.example.com;

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

location / {
return 301 https://$host$request_uri;
}
}

# ─── HTTPS ───────────────────────────────────────────────────────────
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name panel.example.com;

# Сертификат Let's Encrypt
ssl_certificate /etc/letsencrypt/live/panel.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/panel.example.com/privkey.pem;
ssl_trusted_certificate /etc/letsencrypt/live/panel.example.com/chain.pem;

# Протоколы и шифры: рекомендации Mozilla Intermediate
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;

# Сессии и проверка отзыва сертификата
ssl_session_timeout 1d;
ssl_session_cache shared:XLADA_TLS:10m;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 1.0.0.1 8.8.8.8 8.8.4.4 valid=60s;
resolver_timeout 2s;

# Заголовки безопасности. HSTS включайте, только убедившись, что HTTPS
# работает: браузер запомнит это требование надолго.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" 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;

# Размер тела запроса: с запасом для загрузки файлов через интерфейс
client_max_body_size 25m;
client_body_timeout 60s;

# Сжатие ответов
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_min_length 256;
gzip_types
application/atom+xml
application/geo+json
application/javascript
application/json
application/ld+json
application/manifest+json
application/rdf+xml
application/rss+xml
application/wasm
application/xhtml+xml
application/xml
font/otf
font/ttf
image/svg+xml
text/css
text/javascript
text/plain
text/xml;

# ─── Статика интерфейса: сборка Vite лежит в /assets/ ─────────────
# Имена файлов содержат хеш, поэтому их можно кэшировать надолго.
location ^~ /assets/ {
proxy_pass http://xlada_panel;
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 Connection "";

expires 365d;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}

# ─── API и потоковые ответы: без буферизации ──────────────────────
location /api/ {
proxy_pass http://xlada_panel;
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 10s;
proxy_send_timeout 86400s;
proxy_read_timeout 86400s;
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
}

# ─── Интерфейс панели ────────────────────────────────────────────
location / {
proxy_pass http://xlada_panel;
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;

# WebSocket: SSH-терминал к нодам и живые обновления интерфейса
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;

# Терминал держит соединение открытым часами
proxy_connect_timeout 10s;
proxy_send_timeout 86400s;
proxy_read_timeout 86400s;

proxy_buffering on;
}
}

Отдельный блок-заглушка закрывает запросы, пришедшие по IP-адресу или с чужим именем сервера: сертификат таким клиентам не отдаётся вовсе.

/etc/nginx/conf.d/00-default.conf
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
server_name _;

ssl_reject_handshake on;
}

Проверьте конфигурацию и перезагрузите nginx:

sudo nginx -t && sudo systemctl reload nginx

Ограничение доступа по IP​

В базовой конфигурации доступа по IP нет: панель защищена собственной аутентификацией, а страница подписки должна быть открыта всем клиентам. Если панель доступна только вам, используйте полный конфиг того же хоста с фильтром адресов: он приведён ниже и записывается в тот же файл /etc/nginx/sites-available/xlada-panel.

/etc/nginx/sites-available/xlada-panel
# Виртуальный хост панели XLADA с ограничением доступа по IP.

upstream xlada_panel {
server 127.0.0.1:3000;
keepalive 32;
}

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

# ─── HTTP: проверка владения доменом и редирект на HTTPS ─────────────
server {
listen 80;
listen [::]:80;
server_name panel.example.com;

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

location / {
return 301 https://$host$request_uri;
}
}

# ─── HTTPS только для доверенных адресов ─────────────────────────────
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name panel.example.com;

# Пускаем только доверенные адреса, остальным — 403
allow 203.0.113.0/24;
allow 198.51.100.7;
deny all;

ssl_certificate /etc/letsencrypt/live/panel.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/panel.example.com/privkey.pem;
ssl_trusted_certificate /etc/letsencrypt/live/panel.example.com/chain.pem;

ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;

ssl_session_timeout 1d;
ssl_session_cache shared:XLADA_TLS:10m;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 1.0.0.1 8.8.8.8 8.8.4.4 valid=60s;
resolver_timeout 2s;

add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" 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;

client_max_body_size 25m;
client_body_timeout 60s;

gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_min_length 256;
gzip_types
application/atom+xml
application/geo+json
application/javascript
application/json
application/ld+json
application/manifest+json
application/rdf+xml
application/rss+xml
application/wasm
application/xhtml+xml
application/xml
font/otf
font/ttf
image/svg+xml
text/css
text/javascript
text/plain
text/xml;

location ^~ /assets/ {
proxy_pass http://xlada_panel;
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 Connection "";

expires 365d;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}

location /api/ {
proxy_pass http://xlada_panel;
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 10s;
proxy_send_timeout 86400s;
proxy_read_timeout 86400s;
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
}

location / {
proxy_pass http://xlada_panel;
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 10s;
proxy_send_timeout 86400s;
proxy_read_timeout 86400s;

proxy_buffering on;
}
}

Такой фильтр ставьте только на хост панели. На хосте страницы подписки он сломает выдачу ссылок пользователям, а на API — вебхуки и внешние интеграции. Basic auth как второй слой неудобен: он ломает работу API-токенов и служебных запросов, поэтому вместо него полагайтесь на пароль администратора и TOTP.

nginx в контейнере​

Если nginx у вас тоже в Docker, включите для него host-режим сети: тогда контейнер видит те же 127.0.0.1:3000 и 127.0.0.1:3010, что и хост, и полные конфигурации из этой страницы подходят без единой правки.

/opt/xlada/nginx/docker-compose.yml
services:
xlada-nginx:
image: nginx:1.30
container_name: xlada-nginx
restart: unless-stopped
# host-режим: апстримы 127.0.0.1:3000 и 127.0.0.1:3010 доступны как на хосте
network_mode: host
volumes:
- /etc/nginx/sites-available/xlada-panel:/etc/nginx/conf.d/xlada-panel.conf:ro
- /etc/nginx/sites-available/xlada-subpage:/etc/nginx/conf.d/xlada-subpage.conf:ro
- /etc/nginx/conf.d/00-default.conf:/etc/nginx/conf.d/00-default.conf:ro
- /etc/letsencrypt:/etc/letsencrypt:ro
- /var/www/certbot:/var/www/certbot:ro
cd /opt/xlada/nginx
docker compose up -d
docker exec xlada-nginx nginx -t
docker exec xlada-nginx nginx -s reload

Обновление конфигурации сводится к правке файлов в /etc/nginx/sites-available/ и перезагрузке внутри контейнера:

docker exec xlada-nginx nginx -t && docker exec xlada-nginx nginx -s reload

Страница подписки​

Страница подписки работает на 127.0.0.1:3010 в контейнере xpanel-subpage и живёт на отдельном поддомене. Требования те же: HTTPS и заголовки X-Forwarded-For и X-Forwarded-Proto: https, иначе контейнер разрывает соединение. WebSocket здесь не нужен, а таймаут можно сделать короче.

/etc/nginx/sites-available/xlada-subpage
# Виртуальный хост страницы подписки XLADA.

upstream xlada_subpage {
server 127.0.0.1:3010;
keepalive 16;
}

# Странице подписки WebSocket не нужен, но заголовки оставлены:
# так конфиг остаётся рабочим, если приложение начнёт их использовать.
map $http_upgrade $subpage_connection_upgrade {
default upgrade;
'' close;
}

server {
listen 80;
listen [::]:80;
server_name sub.example.com;

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

location / {
return 301 https://$host$request_uri;
}
}

server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name sub.example.com;

ssl_certificate /etc/letsencrypt/live/sub.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/sub.example.com/privkey.pem;
ssl_trusted_certificate /etc/letsencrypt/live/sub.example.com/chain.pem;

ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;

ssl_session_timeout 1d;
ssl_session_cache shared:XLADA_TLS:10m;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 8.8.8.8 valid=60s;
resolver_timeout 2s;

add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

client_max_body_size 10m;

gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 256;
gzip_types
application/javascript
application/json
application/manifest+json
application/xml
font/otf
font/ttf
image/svg+xml
text/css
text/javascript
text/plain
text/xml;

# Статика сборки страницы подписки
location ^~ /assets/ {
proxy_pass http://xlada_subpage;
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 Connection "";

expires 365d;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}

location / {
proxy_pass http://xlada_subpage;
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;

# WebSocket: сейчас не используется, но конфиг готов к нему
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $subpage_connection_upgrade;

proxy_connect_timeout 10s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;

proxy_buffering on;
}
}

Включите хост и перезагрузите nginx:

sudo ln -sf /etc/nginx/sites-available/xlada-subpage /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot renew --dry-run

Если в панели задан CUSTOM_SUB_PREFIX, тот же путь нужно добавить в SUB_PUBLIC_DOMAIN — подробности в разделе Страница подписки. А в файле окружения самой страницы оставьте TRUST_PROXY=1, чтобы она доверяла одному хопу прокси и брала IP клиента из заголовка.

Реальный IP клиента​

Панель должна видеть настоящий адрес клиента, потому что от него зависят:

  • учёт устройств по HWID — привязка устройства и история обращений хранят адрес запроса (Лимит устройств);
  • журнал действий администратора: по адресу видно, кто и откуда вошёл (Безопасность панели);
  • защита входа от перебора: попытки входа считаются по адресу, и если все запросы приходят с одного адреса прокси, ограничение либо сработает на всех сразу, либо не сработает вовсе.

Если перед nginx стоит Cloudflare, обычный X-Forwarded-For бесполезен: Cloudflare передаёт адрес клиента в заголовке CF-Connecting-IP. Доверять этому заголовку можно только от подсетей Cloudflare — иначе любой желающий подставит в запрос чужой адрес.

/etc/nginx/conf.d/real-ip.conf
# Адрес клиента берём из заголовка Cloudflare.
# Список диапазонов Cloudflare время от времени меняется — сверяйтесь с ним.
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 103.22.200.0/22;
set_real_ip_from 103.31.4.0/22;
set_real_ip_from 141.101.64.0/18;
set_real_ip_from 108.162.192.0/18;
set_real_ip_from 190.93.240.0/20;
set_real_ip_from 188.114.96.0/20;
set_real_ip_from 197.234.240.0/22;
set_real_ip_from 198.41.128.0/17;
set_real_ip_from 162.158.0.0/15;
set_real_ip_from 104.16.0.0/13;
set_real_ip_from 104.24.0.0/14;
set_real_ip_from 172.64.0.0/13;
set_real_ip_from 131.0.72.0/22;

real_ip_header CF-Connecting-IP;
real_ip_recursive on;

Если перед nginx стоит ваш собственный балансировщик или второй прокси, доверяйте его подсети и обычному заголовку:

/etc/nginx/conf.d/real-ip.conf
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 192.168.0.0/16;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
Осторожно

Никогда не указывайте set_real_ip_from 0.0.0.0/0 и не доверяйте X-Forwarded-For от всего интернета. И не подменяйте X-Forwarded-Proto значением http вручную: панель откажется работать.

Проверка​

# 1. Синтаксис и перезагрузка
sudo nginx -t && sudo systemctl reload nginx

# 2. Редирект с HTTP на HTTPS — ожидаем 301
curl -sI http://panel.example.com | head -1

# 3. HTTPS: код ответа и заголовок HSTS
curl -sI https://panel.example.com | grep -Ei 'HTTP/|strict-transport'

# 4. Срок действия сертификата
echo | openssl s_client -connect panel.example.com:443 \
-servername panel.example.com 2>/dev/null | openssl x509 -noout -dates

# 5. Страница подписки — ожидаем 200 и заголовок profile-web-page-url
curl -sI https://sub.example.com/AbCdEf1234567890

Что смотреть, если что-то не работает:

sudo tail -f /var/log/nginx/error.log
sudo tail -f /var/log/nginx/access.log
sudo journalctl -u nginx -n 50 --no-pager
docker compose logs --tail 50 xpanel

Типичные признаки: 502 Bad Gateway — контейнер панели не запущен или слушает не тот порт; ошибки рукопожатия TLS — неверный путь к сертификату или истёкший срок; обрыв соединения сразу после запроса — потеряны заголовки X-Forwarded-For или X-Forwarded-Proto. В логе доступа адрес клиента должен совпадать с его настоящим адресом, а не с адресом Cloudflare.

Что дальше​