Dockerized Xray gateway for a home LAN.
Русская версия: README.ru.md.
The project runs Xray-Core behind stable local ports and supervises a pool of VLESS servers from a subscription plus an optional private server list. It keeps a hot standby connection ready, checks degradation, switches away from bad paths, exposes a simple status page, and routes Russian domains/IP ranges directly.
- SOCKS proxy on
1080. - HTTP proxy on
8123. - Optional plain LAN VLESS inbound on
10086. - Local status UI on
18080. - VLESS subscription refresh every two hours by default.
- Extra private VLESS links from
vless-extra.txt. - Two active Xray slots: current path plus hot standby.
- Liveness, latency, small quality download, throughput, and random candidate checks.
- RU split DNS and direct routing for
.ru,.su,.рф,geosite:category-ru, andgeoip:ru. - LoyalSoldier
geoip.dat/geosite.datasset management. - Telegram notification after successful failover recovery.
- SSH deploy script for a home server.
Real connection data is intentionally not tracked.
Ignored local files:
.env- subscription URL, Telegram token/chat id, LAN VLESS UUID.vless-extra.txt- private VLESS links.state.json- cached subscription candidates and measured server state.vless-lan-qr.png- local QR code.assets/- downloaded geo assets.
Use the example files as templates:
cp .env.example .env
cp vless-extra.example.txt vless-extra.txt
cp state.example.json state.json
mkdir -p assetsThen edit .env and vless-extra.txt.
.env:
# VLESS subscription URL from your provider.
XRAY_SUB_URL=https://example.com/subscription
# Non-RU DNS transport: socks (recommended), http, or direct.
# direct completely disables DNS encapsulation through VLESS.
DNS_GLOBAL_ROUTE=socks
# UUID for local LAN clients connecting to this gateway on port 10086.
# Generate once with: python3 -c 'import uuid; print(uuid.uuid4())'
# Use the same value in V2RayTun / VLESS client config.
INBOUND_VLESS_ID=00000000-0000-0000-0000-000000000000
# Optional Telegram notification settings.
# TELEGRAM_BOT_TOKEN: create a bot with @BotFather and copy its HTTP API token.
# TELEGRAM_CHAT_ID: send any message to the bot, then run:
# curl "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/getUpdates"
# Use message.chat.id from the response.
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
TZ=Europe/MoscowINBOUND_VLESS_ID is not issued by the subscription provider. It is your own local client UUID for the inbound VLESS listener on the home gateway. Generate it once, keep it in .env, and use the same UUID when creating the LAN VLESS client profile.
Telegram notifications are optional. Create a bot through @BotFather, put the returned API token into TELEGRAM_BOT_TOKEN, send any message to that bot from your Telegram account, then call getUpdates and copy message.chat.id into TELEGRAM_CHAT_ID.
vless-extra.txt contains one private VLESS URI per line. These servers are not refreshed from the subscription and are sampled more often by the candidate checker.
docker compose build proxy-xray
docker compose up -d --force-recreateCheck status:
curl http://127.0.0.1:18080/json
docker logs proxy-xray --tail 80Open the UI:
http://127.0.0.1:18080/
| Port | Purpose |
|---|---|
1080/tcp |
SOCKS proxy |
1080/udp |
SOCKS UDP |
8123/tcp |
HTTP proxy |
10086/tcp |
LAN VLESS inbound |
18080/tcp |
Status web UI |
The DNS relay exists inside the container, but compose does not publish port 53 by default.
When --inbound-vless is enabled, LAN clients can connect to the gateway through the local VLESS inbound on port 10086.
The easiest way to get the client profile is through the status UI:
-
Open the status UI through the server LAN address, not
127.0.0.1:http://HOME_SERVER_IP:18080/ -
Click the
Qbutton in the top-right toolbar. -
Open the generated
/clientpage. -
Scan the QR code from V2RayTun or copy the connection string manually.
The page builds the VLESS URL from the address used to open the UI. For example, if the UI is opened as http://192.168.2.200:18080/, the generated client URL will point to 192.168.2.200:10086.
Manual format:
vless://INBOUND_VLESS_ID@HOME_SERVER_IP:10086?security=none&type=tcp#home-proxy
Replace:
INBOUND_VLESS_IDwith your generated value from.env;HOME_SERVER_IPwith the Docker host LAN IP.
The supervisor starts two Xray instances:
- active slot: receives public
1080,8123, and10086through a local TCP switch and runs a small Xray balancer pool; - standby slot: already running with a separate hot standby balancer pool and checked in the background.
The default compose uses:
- active pool:
3candidates; - standby pool:
3candidates. - extra reserve:
1live private extra URI per active/standby slot when available; - hot standby fast switch:
1full active-path failure when standby is already healthy. - liveness check: every
20seconds, failover after2failures; - quality download: every
60seconds, 512 KB, failover after2slow checks; - heavy throughput: every
300seconds, used as a quality metric by default.
Each slot has a score-ordered pool. Xray can choose between several outbounds inside the slot, and the first outbound remains the native Xray fallback if observatory cannot choose one.
Important connection behavior:
- Xray chooses an outbound from the active pool for new connections.
- Xray does not migrate an already opened TCP connection to another outbound.
- If the currently selected outbound dies or degrades, new browser/app requests can move to another outbound inside the same active pool.
- Existing downloads, media streams, or image requests can still stall or break for a short time because they were opened through the old outbound.
- If the whole active slot is unhealthy or degraded, the supervisor switches the public ports to the hot standby slot.
This means short interruptions are expected during a bad-server event. The goal is to make new connections recover quickly, not to make every existing TCP session survive a server failure.
Failover can be triggered by:
- repeated health-check failures;
- repeated high latency;
- repeated low quality-download speed;
- unhealthy current slot with a healthy hot standby available.
On successful switch:
- Public ports are pointed to the standby slot.
- The previous active pool head is soft-quarantined.
- A new standby is built.
state.jsonis updated.- Telegram notification is sent if configured.
Candidate checks are intentionally sequential. One random candidate is tested every 2-5 minutes by default, with extra-list servers weighted higher. This avoids hammering a subscription with many concurrent VLESS connections.
At startup the active slot must pass a preflight health check before public ports are attached to it. If the first outbound in the pool is dead, it is soft-quarantined and another pool is tried.
The primary dashboard has an Extra pool button in the top toolbar. It is an emergency one-shot override for cases where subscription servers are globally degraded but private extra links still work better.
When clicked and confirmed, the supervisor tests each configured extra VLESS link from vless-extra.txt separately, sequentially: two consecutive successful health requests and a small quality download are required. A transient failure is retried. Only verified transports enter the pool. gRPC and XHTTP are preferred as startup/fallback transports; native leastPing still chooses among the pool's live transports by latency. This intentionally ignores the normal active pool: 3 size and same-host limits for that one rebuild.
The verified pool starts on a temporary, loopback-only Xray slot while the current active path and hot standby stay running. After a sequential observatory warmup, repeated pool health checks and a quality download must pass before public ports switch. If balancing fails, the preferred verified transport is tried alone. The original hot standby is retained; the temporary slot becomes the active slot on success and is cleaned up on rejection. Preparation can take a minute or more, especially when transports time out.
While manual mode is active, failed health requests are retried, and the usual max_failures threshold applies instead of the one-cycle hot-standby shortcut. Recovery rechecks transports individually. A failed pooled request or an API selection snapshot alone never quarantines a transport. Only confirmed individual failures do; if no staged extra path passes, the supervisor returns to the preserved regular standby. Degradation respects the switch cooldown, but total failure does not. Existing TCP sessions can still be interrupted during a slot switch.
state.json uses schema v2 and stores candidate cache plus a bounded quality history:
- last OK/fail timestamps;
- last latency and throughput;
- last
50recent checks per candidate; - rolling success rate, failure streak, latency EWMA, and throughput EWMA.
The file is written atomically. If it is corrupted, startup moves it to state.json.corrupt.<timestamp> and continues with an empty state instead of crashing.
The default compose routes Russian resources directly:
geosite:category-ruregexp:.*\.ru$regexp:.*\.su$regexp:.*\.xn--p1ai$geoip:ru
Split DNS is enabled:
- RU domains use
77.88.8.8,77.88.8.1; - other domains use
8.8.8.8,1.1.1.1through the active VLESS pool; DNS_GLOBAL_ROUTE=sockssends DNS-over-TCP through the SOCKS proxy on1080;DNS_GLOBAL_ROUTE=httpuses HTTPCONNECTthrough the HTTP proxy on8123;DNS_GLOBAL_ROUTE=directdisables encapsulation and restores direct DNS-over-TCP;- direct fallback for normal global domains is disabled by default, preventing silent DNS bypass around VLESS;
- positive responses are cached in memory and stale cache entries cover short failover windows.
The subscription URL host and all VLESS endpoint hosts are maintained in an automatic bootstrap list and resolved directly over DNS-over-TCP. This prevents a startup loop where DNS waits for VLESS while VLESS waits for DNS. Only connection-infrastructure names use bootstrap resolution.
Before the proxy switch becomes ready, startup queries temporarily use direct DNS-over-TCP so an initial subscription redirect can complete. Normal global DNS automatically moves to the configured VLESS transport after port 1080 or 8123 is ready.
The user-facing proxies remain independent:
- SOCKS5
1080: clients must pass the hostname to the proxy. In Firefox enableProxy DNS when using SOCKS v5; - HTTP proxy
8123: HTTPS destinations are passed by hostname throughCONNECT; - LAN VLESS
10086: destination hostnames pass through the inbound VLESS connection and active outbound.
To disable DNS-over-VLESS without changing any proxy or routing settings, set:
DNS_GLOBAL_ROUTE=directand recreate the container.
Xray sniffing is enabled on SOCKS, HTTP, and LAN VLESS inbounds with routeOnly, so TLS SNI / HTTP Host can be used for routing without rewriting the destination.
LoyalSoldier assets are stored in ./assets:
geoip.datgeosite.datassets-state.json
The image seeds bundled assets. Runtime refresh happens on schedule, but compose uses --no-asset-refresh-on-start so a slow GitHub download does not block container startup.
Available endpoints:
/- primary operator dashboard./status- alias for the primary dashboard./dashboard-v5- alias for the primary dashboard kept for bookmarked test links./dashboard-classic- previous dashboard layout kept for comparison./client- LAN VLESS connection string and QR code./servers/live- tested live servers./servers/all- all candidates./json- machine-readable status./fragments/dashboard-v5- small HTML fragments used for primary dashboard updates./fragments/status- small HTML fragments used by the classic dashboard./control/force-extra-pool- local POST action used by theExtra poolemergency override button./diagnostics- live direct/SOCKS/HTTP URL probes and DNS probes./diagnostics.json- machine-readable sanitized diagnostic output./diagnostics/bundle- downloadable sanitized diagnostic JSON./logs- recent supervisor logs.
The dashboards do not use full-page auto-refresh. They update dynamic blocks in place every 15 seconds.
The screenshots below use synthetic demo data. Server names, endpoints, IDs, and operational details are not real.
The dashboard shows:
- health indicators;
- current connection;
- hot standby;
- active path selected by Xray balancer API;
- active and standby observatory snapshots in
/json; - candidate scores;
- throughput;
- subscription state;
- geo asset state;
- diagnostic probe entry point;
- recent logs.
The deploy script syncs files over SSH, preserves server runtime state, rebuilds the image, and recreates the container.
DEPLOY_HOST=192.168.1.10 \
DEPLOY_USER=user \
DEPLOY_PATH=/home/user/proxy-xray \
scripts/deploy-server.shBy default it copies local .env and vless-extra.txt, but keeps server-side state.json and assets/.
See DEPLOY.md for options.
Run the client smoke container:
docker compose -f docker-compose.yml -f docker-compose.test.yml run --rm proxy-client-testIt checks:
- status UI;
- SOCKS proxy;
- HTTP proxy;
- LAN VLESS inbound;
- basic throughput;
- small quality download status;
- split DNS behavior;
- RU direct-routing smoke access;
- bundled LoyalSoldier assets.
Rebuild and restart:
docker compose build proxy-xray
docker compose up -d --force-recreateWatch logs:
docker logs -f proxy-xrayCheck current public HTTP proxy:
curl -x http://127.0.0.1:8123 https://www.gstatic.com/generate_204 -iCheck SOCKS:
curl -x socks5h://127.0.0.1:1080 https://checkip.amazonaws.comThis project started from the original samuelhbne/proxy-xray Docker wrapper for Xray-Core, then grew into a subscription supervisor and home LAN gateway with hot standby failover, split DNS, status UI, and deployment tooling.

