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

**URL:** <https://gig.ovh/t/pasarguard-na-dvuh-serverah-panel-noda-i-vless-reality-ot-ustanovki-do-rabochego-podklyucheniya/519>\
**Category:** Прокси и Виртуальный Частные Сети\
**Tags:** vless, docker, guide, pasarguard, reality\
**Created:** [25.Август.2026 21:40:31 UTC](https://gig.ovh/t/pasarguard-na-dvuh-serverah-panel-noda-i-vless-reality-ot-ustanovki-do-rabochego-podklyucheniya/519 "2026-08-25T21:40:31Z")\
**Posts on this page:** 1\
**Page:** 1

<div class="post-metadata">

**Author:** ![TrustMe](https://gig.ovh/user_avatar/gig.ovh/trustme/32/234_2.png) [@TrustMe](https://gig.ovh/u/TrustMe)\
**Post date:** [25.Август.2026 21:40:31 UTC](https://gig.ovh/t/pasarguard-na-dvuh-serverah-panel-noda-i-vless-reality-ot-ustanovki-do-rabochego-podklyucheniya/519/1 "2026-08-25T21:40:31Z")

</div>

Это сквозной гайд по развёртыванию 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`:

```auto
## 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:

```bash
# от 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` (общий секрет между нодой и панелью) удобно сгенерировать один раз:

```bash
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: 

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

```

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

```ini
# ==== 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:

```yaml
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, и только потом запустит панель.

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

```

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

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

 Warning: 

```auto
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`:

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

```

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

```caddyfile
{
    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 — через переменную окружения):

```yaml
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

```

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

```bash
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. Смотрим, что за сертификат он выдаёт (команду можно выполнить и с домашней машины — тогда это будет проверка по-настоящему снаружи):

```bash
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` ещё на старте, поэтому перезапускать панель не нужно — домен сразу отдаёт дашборд:

```bash
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`), так что наружу они и так не смотрят, но файрвол — полезный второй рубеж:

```bash
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: 

```auto
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, имя и пароль владельца. Теперь генерируем ключ и сразу вставляем его в открытую форму:

```bash
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: кнопка Owner access и режим Create](https://s3.gig.ovh/original/2X/6/661fee066d1bb4a9e398943d961e1c21fdcd6792.png)

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

 ![Дашборд PasarGuard после входа](https://s3.gig.ovh/original/2X/b/b9939e190eaa6858d5ed73f26f2c9dedce137f1d.png)

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

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

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

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

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

 ![Раздел Statistics: трафик по пользователям и нодам](https://s3.gig.ovh/original/2X/1/11d6838ee75d28a11647261cfce34ba6deb60950.png)

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

**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: 

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

```

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

 Error: 

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

```

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

```bash
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`:

```bash
mkdir -p /opt/pg-node && cd /opt/pg-node

```

```yaml
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

```

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

```bash
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`, иначе можно отрезать себе доступ:

```bash
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

```

```auto
[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**. Форма простая, но одно поле — с сюрпризом. Здесь всплывает последняя ошибка связки, и она видна **только в логах панели** : в интерфейсе при неверном сертификате просто загорается красная точка без пояснений.

 ![Форма создания и редактирования ноды](https://s3.gig.ovh/original/2X/b/b7fd39f4b2cbaa4951155f47019e150465e8df25.jpeg)

Поля формы:

| Поле | Значение | Пояснение |
| --- | --- | --- |
| 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: 

```auto
certificate verify failed: self-signed certificate

```

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

```auto
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`):

 ![Список нод: зелёный статус и версии](https://s3.gig.ovh/original/2X/9/916e376b0124d5bcf96f70b1967232cbe4a20dd9.png)

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

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

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

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

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

```bash
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 это не влияет.

```json
{
  "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`:

 ![Core Config: инбаунд VLESS Reality на порту 443](https://s3.gig.ovh/original/2X/4/4fa8c9dfbf0ee535b0a60cb83205d22616f211df.png)

«Жертву» (`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`.

 ![Форма хоста: инбаунд VLESS Reality, адрес и порт](https://s3.gig.ovh/original/2X/e/e44f770bf943b342c68b5f2bb41a749550980cab.jpeg)

 Important: 

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

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

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

 ![Группа test-group с инбаундом VLESS Reality](https://s3.gig.ovh/original/2X/9/9ac48c9b65ec40447280d37b8f25cae5ffdfa3c8.png)

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

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

 ![Форма создания пользователя с выбором группы](https://s3.gig.ovh/original/2X/a/a26d3ada5e1b14d8bf8899a495321ebf0765ae22.jpeg)

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

 ![Список пользователей: тестовый пользователь Active](https://s3.gig.ovh/original/2X/6/6eacb72479805d66a1eb0531d9e3abdb8ecbdbd5.png)

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

 ![Страница подписки пользователя: QR и конфигурация VLESS Reality](https://s3.gig.ovh/original/2X/5/50900bee9b39edb562a6cdd83bb6e45854af1ae8.jpeg)

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

```auto
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.

```bash
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://` в клиент и проверьте реальный выход в сеть.

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

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

```bash
# на ноде
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`:

```bash
# дамп базы
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`:

```bash
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 накатываются автоматически и назад не откатываются.

```bash
# в /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` — держите лог панели открытым.

## Источники

- PasarGuard Panel — репозиторий и `.env.example`: [github.com/PasarGuard/panel](https://github.com/PasarGuard/panel)
- PasarGuard Node — репозиторий и `docker-compose.yml`: [github.com/PasarGuard/node](https://github.com/PasarGuard/node)
- Официальная документация PasarGuard: [docs.pasarguard.org](https://docs.pasarguard.org)
- Xray-core (VLESS, Reality): [github.com/XTLS/Xray-core](https://github.com/XTLS/Xray-core)
- Caddy — TLS-автоматизация и DNS-модуль Cloudflare: [caddyserver.com/docs](https://caddyserver.com/docs/automatic-https)
- Образ Caddy с Cloudflare: [github.com/caddybuilds/caddy-cloudflare](https://github.com/caddybuilds/caddy-cloudflare)
- Cloudflare API — DNS-записи: [developers.cloudflare.com/api](https://developers.cloudflare.com/api/)

 Question: 

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