От чистого VPS до работающей ноды с VLESS + XHTTP + Reality, где Reality маскируется под собственный сайт на том же сервере. Порядок фаз обязателен: каждая заканчивается проверкой, без которой следующая не имеет смысла.
Заполни один раз — команды и конфиги ниже подставят их автоматически. Сохраняется в браузере, для каждой ноды свой набор.
Ключи и shortId сюда не вводи. Приватный ключ Reality — секрет, которому нечего делать в браузерной странице. Он подставляется вручную в шаблон панели, на своём месте в фазе 09.
Нода и панель — разные машины. В панели Remnawave нет Xray, весь трафик обрабатывает нода, поэтому под неё нужен отдельный VPS.
| Параметр | Минимум | Комментарий |
|---|---|---|
| CPU | 1 vCPU | Xray почти не грузит процессор; упор идёт в сеть |
| RAM | 1 ГБ | с 1 ГБ нужен swap, с 2 ГБ спокойнее |
| Диск | 10 ГБ | образы Docker плюс логи |
| Сеть | IPv4 | обязателен — под A-запись и ACME |
| ОС | Ubuntu 24.04 / Debian 12 | чистая установка, без панелей вроде aaPanel |
Заведи DNS-запись прямо сейчас, не дожидаясь фазы 04. Распространение занимает от минут до часа, и к моменту, когда она понадобится для сертификата, запись будет уже готова.
Выполняется на своей машине, не на сервере:
ssh-keygen -t ed25519 -C "remnawave-node" ssh-copy-id -i ~/.ssh/id_ed25519.pub root@IP_СЕРВЕРА
Убедись, что вход по ключу работает, не закрывая текущую сессию. Только после этого отключай пароли — на сервере:
sed -i 's/^#\?PasswordAuthentication .*/PasswordAuthentication no/' /etc/ssh/sshd_config grep -rn "PasswordAuthentication" /etc/ssh/sshd_config.d/ 2>/dev/null systemctl restart ssh
Облачные образы часто кладут PasswordAuthentication yes в отдельный файл внутри /etc/ssh/sshd_config.d/, и он переопределяет основной конфиг. Правка одного sshd_config тогда ничего не даёт, а ты будешь считать, что пароли отключены. Если grep что-то нашёл — правь и там.
ufw allow OpenSSH ufw allow 80/tcp ufw allow 443/tcp ufw --force enable ufw status numbered
Порт ноды для связи с панелью откроем в фазе 03 — точечно, не для всех.
fallocate -l 2G /swapfile chmod 600 /swapfile mkswap /swapfile swapon /swapfile echo '/swapfile none swap sw 0 0' >> /etc/fstab free -h
curl -fsSL https://get.docker.com | sh docker --version docker compose version
ufw status показывает SSH, 80 и 443docker compose version отвечает без ошибокПанель сама генерирует compose-файл для ноды вместе с сертификатом — вручную ничего собирать не нужно.
Панель → Nodes → Management → кнопка добавления. Заполни:
2222. К пользовательскому трафику отношения не имеетЧасть гайдов советует переносить SSH на порт 2222. Если ты так сделал — возьми для NODE_PORT другой номер, иначе нода и SSH схлестнутся за один порт.
Панель покажет готовый docker-compose.yml. Скопируй его целиком и вставь в редактор на сервере:
mkdir -p /opt/remnanode && cd /opt/remnanode nano docker-compose.yml
Вставь сгенерированное панелью, сохрани Ctrl+O → Enter → Ctrl+X, затем:
docker compose config docker compose up -d docker compose logs -f -t
docker compose config перед запуском — привычка, которая экономит время: если при вставке в файл попала лишняя строка, ошибка вылезет сразу, а не в виде молчащего контейнера.
В сгенерированном compose лежит SSL_CERT — учётные данные, которыми панель авторизуется на этой ноде. Не выкладывай файл и не пересылай его целиком, когда просишь помощи с конфигом.
Вернись к карточке создания ноды в панели, нажми Next, выбери Config Profile и заверши созданием. После этого панель начнёт отдавать ноде конфиг Xray — тот самый, который мы заменим в фазе 09.
Порт ноды должен быть доступен только панели:
ufw allow from IP_ПАНЕЛИ to any port 2222 proto tcp ufw status numbered
ufw allow 2222 без from выставляет API ноды в интернет. Правило обязано быть с указанием IP панели.
docker ps показывает remnanode в статусе Upss -tlnp | grep :443 показывает rw-core — значит конфиг доехал и Xray слушаетufw status порт API открыт только для IP панелиЕсли завёл запись ещё на фазе 01 — здесь остаётся только проверить. Каждой ноде нужен свой поддомен — A-запись указывает на конкретный IP, поэтому один домен на несколько нод не подойдёт.
cdn., static., img., files., географическое название — нормально. vpn., proxy., node3. — нетdig +short мой.домен
Возвращается ровно IP этой ноды и ничего больше. Если пусто — запись не разошлась, подожди. Если IP чужой — включено проксирование Cloudflare.
| Порт | Кто занимает | Доступ | Зачем |
|---|---|---|---|
| 22 | sshd | снаружи | доступ по ключу, пароли отключены в фазе 02 |
| 80 | Caddy | снаружи | ACME-челлендж и редирект на HTTPS |
| 443 | Xray | снаружи | Reality, основной вход |
| 2222 | Нода | только IP панели | внутренний API, закрыт правилом из фазы 03 |
| 9443 | Caddy | только 127.0.0.1 | сайт-прикрытие, наружу не торчит |
ss -tlnp | grep -E ':(80|443|9443)' ufw status
На 443 должен быть rw-core — это Xray внутри контейнера remnanode. Порты 80 и 9443 обязаны быть свободны. Если 80 закрыт фаерволом:
ufw allow 80/tcp
У многих хостеров есть второй фаервол в панели управления, независимый от ufw. Если ACME не проходит при открытом ufw — смотри туда.
Это то, что увидит проверяющий. Требование одно, но жёсткое: страница должна выглядеть как настоящий маленький сайт.
mkdir -p /var/www/site echo '<!doctype html><html><head><title>Studio</title></head><body><h1>Studio</h1></body></html>' \ > /var/www/site/index.html
Заглушка годится только чтобы довести сборку до конца. Перед тем как пускать пользователей — замени на настоящий шаблон и положи favicon.ico: без него браузер проверяющего получит 404 на первом же запросе.
Caddy раздаёт сайт по HTTPS на 127.0.0.1:9443 и сам получает сертификат Let's Encrypt через порт 80. Xray будет отправлять сюда всё, что не прошло авторизацию Reality.
Создавай через редактор, не через heredoc: незакрытый heredoc молча съедает следующие команды как текст.
mkdir -p /opt/selfsteal && nano /opt/selfsteal/Caddyfile
{
admin off
https_port 9443
auto_https disable_redirects
servers {
protocols h1 h2
}
}
http://мой.домен {
redir https://{host}{uri} permanent
}
https://мой.домен {
bind 127.0.0.1
root * /var/www/site
file_server
encode gzip
tls {
issuer acme {
disable_tlsalpn_challenge
}
}
}
Две строки здесь неочевидны и обе обязательны:
disable_tlsalpn_challenge — ACME-челлендж TLS-ALPN-01 требует порт 443, а он занят Xray. Без этой строки выпуск сертификата подвисаетprotocols h1 h2 — отключает HTTP/3. Иначе Caddy отдаёт заголовок alt-svc: h3=":9443", который прямо сообщает проверяющему, что настоящий веб-сервер сидит на другом портуhead -3 /opt/selfsteal/Caddyfile docker run --rm -v /opt/selfsteal/Caddyfile:/etc/caddy/Caddyfile:ro \ caddy:2-alpine caddy validate --config /etc/caddy/Caddyfile
Первая строка файла — ровно {. Валидатор отвечает Valid configuration.
docker run -d \ --name selfsteal-caddy \ --restart always \ --network host \ -v /opt/selfsteal/Caddyfile:/etc/caddy/Caddyfile:ro \ -v /var/www/site:/var/www/site:ro \ -v caddy_data:/data \ -v caddy_config:/config \ caddy:2-alpine docker logs -f selfsteal-caddy
Пути на хосте и в контейнере совпадают намеренно — в Caddyfile указан тот же root, путать нечего. Жди в логах certificate obtained successfully, затем Ctrl+C.
ss -tlnp | grep -E ':(80|9443)' curl -sI --resolve мой.домен:9443:127.0.0.1 \ https://мой.домен:9443
caddy слушает *:80 и 127.0.0.1:9443curl отвечает HTTP/2 200alt-svc в ответе нетdocker run --rm ghcr.io/xtls/xray-core:latest x25519
openssl rand -hex 8 openssl rand -hex 8 openssl rand -hex 8
| Значение | Куда идёт |
|---|---|
| Private key | шаблон Xray, поле privateKey |
| Public key | хост в панели, поле Public Key |
| shortId ×2 | шаблон Xray; в хосте указывается один любой из них |
| path | шаблон Xray и хост — должны совпадать точно |
shortIds: [""] принимает любого, кто знает публичный ключ. Генерируй настоящие.
Path в шаблоне стоит заглушкой /Path — обязательно замени на свой и пиши со слешем. Оставишь как есть — путь будет одинаковым у всех, кто брал этот runbook, а это ровно тот признак, от которого мы тут избавляемся.
На новой ноде здесь стоит профиль, который панель привязала в фазе 03 — его и заменяем. Если нода уже работала с пользователями, сначала сохрани текущий шаблон в файл: это твой откат.
Про тег инбаунда. На новой ноде поставь любое понятное имя — например VLESS_XHTTP_Reality. На работающей оставь тот тег, что уже используется, иначе привязки хостов в панели слетят и инбаунд придётся выбирать заново в каждом хосте. На работу имя не влияет ни в том, ни в другом случае.
{
"log": { "loglevel": "warning" },
"inbounds": [
{
"tag": "ТЕКУЩИЙ_ТЕГ_ИНБАУНДА",
"listen": "0.0.0.0",
"port": 443,
"protocol": "vless",
"settings": {
"clients": [],
"decryption": "none"
},
"streamSettings": {
"network": "xhttp",
"security": "reality",
"realitySettings": {
"show": false,
"target": "127.0.0.1:9443",
"xver": 0,
"serverNames": ["мой.домен"],
"privateKey": "ПРИВАТНЫЙ_КЛЮЧ",
"shortIds": ["SHORTID_1", "SHORTID_2"]
},
"xhttpSettings": {
"host": "мой.домен",
"path": "/Path",
"mode": "auto"
}
},
"sniffing": {
"enabled": true,
"destOverride": ["http", "tls", "quic"]
}
}
],
"outbounds": [
{
"tag": "DIRECT",
"protocol": "freedom",
"settings": { "domainStrategy": "UseIPv4" }
},
{
"tag": "BLOCK",
"protocol": "blackhole"
}
],
"dns": {
"servers": ["94.140.14.14", "94.140.15.15", "1.1.1.1"],
"queryStrategy": "UseIPv4",
"disableCache": false
},
"routing": {
"domainStrategy": "IPIfNonMatch",
"rules": [
{ "ip": ["0.0.0.0/8", "::/128"], "outboundTag": "BLOCK" },
{ "ip": ["geoip:private"], "outboundTag": "BLOCK" },
{ "domain": ["geosite:private"], "outboundTag": "BLOCK" },
{ "protocol": ["bittorrent"], "outboundTag": "BLOCK" }
]
}
}
| Поле | Назначение |
|---|---|
target: 127.0.0.1:9443 | SelfSteal — непрошедшие авторизацию уходят на локальный Caddy, а не на чужой сайт |
network: xhttp | транспорт внутри туннеля; с realitySettings не конфликтует, это разные слои |
mode: auto | с Reality клиент сам выберет stream-one; сервер принимает все режимы |
xver: 0 | Caddy видит соединения с 127.0.0.1. Значение 1 требует приёма PROXY-протокола в Caddyfile — рассинхрон ломает всё |
domainStrategy: IPIfNonMatch | резолвит домен вторым проходом, чтобы поймать 0.0.0.0 от AdGuard; заодно закрывает SSRF в приватные сети |
0.0.0.0/8 → BLOCK | AdGuard отвечает 0.0.0.0 на рекламу, а connect() к нему на Linux уходит на 127.0.0.1 — то есть обратно в свой же инбаунд |
freedom: UseIPv4 | заставляет исходящий резолвить через внутренний DNS; без этого блок dns не работает |
Про блокировку рекламы. Фильтрация целиком на стороне резолвера. AdGuard отвечает 0.0.0.0 на рекламные домены, IPIfNonMatch вторым проходом резолвит адрес, правило 0.0.0.0/8 отправляет соединение в blackhole. Локальных списков нет — обновлять и поддерживать нечего, а база AdGuard свежее любого geosite.
Цена такого решения — единственная точка отказа. 1.1.1.1 третьим в списке страхует от недоступности AdGuard, но в момент переключения на него фильтрация выключается целиком и молча. Раз в месяц проверяй, что фильтр вообще жив:
dig +short @94.140.14.14 doubleclick.net — ожидается 0.0.0.0. Пусто или таймаут означают, что реклама прямо сейчас не режется. Альтернатива — убрать 1.1.1.1 совсем: тогда отказ AdGuard уронит интернет всем пользователям, зато незаметной потери фильтрации не будет.
| Поле | Значение |
|---|---|
| Network / Transport | xhttp |
| Path | тот же, что в шаблоне |
| Host | домен ноды |
| Security | reality |
| SNI | домен ноды |
| Public Key | из вывода x25519 |
| Short ID | один из двух |
| Fingerprint | chrome |
| Flow | пусто |
xtls-rprx-vision работает только поверх raw/TCP. Оставишь его в поле Flow — соединение не поднимется, а сообщение об ошибке будет невразумительным и уведёт диагностику не туда. Поле нужно именно очистить.
Второе: mux.cool с XHTTP не включать. XHTTP мультиплексирует сам через xmux, а свежие версии Xray на сервере такие соединения отвергают.
Три проверки, каждая закрывает свой слой.
docker logs --tail 50 remnanode ss -tlnp | grep :443
curl -sI https://мой.домен
Без --resolve — обычный запрос снаружи на 443. Он проходит через Xray, не проходит авторизацию Reality и проваливается в Caddy. Именно это увидит проверяющий.
echo | openssl s_client -connect мой.домен:443 \ -servername мой.домен 2>/dev/null \ | openssl x509 -noout -subject -issuer
rw-corecurl отдаёт HTTP/2 200 и твою страницуДиагностика послойно, не наугад. Сначала определи, какой слой сломан, потом чини.
| Симптом | Слой | Что смотреть |
|---|---|---|
| В панели нода «не подключена» | Нода | Порт API закрыт или открыт не тому IP, либо в Address указан не тот адрес. Смотри docker logs remnanode и ufw status numbered |
Контейнер remnanode перезапускается по кругу |
Нода | Обычно испорченный SSL_CERT — при копировании из панели потерялась часть строки. Сгенерируй карточку ноды заново и вставь compose целиком |
ss не показывает rw-core на 443 |
Профиль | Config Profile не привязан к ноде, панель не отдала конфиг Xray. Вернись к фазе 03, шаг 3 |
| SSH пускает по паролю после запрета | SSH | Drop-in файл в /etc/ssh/sshd_config.d/ переопределяет основной конфиг. Правь и его, затем systemctl restart ssh |
| Caddy зациклился на выпуске сертификата | ACME | Закрыт порт 80 либо включено проксирование Cloudflare. Проверь оба фаервола — ufw и панель хостера |
В ответе есть alt-svc |
Caddy | Не применился protocols h1 h2. Проверь глобальный блок Caddyfile и перезапусти контейнер |
Открывается чужой сайт или редирект на guce. |
Xray | Шаблон не применился, на ноде ещё старый target. Consent-страница Yahoo — типичный признак |
curl отдаёт сайт, но клиент не подключается |
Хост | Почти всегда непустой Flow. Дальше — расхождение path или публичного ключа |
| Клиент подключается, трафика нет | Роутинг | docker logs remnanode при loglevel: warning |
| Connection refused на 443 снаружи | Xray | Нода не стартовала — ошибка в JSON. Логи контейнера покажут строку |
| YAML или heredoc ведут себя странно | Шелл | Приглашение > вместо # означает незакрытый heredoc — Ctrl+C. Затем head -3 на файле: в него могла попасть лишняя строка |
Откат: вернуть сохранённый шаблон Xray в панель и применить к ноде. Caddy при этом можно не трогать — он никому не мешает и пригодится со следующей попытки.
SSL_CERT — генерируются отдельно на каждую, переиспользовать нельзяXHTTP требует свежих версий: v2rayNG 1.9+, v2rayN 7+, sing-box 1.11+, Hiddify, Streisand. Старые клиенты отвалятся молча. Предупреди пользователей до переключения, иначе получишь волну обращений.
Если на клиенте настроен балансировщик, его конфиг править не нужно — Remnawave подставляет транспорт из настроек хоста. После миграции дай наблюдателю несколько минут на пересбор статистики: скользящее окно выборки наполняется не мгновенно, и первые пробы могут увести балансировщик не туда.
Новая нода. Иди по фазам подряд, от 01 до 11. Пользователей на ней ещё нет, ломать нечего.
Миграция работающей ноды. Фазы 01–03 уже пройдены, начинай с 04. Фазы 04–07 ничего не ломают: старый инбаунд продолжает работать, пока ты заводишь домен, поднимаешь Caddy и получаешь сертификат. Единственный момент простоя наступает в фазе 09, при перезапуске ноды с новым шаблоном.
При нескольких нодах это даёт удобную схему: доведи все ноды до конца фазы 07 заранее, проверь у каждой сайт-прикрытие, и только потом пройди переключением по фазам 08–11. Тогда простой сводится к одному рестарту на ноду вместо растянутой на вечер возни.
Собрано из рабочей сессии развёртывания. Проверено на Xray 25.x, Caddy 2, Remnawave с нодой в Docker. Значения в полях наверху хранятся только в этом браузере.