PasarGuard на двух серверах: панель, нода и VLESS + Reality от установки до рабочего подключения

Это сквозной гайд по развёртыванию PasarGuard — форка Marzban, панели управления Xray-ядром с подписками, статистикой и вынесенными на отдельные машины нодами. Мы поднимем панель на одном сервере, ноду на втором, свяжем их по gRPC, закроем панель боевым сертификатом Let’s Encrypt через Caddy и настроим не игрушечный Shadowsocks, а актуальный VLESS + Reality — с маскировкой под чужой TLS. В конце у нас будет пользователь, чью ссылку-подписку можно открыть в клиенте и подключиться.

Почти всё, что можно, делается через веб-панель — я показываю конкретные страницы и формы на скриншотах. В командную строку уходим только там, где иначе никак: установка Docker, генерация сертификата ноды, ключей Reality и запуск контейнеров. Всё снято с живого стенда: панель v5.2.1, нода v0.5.3, ядро Xray 26.3.27. Каждая ошибка в тексте — реальный лог, а не пересказ.

По ходу дела разберём восемь мест, где установка спотыкается:

  1. официальный docker-compose.yml тянет образы из Docker Hub, который отдаёт 429;
  2. формат .env из репозитория ломает докеровский парсер переменных;
  3. панель без TLS слушает только localhost — снаружи не достучаться;
  4. базовый образ Caddy не умеет Cloudflare и не стартует с DNS-01;
  5. статический админ из переменных окружения в свежих версиях запрещён;
  6. CLI спрятан не там, где его ищешь;
  7. сертификат ноды: без файла — вечный рестарт, без SAN на IP — отказ панели;
  8. в поле Certificate карточки ноды легко положить не тот сертификат. Почти ни одна из них не объясняется в интерфейсе: единственное исключение — запрет env-админа, и то без подсказки, что делать дальше. Остальное диагностируется только через docker logs.
Note:

Домен в примерах — pg-test.gig.ovh. IP серверов на скриншотах заменены на адреса из документационного диапазона (203.0.113.10 — нода, 198.51.100.10 — панель). Подставляйте свои. Приватный ключ Reality, api_key ноды и UUID пользователя в тексте даны плейсхолдерами — генерируйте собственные.

Архитектура

Панель и нода живут на разных машинах, и это не прихоть. Панель — оркестратор: хранит пользователей, ядра, хосты, генерирует подписки и собирает статистику. Нода — «руки»: на ней крутится Xray, который принимает клиентов. Между ними — управляющий канал gRPC, зашифрованный самоподписанным сертификатом.

  • Сервер A (панель): PasarGuard panel + TimescaleDB + Caddy (reverse-proxy и TLS). Наружу — 80/443 и SSH.
  • Сервер B (нода): PasarGuard node + Xray. Принимает управляющее соединение от панели на 62050 и клиентов VLESS Reality на 443.

Требования к железу

Стенд собран на двух почти одинаковых VPS. Реальные цифры со стенда — это тот минимум, на котором всё работает без свопа при одной ноде и горстке пользователей.

Параметр Сервер A (панель) Сервер B (нода)
ОС Ubuntu 26.04 LTS Ubuntu 26.04 LTS
Ядро 7.0.0-14-generic 7.0.0-14-generic
Архитектура x86_64 (KVM) x86_64 (KVM)
vCPU 2 2
ОЗУ 3.3 ГБ 3.3 ГБ
Диск 99 ГБ (занято ~5 ГБ) 79 ГБ (занято ~4 ГБ)

Самый прожорливый компонент на панели — не панель, а TimescaleDB: её образ весит около 2 ГБ, а процесс в покое держит несколько сотен мегабайт. На SQLite под панель хватило бы и 1 ГБ ОЗУ, но тогда из панели пропадает статистика по нодам (об этом ниже). Под TimescaleDB закладывайте 2 ГБ как рабочий минимум.

Таблица портов

Порт Где Наружу? Назначение
22 A, B да (лучше ограничить) SSH
80 A да HTTP → редирект на HTTPS (для ACME не нужен: валидация по DNS-01)
443 A да HTTPS: панель и подписки
8000 A нет (только localhost) uvicorn панели за Caddy
5432 A нет (127.0.0.1) TimescaleDB
62050 B только с IP панели gRPC: управляющий канал панель↔нода
443 B да клиентский инбаунд VLESS Reality

Обратите внимание: 443 занят на обоих серверах, но это разные машины — на A его слушает Caddy, на B — Xray. Конфликта нет.

Почему TimescaleDB, а не SQLite

PasarGuard из коробки умеет SQLite, и для «панель плюс одна локальная нода» этого хватает. Но как только ноды выносятся на отдельные серверы и хочется видеть графики по каждой, всплывает ограничение прямо из .env.example:

## Usage recording. Node stats recording is only available for PostgreSQL/TimescaleDB.
# ENABLE_RECORDING_NODES_STATS = False

