Это сквозной гайд по развёртыванию PasarGuard — форка Marzban, панели управления Xray-ядром с подписками, статистикой и вынесенными на отдельные машины нодами. Мы поднимем панель на одном сервере, ноду на втором, свяжем их по gRPC, закроем панель боевым сертификатом Let’s Encrypt через Caddy и настроим не игрушечный Shadowsocks, а актуальный VLESS + Reality — с маскировкой под чужой TLS. В конце у нас будет пользователь, чью ссылку-подписку можно открыть в клиенте и подключиться.
Почти всё, что можно, делается через веб-панель — я показываю конкретные страницы и формы на скриншотах. В командную строку уходим только там, где иначе никак: установка Docker, генерация сертификата ноды, ключей Reality и запуск контейнеров. Всё снято с живого стенда: панель v5.2.1, нода v0.5.3, ядро Xray 26.3.27. Каждая ошибка в тексте — реальный лог, а не пересказ.
По ходу дела разберём восемь мест, где установка спотыкается:
- официальный
docker-compose.ymlтянет образы из Docker Hub, который отдаёт429; - формат
.envиз репозитория ломает докеровский парсер переменных; - панель без TLS слушает только localhost — снаружи не достучаться;
- базовый образ Caddy не умеет Cloudflare и не стартует с DNS-01;
- статический админ из переменных окружения в свежих версиях запрещён;
- CLI спрятан не там, где его ищешь;
- сертификат ноды: без файла — вечный рестарт, без SAN на IP — отказ панели;
- в поле Certificate карточки ноды легко положить не тот сертификат. Почти ни одна из них не объясняется в интерфейсе: единственное исключение — запрет env-админа, и то без подсказки, что делать дальше. Остальное диагностируется только через
docker logs.
Домен в примерах — 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 с расширением для временных рядов, что идеально ложится на поминутную статистику трафика).
Выбор базы делается до первого запуска. Мигрировать 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:
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-миграций всплывёт предупреждение, которое пугает новичков, но ожидаемо:
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 это даёт отлуп на логине:
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, и сертификат нужен до первого старта — иначе контейнер уходит в вечный рестарт:
open /var/lib/pg-node/certs/ssl_cert.pem: no such file or directory
Сначала сертификат, потом контейнер. И здесь — самая коварная ошибка установки. Если сгенерировать самоподписанный сертификат «как обычно», без Subject Alternative Name, панель при подключении отвергнет ноду:
[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 проходит, остальные — нет.
Проверьте с посторонней машины: 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 вы увидите причину:
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.
Поле 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
Соблазн поставить 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 — держите лог панели открытым.
Источники
- PasarGuard Panel — репозиторий и
.env.example: github.com/PasarGuard/panel - PasarGuard Node — репозиторий и
docker-compose.yml: github.com/PasarGuard/node - Официальная документация PasarGuard: docs.pasarguard.org
- Xray-core (VLESS, Reality): github.com/XTLS/Xray-core
- Caddy — TLS-автоматизация и DNS-модуль Cloudflare: caddyserver.com/docs
- Образ Caddy с Cloudflare: github.com/caddybuilds/caddy-cloudflare
- Cloudflare API — DNS-записи: developers.cloudflare.com/api
Какую «жертву» для Reality вы используете на проде — крупный CDN, нейтральный зарубежный сайт, или домен, который сами же и держите? И как закрываете управляющий порт ноды: голый ufw с whitelist по IP панели, приватная сеть/WireGuard между панелью и нодами, или туннель? Интересно собрать разные подходы.