Запись статистики по нодам работает только на PostgreSQL/TimescaleDB. На SQLite флаг ни на что не влияет, и раздел статистики по узлам исчезает. Поэтому база — TimescaleDB (PostgreSQL с расширением для временных рядов, что идеально ложится на поминутную статистику трафика).

Important:

Выбор базы делается до первого запуска. Мигрировать SQLite → PostgreSQL на живой панели — отдельная боль. Если есть шанс, что нод будет больше одной, сразу берите TimescaleDB.

Что нужно приготовить заранее

Чтобы не спотыкаться посреди установки, соберите всё это до первой команды:

  • два VPS с root-доступом (характеристики — в таблице выше) и их публичные IP: PANEL_IP для сервера A, NODE_IP для сервера B;
  • домен для панели (в примерах pg-test.gig.ovh), делегированный на NS Cloudflare — DNS-01 работает через её API;
  • аккаунт Cloudflare и API-токен с правами Zone:DNS на вашу зону (как его сделать — в разделе про Caddy);
  • проверьте, что у провайдера/в облачном файрволе не закрыты нужные порты из таблицы выше.

A-запись на домен удобно завести сразу же — пока вы ставите Docker и поднимаете панель, она успеет разойтись по DNS.

Подготовка обоих серверов

На чистой Ubuntu ставим Docker официальным скриптом — он сам подключает репозиторий и ставит движок с плагином compose. Команды одинаковы для A и B:

# от root
curl -fsSL https://get.docker.com | sh
docker --version          # Docker version 29.7.2
docker compose version    # Docker Compose version v5.5.0

Убедитесь, что работает docker compose — через пробел, это плагин, а не старый самостоятельный docker-compose. Весь гайд построен на нём; версии в выводе выше — те, что были на стенде, у вас числа могут отличаться.

Сервер A: база и панель

Создаём каталог и генерируем секреты — пароль базы и api_key (общий секрет между нодой и панелью) удобно сгенерировать один раз:

mkdir -p /opt/pasarguard && cd /opt/pasarguard
DB_PASS=$(openssl rand -hex 24)
API_KEY=$(cat /proc/sys/kernel/random/uuid)   # обязан быть валидным UUID
printf 'DB_PASS=%s\nAPI_KEY=%s\n' "$DB_PASS" "$API_KEY" > .secrets && chmod 600 .secrets

Посмотреть значения в любой момент — cat /opt/pasarguard/.secrets. Дальше по тексту они встречаются как плейсхолдеры, и их нужно подставлять руками:

  • DB_PASS — в .env панели и в docker-compose.yml (в двух местах: пароль базы и строка подключения);
  • API_KEY — понадобится позже, на сервере B и в карточке ноды. Забрать его оттуда: grep API_KEY /opt/pasarguard/.secrets.

Чтобы не запутаться, вот все значения гайда разом — где рождаются и куда подставляются. Держите эту таблицу под рукой:

Значение Где берётся Куда подставляется
PANEL_IP публичный IP сервера A A-запись домена, правило ufw на ноде
NODE_IP публичный IP сервера B карточка ноды, адрес хоста, SAN сертификата ноды
DB_PASS openssl rand -hex 24 на сервере A .env панели и docker-compose.yml базы
API_KEY UUID на сервере A (.secrets) docker-compose.yml ноды и поле API Key в карточке ноды
домен ваш, на NS Cloudflare Caddyfile, VITE_BASE_API, ALLOWED_ORIGINS
CF_API_TOKEN Cloudflare → API Tokens docker-compose.yml Caddy
PRIVATE_KEY / PUBLIC_KEY docker exec pg-node xray x25519 приватный — в инбаунд Core Config; публичный панель сама подставит в ссылку
shortId openssl rand -hex 8 инбаунд Core Config (shortIds)

Формат .env, из-за которого падает --env-file

Официальный .env.example написан «с пробелами вокруг равно и значениями в кавычках»: UVICORN_HOST = "0.0.0.0". Python-овский dotenv, который использует панель, такой формат ест. А докеровский парсер переменных — нет; нагляднее всего это видно на docker run --env-file:

Error:
docker: invalid env file (/opt/pasarguard/.env): variable 'UVICORN_HOST ' contains whitespaces

Заметьте 'UVICORN_HOST ' с хвостовым пробелом — парсер считает пробел частью имени. Через env_file: в compose спотыкание то же самое, только текст ошибки чуть другой. Надёжнее сразу писать .env без пробелов, в формате KEY=value. Полный .env панели с комментариями:

# ==== PasarGuard panel .env (чистый KEY=value, без пробелов) ====

# Слушать только localhost: наружу панель выставляет Caddy.
UVICORN_HOST=127.0.0.1
UVICORN_PORT=8000

# Подключение к базе. Драйвер asyncpg идёт в образе панели.
SQLALCHEMY_DATABASE_URL=postgresql+asyncpg://pasarguard:DB_PASS@127.0.0.1:5432/pasarguard

# Статистика по нодам — только на PostgreSQL/TimescaleDB. Ради неё и взяли Timescale.
ENABLE_RECORDING_NODES_STATS=True
# Двойное отрицание: False = учёт трафика по нодам включён.
DISABLE_RECORDING_NODE_USAGE=False

# Путь подписки: ссылки будут вида https://домен/sub/<token>.
# Формат намеренно разный: здесь — сегмент без слэшей, ниже — путь со слэшами.
SUBSCRIPTION_PATH=sub

# Путь дашборда (по нему открывается веб-интерфейс).
DASHBOARD_PATH=/dashboard/

LOG_LEVEL=INFO

# ==== работа за reverse-proxy Caddy ====
# Домен поднимем следующим шагом, но ключи задаём сразу — тогда панель
# стартует уже готовой к работе за прокси, и её не придётся перезапускать.
UVICORN_PROXY_HEADERS=True                 # доверять заголовкам X-Forwarded-* от Caddy
UVICORN_FORWARDED_ALLOW_IPS=127.0.0.1      # Caddy стоит на том же хосте
VITE_BASE_API=https://pg-test.gig.ovh/     # на каком домене фронтенд ищет API
ALLOWED_ORIGINS=https://pg-test.gig.ovh    # CORS

docker-compose.yml: образы из ghcr, а не из Hub

В официальном compose образ прописан как pasarguard/panel:latest — это Docker Hub, который на пуллах регулярно отдаёт 429 Too Many Requests. Рабочее зеркало — GitHub Container Registry: ghcr.io/pasarguard/panel и ghcr.io/pasarguard/node. Теги я фиксирую конкретными версиями, а не latest — почему, разберём в разделе про обновление. Полный compose для сервера A:

services:
  timescaledb:
    image: timescale/timescaledb:latest-pg17
    container_name: pasarguard-db
    restart: always
    environment:
      POSTGRES_DB: pasarguard
      POSTGRES_USER: pasarguard
      POSTGRES_PASSWORD: DB_PASS
    volumes:
      - /var/lib/pasarguard-db/pgdata:/var/lib/postgresql/data   # отдельно от состояния панели
    ports:
      - "127.0.0.1:5432:5432"     # только localhost
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U pasarguard -d pasarguard"]
      interval: 5s
      timeout: 5s
      retries: 20

  pasarguard:
    image: ghcr.io/pasarguard/panel:v5.2.1     # версия со стенда; про :latest — в разделе про обновление
    container_name: pasarguard
    restart: always
    env_file: .env
    network_mode: host
    volumes:
      - /var/lib/pasarguard:/var/lib/pasarguard
    depends_on:
      timescaledb:
        condition: service_healthy

Оба файла кладём в /opt/pasarguard: конфиг — как .env, compose — как docker-compose.yml. Поднимаем одной командой: за очерёдность отвечает depends_on с condition: service_healthy — compose сам дождётся, пока база станет healthy, и только потом запустит панель.

cd /opt/pasarguard
docker compose up -d
docker logs -f pasarguard

Панель без TLS слушает только localhost

В логах после серии alembic-миграций всплывёт предупреждение, которое пугает новичков, но ожидаемо:

Warning:
IMPORTANT!
You're running PasarGuard without specifying UVICORN_SSL_CERTFILE and UVICORN_SSL_KEYFILE.
The application will only be accessible through localhost...
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)

Это не ошибка — это ровно то, что нам нужно. Панель слушает 127.0.0.1:8000, а наружу её и подписки отдаёт Caddy, терминируя TLS. Именно поэтому в .env стоит UVICORN_HOST=127.0.0.1. Рядом будет Attention: You have no node — ноду добавим позже.

Caddy: TLS для панели и доступ по домену

Панель работает, но пока только на localhost. Ставим перед ней Caddy — он получит сертификат Let’s Encrypt и будет терминировать TLS. Валидацию делаем через DNS-01 (Cloudflare): не нужен открытый 80-й для ACME.

Первая тонкость: обычный образ caddy:2-alpine собран без DNS-модулей, и на директиве dns cloudflare он не стартует — конфиг просто не загружается. Поэтому берём сборку ghcr.io/caddybuilds/caddy-cloudflare:latest, в которой dns.providers.cloudflare уже вкомпилирован; её и прописываем в compose ниже.

Вторая тонкость: домен должен быть делегирован на NS Cloudflare — DNS-01 работает через её API. В зоне заводим A-запись pg-test.gig.ovh → PANEL_IP в режиме DNS only (серое облако), чтобы TLS терминировался на Caddy, а не на стороне Cloudflare.

Токен для Caddy делается там же: My Profile → API Tokens → Create Token, шаблон Edit zone DNS, в Zone Resources выбираем свою зону, жмём Create и копируем значение — показывается оно один раз.

Caddyfile

Caddy — отдельный стек, поэтому заводим ему свой каталог; в него положим два файла, Caddyfile и docker-compose.yml:

mkdir -p /opt/caddy && cd /opt/caddy

Домена и токена Cloudflare достаточно. Указываем reverse-proxy на панель и просим TLS через DNS-01 — Caddy сам сходит в Let’s Encrypt за настоящим, доверенным браузерами сертификатом:

{
    email admin@gig.ovh
}

pg-test.gig.ovh {
    reverse_proxy 127.0.0.1:8000
    tls {
        dns cloudflare {env.CF_API_TOKEN}
        resolvers 1.1.1.1
    }
}

docker-compose.yml для Caddy (токен Cloudflare — через переменную окружения):

services:
  caddy:
    image: ghcr.io/caddybuilds/caddy-cloudflare:latest
    container_name: caddy
    restart: always
    network_mode: host
    environment:
      CF_API_TOKEN: "CLOUDFLARE_TOKEN"   # токен с правами Zone:DNS на вашу зону
    volumes:
      - /opt/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
      - /var/lib/caddy/data:/data
      - /var/lib/caddy/config:/config

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

cd /opt/caddy && docker compose up -d
docker logs -f caddy
# http.acme_client  trying to solve challenge  challenge_type=dns-01
# http.acme_client  authorization finalized  authz_status=valid
# tls.obtain  certificate obtained successfully

Пока Caddy решает челлендж, в зоне Cloudflare на несколько секунд появляется TXT-запись _acme-challenge.pg-test.gig.ovh и после валидации убирается — так и работает DNS-01. Смотрим, что за сертификат он выдаёт (команду можно выполнить и с домашней машины — тогда это будет проверка по-настоящему снаружи):

echo | openssl s_client -connect pg-test.gig.ovh:443 -servername pg-test.gig.ovh 2>/dev/null \
  | openssl x509 -noout -issuer -subject -dates
# issuer=C=US, O=Let's Encrypt, CN=YE2
# subject=CN=pg-test.gig.ovh
# notBefore=... notAfter=... (90 дней)

Ключи для работы за прокси (UVICORN_PROXY_HEADERS, VITE_BASE_API и остальные) мы задали в .env ещё на старте, поэтому перезапускать панель не нужно — домен сразу отдаёт дашборд:

curl -s -o /dev/null -w "%{http_code}\n" https://pg-test.gig.ovh/dashboard/   # 200

Всё: панель доступна по https://pg-test.gig.ovh с валидным сертификатом. Дальше работаем прямо в браузере.

Заодно закроем и сервер A. Панель и база слушают только localhost (UVICORN_HOST=127.0.0.1 и бинд 127.0.0.1:5432), так что наружу они и так не смотрят, но файрвол — полезный второй рубеж:

ufw allow 22/tcp
ufw allow 80,443/tcp
ufw --force enable

У Let’s Encrypt есть лимит на неудачные выпуски — пять на домен в час. Если упёрлись, не перезапускайте Caddy по кругу: причина видна в docker logs caddy (чаще всего неверный токен Cloudflare или запись, не доехавшая до DNS), и повторить попытку лучше через час.

Создание владельца: SUDO_USERNAME больше не работает

Первый инстинкт — прописать в .env пару SUDO_USERNAME/SUDO_PASSWORD и войти. В 5.2.1 это даёт отлуп на логине:

Error:
env admin not allowed in production

Статический админ из переменных окружения в проде запрещён намеренно. Владелец создаётся одноразовым временным ключом через CLI. И тут вторые грабли: бинарника pasarguard в PATH нет, cli_wrapper.sh не исполняемый. Рабочий вызов — только напрямую через Python внутри контейнера, рабочая директория /code:

Ключ живёт 5 минут и одноразовый, поэтому порядок такой: сначала открываем форму, потом генерируем.

Панель уже доступна по домену — заходим на https://pg-test.gig.ovh/dashboard/, на странице логина жмём Owner access и переключаемся в режим Create. Форма попросит temp key, имя и пароль владельца. Теперь генерируем ключ и сразу вставляем его в открытую форму:

docker exec pasarguard python /code/pasarguard-cli.py generate-temp-key
# Temp key: 3e2a706a-....
# This key is valid for 5 minutes and can only be used once.

Не успели — не беда: сгенерируйте новый, повторно один и тот же ключ не сработает.

После входа открывается дашборд — отсюда и дальше почти всё делается мышкой.

Обзор панели: что за что отвечает

Прежде чем настраивать, стоит понять карту интерфейса. Меню слева — это и есть весь PasarGuard. Пройдёмся по каждому пункту сверху вниз.

Dashboard — стартовый экран со сводкой: сколько пользователей онлайн, сколько активно, суммарный трафик, состояние нод. Ничего не настраивается, только мониторинг «одним взглядом».

Users — главный рабочий раздел. Здесь создаются и живут клиенты: у каждого свои лимит трафика, срок действия, статус (active/disabled/limited/expired), привязка к группам и, по кнопкам в строке, ссылка-подписка и QR. Отсюда же выдаётся и отзывается доступ. 90% ежедневной работы админа — именно тут.

Statistics — графики по трафику: в разрезе пользователей и в разрезе нод. Тот самый раздел, который работает только на PostgreSQL/TimescaleDB — на SQLite он пустой. По нему видно, кто и сколько прокачал и как распределяется нагрузка между узлами.

Так этот раздел выглядит на уже работающем стенде. У вас он пока пуст — наполнится после того, как подключим ноду и заведём первого пользователя.

Hosts — «витрина» подключений. Инбаунд на ноде (например, VLESS Reality) сам по себе не превращается в ссылку клиента: нужен хост, который говорит «этот инбаунд доступен по такому-то адресу и порту, с такими SNI/отпечатком». Один инбаунд может отдавать несколько хостов (разные адреса, домены-обёртки, приоритеты). Подписка пользователя собирается именно из хостов.

Groups — связующее звено между инбаундами и пользователями. Группа — это набор инбаундов (по тегам). Пользователь состоит в одной или нескольких группах и получает конфиги всех инбаундов из них. Удобно раздавать «тариф»: группа «full» со всеми протоколами, группа «trial» с одним.

Admins и Admin Roles — многопользовательская администрация. Можно завести отдельных админов (например, реселлеров), а ролями ограничить, что им видно и можно: свой лимит трафика, запрет на чужих пользователей, права на ноды и так далее. Владелец (owner) — суперадмин над всем этим.

API Keys — ключи для программного доступа к тем же операциям, что и в UI: автоматизация выдачи пользователей, интеграция с биллингом, боты. Каждому ключу можно наследовать права роли.

Nodes — управление узлами и ядром. Внутри четыре вкладки:

  • Nodes — список нод, их статус (зелёный/красный), версии ноды и ядра, добавление/редактирование узлов.
  • Core Configs — конфигурации Xray-ядра: именно здесь заводятся инбаунды (VLESS, VMess, Trojan, Shadowsocks, Reality и т.д.), исходящие, маршрутизация. Конфиг применяется к назначенным нодам.
  • WireGuard — управление WireGuard-подсетями и пирами, если используете WG наравне с Xray.
  • Logs — живые логи ядра с ноды прямо в панели, удобно для отладки инбаундов.

Templates — шаблоны: страницы подписки, наборы настроек пользователя по умолчанию, шаблоны конфигов. Позволяют не заполнять одно и то же руками при массовой выдаче.

Bulk — массовые операции над пользователями: продлить срок, сбросить трафик, включить/выключить пачку аккаунтов разом.

Settings — общие настройки панели: подписки, вебхуки/уведомления, оформление, служебные параметры. Часть того, что раньше держали в .env, вынесена сюда.

Держа эту карту в голове, дальше мы пройдём по ней в логичном порядке: сначала подключим ноду (Nodes), затем на ней настроим протокол (Core Configs), выставим подключение наружу (Hosts), объединим в тариф (Groups) и выдадим клиенту (Users).

Сервер B: сертификат, без которого нода не стартует

Нода общается с панелью по gRPC, канал шифруется TLS, и сертификат нужен до первого старта — иначе контейнер уходит в вечный рестарт:

Error:
open /var/lib/pg-node/certs/ssl_cert.pem: no such file or directory

Сначала сертификат, потом контейнер. И здесь — самая коварная ошибка установки. Если сгенерировать самоподписанный сертификат «как обычно», без Subject Alternative Name, панель при подключении отвергнет ноду:

Error:
[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed:
IP address mismatch, certificate is not valid for 'NODE_IP'

Панель проверяет, что сертификат выписан на IP ноды, и смотрит SAN с IP-адресом, а не CN. Правильная команда добавляет subjectAltName=IP:...:

mkdir -p /var/lib/pg-node/certs && cd /var/lib/pg-node/certs
openssl req -x509 -newkey rsa:2048 -keyout ssl_key.pem -out ssl_cert.pem -days 3650 -nodes \
  -subj "/CN=NODE_IP" -addext "subjectAltName=IP:NODE_IP"

openssl x509 -in ssl_cert.pem -noout -text | grep -A1 "Subject Alternative Name"
# X509v3 Subject Alternative Name:
#     IP Address:NODE_IP

docker-compose.yml ноды

Образ — снова из ghcr. API_KEY — тот самый UUID, что мы сгенерировали на панели: это общий секрет ноды и панели, он же пойдёт в карточку ноды. Забрать его можно на сервере A командой grep API_KEY /opt/pasarguard/.secrets — подставьте значение вместо плейсхолдера. Нода работает в сети хоста и требует NET_ADMIN.

Заводим каталог и кладём в него docker-compose.yml:

mkdir -p /opt/pg-node && cd /opt/pg-node
services:
  node:
    image: ghcr.io/pasarguard/node:v0.5.3      # версия со стенда
    container_name: pg-node
    restart: always
    network_mode: host
    cap_add:
      - NET_ADMIN
    environment:
      SERVICE_PORT: 62050
      SERVICE_PROTOCOL: "grpc"
      SSL_CERT_FILE: "/var/lib/pg-node/certs/ssl_cert.pem"
      SSL_KEY_FILE: "/var/lib/pg-node/certs/ssl_key.pem"
      API_KEY: "API_KEY"          # валидный UUID, общий с панелью
      GENERATED_CONFIG_PATH: "/var/lib/pg-node/generated"
    volumes:
      - /var/lib/pg-node:/var/lib/pg-node

Поднимаем и проверяем логи:

docker compose up -d
docker logs pg-node
# Starting Node: v0.5.3
# gRPC Server listening on 0.0.0.0:62050

Строку Failed to load env file ... open .env в самом верху можно игнорировать: переменные переданы через environment:, файл .env ноде не нужен.

Файрвол: 62050 — только для панели

Управляющий порт не должен торчать в мир. Открываем 62050 только с IP панели. SSH разрешаем до включения ufw, иначе можно отрезать себе доступ:

ufw allow 22/tcp
ufw allow from PANEL_IP to any port 62050 proto tcp   # управляющий канал — только с панели
ufw allow 443/tcp                                     # клиентский инбаунд VLESS Reality, настроим дальше
ufw --force enable
ufw status numbered
[ 1] 22/tcp        ALLOW IN    Anywhere
[ 2] 62050/tcp     ALLOW IN    PANEL_IP
[ 3] 443/tcp       ALLOW IN    Anywhere

Политика ufw по умолчанию — deny incoming, поэтому отдельное запрещающее правило для 62050 не нужно: всё, что не разрешено явно, отбрасывается ещё на SYN. Панель по своему IP проходит, остальные — нет.

Security:

Проверьте с посторонней машины: nc -zv NODE_IP 62050 должен таймаутиться, а с панели — отвечать succeeded. Управляющий канал — это полный доступ к ядру ноды, в открытый интернет его выставлять нельзя.

Добавление ноды в панель (вкладка Nodes)

Теперь в панели идём в Nodes → Nodes и жмём Create Node. Форма простая, но одно поле — с сюрпризом. Здесь всплывает последняя ошибка связки, и она видна только в логах панели: в интерфейсе при неверном сертификате просто загорается красная точка без пояснений.

Поля формы:

Поле Значение Пояснение
Node Name test-node-1 произвольное имя
Node Address NODE_IP публичный IP сервера B
Node Port 62050 gRPC-порт ноды
Core Configuration Default Core Config конфиг ядра; панель создаёт его сама при первом запуске, он уже есть в списке
API Key API_KEY тот же UUID, что в переменных ноды
Certificate содержимое ssl_cert.pem с ноды целиком, вместе с BEGIN/END

В поле Certificate (в API это server_ca) кладётся сертификат с ноды целиком. Если положить туда не тот сертификат — например, сгенерировать ещё один на панели — подключение отвалится, и только в docker logs pasarguard вы увидите причину:

Error:
certificate verify failed: self-signed certificate

Забрать сертификат с сервера B: cat /var/lib/pg-node/certs/ssl_cert.pem. Успешное подключение в логах панели выглядит так:

Node-operation - New node "test-node-1" with id "1" added by admin "admin"
Node-operation - Connecting to "test-node-1" node
Node-operation - Connected to "test-node-1" node v0.5.3, core run on v26.3.27

А в интерфейсе на вкладке Nodes статус становится зелёным, появляются версии ноды (0.5.3) и ядра (26.3.27):

Если статус красный — не гадайте по интерфейсу, сразу смотрите docker logs pasarguard.

VLESS + Reality: протокол, который выглядит как чужой сайт

Дефолтный Shadowsocks годится для «проверить, что вообще работает», но в 2026-м это не выбор для боевого прокси. Актуальный стандарт на Xray — VLESS + Reality. Его идея: нода не поднимает собственный TLS с собственным сертификатом (который выдаёт факт наличия прокси), а на этапе рукопожатия притворяется чужим сайтом — «одалживает» TLS у реального внешнего хоста (например, www.microsoft.com). Для любого сканера порт 443 на ноде неотличим от настоящего Microsoft: тот же сертификат, та же цепочка. Своего домена и сертификата для клиента не нужно вовсе.

Шаг 1. Генерируем ключи Reality

Reality использует пару ключей x25519 и короткий идентификатор shortId. Ключи генерирует само ядро Xray прямо в контейнере ноды:

docker exec pg-node xray x25519
# PrivateKey: <PRIVATE_KEY>
# Password (PublicKey): <PUBLIC_KEY>
# Hash32: ...

openssl rand -hex 8    # shortId, например 86d173896823210b

PrivateKey пойдёт в конфиг инбаунда на ноде, PublicKey (pbk) — в клиентскую ссылку (панель подставит его сама). Сохраните обе половины.

Шаг 2. Добавляем инбаунд в Core Config

Идём в Nodes → Core Configs и открываем Default Core Config. В редакторе переключаемся в JSON-режим кнопкой {} и добавляем в массив inbounds блок ниже, заменив <PRIVATE_KEY> и shortIds своими значениями из предыдущего шага. Дефолтный Shadowsocks-инбаунд можно оставить (в группу мы его не включим, значит клиентам он не попадёт) или удалить — на Reality это не влияет.

{
  "tag": "VLESS Reality",
  "listen": "0.0.0.0",
  "port": 443,
  "protocol": "vless",
  "settings": { "clients": [], "decryption": "none" },
  "streamSettings": {
    "network": "tcp",
    "security": "reality",
    "realitySettings": {
      "show": false,
      "dest": "www.microsoft.com:443",
      "xver": 0,
      "serverNames": ["www.microsoft.com"],
      "privateKey": "<PRIVATE_KEY>",
      "shortIds": ["86d173896823210b"]
    }
  },
  "sniffing": { "enabled": true, "destOverride": ["http", "tls", "quic"] }
}

Внизу редактора оставляем галку Restart Nodes и сохраняем — панель перекатит конфиг на ноду и перезапустит ядро. На вкладке Inbounds появляется наш VLESS Reality / vless / 443:

«Жертву» (dest/serverNames) выбирайте с умом: это должен быть реально работающий чужой сайт на 443 с TLS 1.3, не связанный с вами и не заблокированный там, где сидят ваши клиенты. Классика — крупные CDN и сайты вроде www.microsoft.com, www.cloudflare.com. И не забудьте открыть порт 443 на ноде под инбаунд: ufw allow 443/tcp.

Шаг 3. Заводим хост (вкладка Hosts)

Инбаунд существует, но клиент про него ещё не знает — нужен хост. Идём в Hosts → Add Host: выбираем инбаунд VLESS Reality, в поле Remark задаём имя, под которым конфиг увидит клиент (в примере — VLESS-Reality-node1), адрес — публичный IP ноды, порт 443, в Security Settings — SNI www.microsoft.com и отпечаток (fingerprint) chrome.

Important:

Поле Security в хосте принимает только Inbound Default, none или tls — значения reality там нет. Для Reality выбирайте Inbound Default: хост унаследует reality-настройки из инбаунда, а публичный ключ (pbk) и shortId (sid) панель подставит в ссылку автоматически. Пытаться выставить security: reality руками — прямой путь к 422.

Шаг 4. Группа (вкладка Groups)

Чтобы инбаунд попал пользователю, он должен входить в группу. В Groups создаём группу test-group и включаем в неё инбаунд VLESS Reality.

Пользователь и его подключение (вкладка Users)

Теперь выдаём доступ. Users → Create User: задаём имя, при желании лимит трафика и срок, и — главное — выбираем группу test-group.

Пользователь появляется в списке со статусом Active; в его строке — иконки «скопировать подписку», «ссылка» и «QR».

Нажимаем на иконку QR — открывается страница подписки пользователя: QR-код и список его конфигураций (VLESS-Reality-node1 с адресом ноды). Это ровно тот экран, который вы даёте клиенту: он сканирует QR или копирует ссылку в v2rayNG / v2rayN / Hiddify.

Ссылка подписки отдаёт клиенту готовый vless://-конфиг со всеми параметрами Reality:

vless://<UUID>@NODE_IP:443?encryption=none&security=reality&type=tcp&headerType=none&sni=www.microsoft.com&fp=chrome&pbk=<PUBLIC_KEY>&sid=86d173896823210b#VLESS-Reality-node1

Что здесь что: <UUID> панель выдала пользователю сама, pbk — публичный ключ из шага 1, sid — тот же shortId, sni — домен-«жертва», fp — имитируемый TLS-отпечаток браузера, а после # — имя из поля Remark хоста.

Проверка, что Reality реально работает

Мало сгенерировать ссылку — убедимся, что маскировка живая. TLS-проба на порт ноды с именем «жертвы» должна вернуть настоящий сертификат Microsoft — это и есть камуфляж Reality: для стороннего наблюдателя порт 443 на вашей ноде выглядит как сервер Microsoft.

echo | openssl s_client -connect NODE_IP:443 -servername www.microsoft.com 2>/dev/null \
  | openssl x509 -noout -issuer -subject
# issuer=C=US, O=Microsoft Corporation, CN=Microsoft TLS G2 RSA CA OCSP 04
# subject=C=US, ST=WA, L=Redmond, O=Microsoft Corporation, CN=www.microsoft.com

Видим цепочку Microsoft — значит Reality поднят и корректно «одалживает» чужой TLS. Импортируйте vless:// в клиент и проверьте реальный выход в сеть.

Проверка на перезапуск

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

# на ноде
cd /opt/pg-node && docker compose restart
# на панели
cd /opt/pasarguard && docker compose restart
cd /opt/caddy && docker compose restart

Через 30–60 секунд статус ноды снова connected, в логах панели — свежая пара Connecting to ... / Connected to "test-node-1" node v0.5.3. Ручных действий не требуется.

Если не завелось: симптом → где смотреть

Гайд не зря повторяет про docker logs — в PasarGuard интерфейс почти ничего не объясняет. Вот короткая карта на случай, если что-то пошло не так.

Симптом Куда смотреть Обычная причина
Домен не открывается, сертификат не выпустился docker logs caddy неверный токен Cloudflare, A-запись не доехала до DNS, домен не на NS Cloudflare
Домен открывается, но вместо дашборда пусто или 502 docker logs pasarguard панель не поднялась (чаще всего — пароль базы в .env), либо неверный VITE_BASE_API
Панель не стартует, ошибки про базу docker logs pasarguard-db не подставлен DB_PASS, разъехались пароль в .env и в compose базы
Нода в вечном рестарте docker logs pg-node нет файлов сертификата в /var/lib/pg-node/certs
У ноды красная точка в панели docker logs pasarguard сертификат без SAN на IP, не тот сертификат в поле Certificate, не совпал api_key, закрыт 62050
Ссылка импортируется, но трафика нет docker logs pg-node + Statistics закрыт 443 на ноде, разъехались pbk/sid/sni между инбаундом и клиентом

Бэкап и обновление

Что бэкапить. Состояние панели — это база и /var/lib/pasarguard:

# дамп базы
docker exec pasarguard-db pg_dump -U pasarguard pasarguard | gzip > pg_$(date +%F).sql.gz

# конфиги и состояние панели (.secrets — единственное место, где лежат DB_PASS и API_KEY)
tar czf pasarguard_state_$(date +%F).tar.gz \
  /opt/pasarguard/.env /opt/pasarguard/.secrets /opt/pasarguard/docker-compose.yml \
  /opt/caddy /var/lib/pasarguard

Каталог данных Postgres (/var/lib/pasarguard-db) в архив не кладём: живые файлы базы бэкапить копированием бессмысленно — для этого и снимается pg_dump.

На ноде сохраняем /opt/pg-node/docker-compose.yml и пару ssl_cert.pem / ssl_key.pem:

tar czf pgnode_state_$(date +%F).tar.gz /opt/pg-node/docker-compose.yml /var/lib/pg-node/certs

Помните, что сертификат ноды хранится в двух местах — файл на ноде и его копия в поле Certificate карточки; при замене (например, при переезде ноды на новый IP — тогда нужен новый SAN) обновляйте оба, иначе панель отвалится с self-signed certificate.

Как обновляться. Мы зафиксировали теги версиями, поэтому обновление — это осознанная правка одной строки в compose: меняем тег на новый, перечитываем список изменений и перезапускаем. Перед обновлением панели обязательно снимите дамп базы: миграции alembic накатываются автоматически и назад не откатываются.

# в /opt/pasarguard/docker-compose.yml меняем тег панели на нужный, затем:
cd /opt/pasarguard && docker compose pull pasarguard && docker compose up -d pasarguard

# то же на ноде
cd /opt/pg-node && docker compose pull node && docker compose up -d
Warning:

Соблазн поставить latest велик, но тогда момент мажорного обновления с ломающими изменениями выбираете не вы, а рестарт контейнера. То, что SUDO_USERNAME перестал работать между версиями, — ровно такой случай: «просто перетянул latest» и потерял вход в панель. Поэтому теги в compose зафиксированы.

Итог

Собранный стенд — панель v5.2.1 на своём домене с боевым сертификатом Let’s Encrypt, TimescaleDB под статистику, Caddy с DNS-01 и вынесенная на отдельный сервер нода v0.5.3 с ядром Xray 26.3.27, отдающая VLESS + Reality с маскировкой под www.microsoft.com. Восемь мест, где установка спотыкается — образы из Hub, формат .env, localhost-бинд без TLS, Caddy без Cloudflare-модуля, запрет env-админа, спрятанный CLI, сертификат ноды (без файла и без SAN) и не тот сертификат в карточке — пройдены и задокументированы. Главный практический вывод: почти всё в PasarGuard диагностируется не в интерфейсе, а через docker logs — держите лог панели открытым.

Источники

Question:

Какую «жертву» для Reality вы используете на проде — крупный CDN, нейтральный зарубежный сайт, или домен, который сами же и держите? И как закрываете управляющий порт ноды: голый ufw с whitelist по IP панели, приватная сеть/WireGuard между панелью и нодами, или туннель? Интересно собрать разные подходы.