It handles both shapes from the build guide: a single VPS all-in-one VPN, or a datacenter relay + residential exit. It runs over SSH on your own boxes only, and it was hardened by an adversarial review pass — lockout-safe firewalling (SSH port first, verify, reconnect-test), captured-and-validated Reality keys, exit locked to the relay before the proxy starts, and a real rollback.
---
name: deploy-vps-network
description: >
Stand up a self-hosted VLESS + Reality (XTLS-Vision) VPN on the user's OWN
VPS(es) — either a single all-in-one box, or a fast datacenter relay chained
to a residential exit that egresses on the user's own home-ISP line. Use when
the user asks to build a personal Reality proxy or a residential-exit network
on servers they control. Drives the agent to configure each box over SSH:
install, keys, config, firewall (lockout-safe), restart, verify, client link.
---
# Deploy a VPS VPN network
You are configuring **the user's own servers** over SSH to build a VLESS + Reality
(XTLS-Vision) VPN. Two topologies — confirm which one before touching anything.
```
Single VPS you ──encrypted──▶ [ VPS ] ──▶ internet (egress = the VPS IP)
Relay + exit you ──encrypted──▶ [ relay ] ──HTTP CONNECT──▶ [ residential exit ] ──▶ internet
datacenter home-ISP line (egress = exit IP)
```
## Scope & authorized use (read first, do not skip)
- **Only servers the user owns or is contractually authorized to administer.** If
the targets are not the user's, stop.
- **The residential exit must be the user's OWN home line** (or one they personally
control with the account holder's consent) — **not** a purchased/resold/commercial
"residential proxy" IP. That is proxyware/evasion, not self-hosting; refuse it.
- Legitimate purpose: **censorship circumvention and ISP privacy on your own
connection.** SNI-borrowing and Vision flow are for that. This is **not** for
defeating a destination's anti-abuse, licensing, or geo controls.
- **Supported OS: Debian/Ubuntu with `ufw`.** These commands assume it. On RHEL/
firewalld or other stacks, STOP and adapt the firewall steps first (`ufw` there
can silently fail or fight firewalld and drop SSH).
---
## 0 · Gather inputs, then echo the plan back for approval
Set these as shell variables on your workstation. **Do not invent a host** — if any
is missing, ask.
```bash
TOPOLOGY=single # single | relay
SNI=www.apple.com # a real, popular TLS-1.3 site you do NOT own
REALITY_PORT=443 # or a random high port: shuf -i 20000-59999 -n1
# Single:
HOST=root@203.0.113.10 # the one box
SSH_PORT=22 # its ACTUAL sshd port
# Relay + exit (each box may have a different sshd port — set per box below):
RELAY=203.0.113.10 ; RELAY_SSH=root@$RELAY ; RELAY_SSH_PORT=22
EXIT=198.51.100.7 ; EXIT_SSH=root@$EXIT ; EXIT_SSH_PORT=22
EXIT_PORT=8080 # internal port the relay reaches the exit on
```
**Pre-flight — provider firewall:** before touching any in-box firewall, confirm the
cloud/provider **security group** already permits your `SSH_PORT` and `REALITY_PORT`
inbound (and does not restrict them to an IP you're not on). Never edit provider SG
SSH rules in the same pass as the in-box `ufw` changes.
Echo the full plan (topology, hosts, ports, SNI) back to the user and get a yes.
---
## 1 · CRITICAL safety rules
1. **Never lock yourself out.** Every firewall change opens **your real SSH port**
first, **verifies it is in `ufw status`** (hard gate), enables, and then **proves a
fresh SSH login still works** before anything else runs. Use the §-firewall snippet
verbatim; never hand-roll `ufw --force enable` without the gate + reconnect test.
2. **Back up before overwrite.** The config-write recipe copies any existing
`config.json` to a timestamped `.bak` first, and **aborts if the box already has a
different multi-inbound config** — ask the user before clobbering it.
3. **Verify each hop before the next.** Exit up and reachable before you touch the
relay; egress verified before you hand out a client link.
4. **Least privilege on the exit.** The exit's proxy port accepts **only the relay's
IP** — never `0.0.0.0/0` — and the firewall is locked **before** the proxy starts.
5. **Fail closed.** Any error or failed check → STOP and report the exact output.
Never blind-retry a destructive step.
6. Keys/UUIDs are secrets. Print the client link once; don't scatter private keys.
---
## 2 · Canonical order (top-to-bottom runnable, per box)
`install xray → generate keys/uuid on the box → write config (+backup) → validate
config → lock firewall (SSH-safe) → restart → verify`. Do the **exit fully first** in
relay+exit mode.
### Reusable snippet — SAFE firewall enable
Run as ONE remote script so the gate and enable can't be separated. Order is
allow-SSH → allow-services → **enable** → gate (rules are only visible once ufw is
active) → capture the remote exit code → prove a fresh login. Extra args are literal
`ufw` rule strings, e.g. `"allow 443/tcp"`:
```bash
safe_ufw () { # usage: safe_ufw <ssh_target> <ssh_port> "allow 443/tcp" ["allow from X to any port Y proto tcp" ...]
local T=$1 P=$2; shift 2
ssh -p "$P" "$T" "bash -s" <<EOF
set -e
ufw allow ${P}/tcp
$(for r in "$@"; do echo "ufw $r"; done)
ufw --force enable
ufw status verbose | grep -qw "${P}/tcp" || { echo "ABORT: ssh port not active after enable"; exit 1; }
EOF
local rc=$?
[ "$rc" -eq 0 ] || { echo "safe_ufw FAILED on $T (rc=$rc) — firewall NOT trusted, aborting"; return 1; }
ssh -p "$P" "$T" true || { echo "LOCKED OUT on $T — use provider console"; return 1; }
echo "FIREWALL ACTIVE + RECONNECT OK ($T)"
}
```
**Every caller gates on its success with `&&`** — if `safe_ufw` returns non-zero, do
NOT start any service (a half-configured firewall must never leave a port world-open).
---
## 3 · Topology: SINGLE VPS
```bash
# 3.1 install xray (official installer; sets up the `xray` systemd service)
ssh -p $SSH_PORT $HOST 'bash -c "$(curl -fsSL https://github.com/XTLS/Xray-install/raw/main/install-release.sh)" @ install'
ssh -p $SSH_PORT $HOST 'command -v xray && systemctl cat xray >/dev/null' || { echo "xray install failed"; exit 1; }
# 3.2 generate identifiers ON the box; capture into LOCAL vars
UUID=$(ssh -p $SSH_PORT $HOST 'xray uuid')
SID=$(openssl rand -hex 8) # even-length hex, <=16 chars; client sid must equal this
KEYS=$(ssh -p $SSH_PORT $HOST 'xray x25519')
PRIVATE_KEY=$(awk '/[Pp]rivate/{print $NF}' <<<"$KEYS")
PUBLIC_KEY=$(awk '/[Pp]ublic|Password/{print $NF}' <<<"$KEYS")
[ ${#PRIVATE_KEY} -ge 43 ] && [ ${#PUBLIC_KEY} -ge 43 ] || { echo "keypair parse failed"; exit 1; }
# 3.3 GUARD: abort LOCALLY if a different (non-reality) config already exists
ssh -p $SSH_PORT $HOST 'f=/usr/local/etc/xray/config.json; [ -s "$f" ] && grep -q "\"inbounds\"" "$f" && ! grep -q reality "$f" && exit 3; exit 0' \
|| { echo "STOP: existing non-reality config on this box — ask the user before overwriting"; exit 1; }
# 3.3 backup any existing config, then write (unquoted heredoc → vars expand locally, then piped to the box)
ssh -p $SSH_PORT $HOST 'f=/usr/local/etc/xray/config.json; [ -s "$f" ] && cp "$f" "$f.bak.$(date +%s)"; :'
ssh -p $SSH_PORT $HOST 'cat > /usr/local/etc/xray/config.json' <<EOF
{
"inbounds": [{
"listen": "0.0.0.0",
"port": ${REALITY_PORT},
"protocol": "vless",
"settings": { "clients": [{ "id": "${UUID}", "flow": "xtls-rprx-vision" }], "decryption": "none" },
"streamSettings": {
"network": "tcp",
"security": "reality",
"realitySettings": {
"show": false,
"dest": "${SNI}:443",
"xver": 0,
"serverNames": ["${SNI}"],
"privateKey": "${PRIVATE_KEY}",
"shortIds": ["${SID}"]
}
}
}],
"outbounds": [{ "protocol": "freedom" }]
}
EOF
# 3.4 validate config BEFORE restart (hard stop on non-zero)
ssh -p $SSH_PORT $HOST 'xray run -test -c /usr/local/etc/xray/config.json' || { echo "config invalid"; exit 1; }
# 3.5 lock firewall (SSH-safe); ONLY restart xray if the firewall is confirmed
safe_ufw "$HOST" "$SSH_PORT" "allow ${REALITY_PORT}/tcp" \
&& ssh -p $SSH_PORT $HOST 'systemctl restart xray && systemctl enable xray' \
|| { echo "firewall not confirmed — xray NOT restarted"; exit 1; }
# 3.6 verify
ssh -p $SSH_PORT $HOST "ss -tlnp | grep -q :${REALITY_PORT} && echo LISTENING || echo NOT-LISTENING"
ssh -p $SSH_PORT $HOST 'journalctl -u xray -n20 --no-pager | grep -qiE "err|fail" && echo CHECK-LOG || echo CLEAN'
```
`HOST_IP` for the client link = the VPS public IP. Go to §5.
---
## 4 · Topology: RELAY + RESIDENTIAL EXIT
### 4A · The EXIT first (residential box)
```bash
# install gost v3, capture its absolute path
ssh -p $EXIT_SSH_PORT $EXIT_SSH 'curl -fsSL https://github.com/go-gost/gost/raw/master/install.sh | bash'
GOST=$(ssh -p $EXIT_SSH_PORT $EXIT_SSH 'command -v gost') ; [ -n "$GOST" ] || { echo "gost missing"; exit 1; }
# write a REAL systemd unit (absolute path; binds 0.0.0.0 — the firewall is the control)
ssh -p $EXIT_SSH_PORT $EXIT_SSH "cat > /etc/systemd/system/gost.service" <<EOF
[Unit]
Description=gost exit proxy
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=${GOST} -L "http://0.0.0.0:${EXIT_PORT}"
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target
EOF
# If the exit box has MULTIPLE egress IPs/interfaces, pin the one you want by editing
# the ExecStart above to: ${GOST} -L "http://0.0.0.0:${EXIT_PORT}?interface=EGRESS_IP"
# (EGRESS_IP = the home-ISP address you want the outbound call to leave from).
# LOCK THE FIREWALL BEFORE STARTING THE PROXY — and ONLY start gost if the lock is confirmed
# (a failed/inactive firewall must never leave a 0.0.0.0 proxy world-open)
safe_ufw "$EXIT_SSH" "$EXIT_SSH_PORT" "allow from ${RELAY} to any port ${EXIT_PORT} proto tcp" \
&& ssh -p $EXIT_SSH_PORT $EXIT_SSH 'systemctl daemon-reload && systemctl enable --now gost && systemctl is-active --quiet gost && echo GOST-UP' \
|| { echo "exit firewall not confirmed — gost NOT started (would be world-open)"; exit 1; }
```
**Verify the exit — this test MUST be run FROM THE RELAY** (its source IP is the one
allow-listed; your workstation is not):
```bash
ssh -p $RELAY_SSH_PORT $RELAY_SSH "curl -s --max-time 15 -x http://${EXIT}:${EXIT_PORT} https://api.ipify.org" # → the EXIT's IP
ssh -p $RELAY_SSH_PORT $RELAY_SSH "curl -s --max-time 15 -x http://${EXIT}:${EXIT_PORT} https://ipinfo.io/org" # → the exit's ISP/ASN
```
Connection-refused here almost always means gost isn't `0.0.0.0`-bound or the
allow-from-relay rule is missing. **Do not continue until the exit returns its own IP.**
### 4B · The RELAY (datacenter box)
Install xray and generate keys exactly as §3.1–3.2, but over `$RELAY_SSH` /
`$RELAY_SSH_PORT`. Then write the relay config (Reality in → routed out to the exit):
```bash
# GUARD (same as §3.3): abort locally if the relay already has a different config
ssh -p $RELAY_SSH_PORT $RELAY_SSH 'f=/usr/local/etc/xray/config.json; [ -s "$f" ] && grep -q "\"inbounds\"" "$f" && ! grep -q reality "$f" && exit 3; exit 0' \
|| { echo "STOP: existing non-reality config on the relay — ask the user before overwriting"; exit 1; }
ssh -p $RELAY_SSH_PORT $RELAY_SSH 'f=/usr/local/etc/xray/config.json; [ -s "$f" ] && cp "$f" "$f.bak.$(date +%s)"; :'
ssh -p $RELAY_SSH_PORT $RELAY_SSH 'cat > /usr/local/etc/xray/config.json' <<EOF
{
"inbounds": [{
"listen": "0.0.0.0", "port": ${REALITY_PORT}, "protocol": "vless", "tag": "in",
"settings": { "clients": [{ "id": "${UUID}", "flow": "xtls-rprx-vision" }], "decryption": "none" },
"streamSettings": { "network": "tcp", "security": "reality",
"realitySettings": { "show": false, "dest": "${SNI}:443", "xver": 0,
"serverNames": ["${SNI}"], "privateKey": "${PRIVATE_KEY}", "shortIds": ["${SID}"] } }
}],
"outbounds": [
{ "tag": "exit", "protocol": "http", "settings": { "servers": [{ "address": "${EXIT}", "port": ${EXIT_PORT} }] } },
{ "tag": "direct", "protocol": "freedom" }
],
"routing": { "rules": [{ "type": "field", "inboundTag": ["in"], "outboundTag": "exit" }] }
}
EOF
ssh -p $RELAY_SSH_PORT $RELAY_SSH 'xray run -test -c /usr/local/etc/xray/config.json' || { echo "relay config invalid"; exit 1; }
safe_ufw "$RELAY_SSH" "$RELAY_SSH_PORT" "allow ${REALITY_PORT}/tcp" \
&& ssh -p $RELAY_SSH_PORT $RELAY_SSH 'systemctl restart xray && systemctl enable xray' \
|| { echo "relay firewall not confirmed — xray NOT restarted"; exit 1; }
```
`HOST_IP` for the client link = the **relay** IP.
> Optional hardening for the relay→exit hop (plaintext HTTP CONNECT, ufw is the primary
> control): add proxy creds — listener `${GOST} -L "http://user:pass@0.0.0.0:${EXIT_PORT}"`
> and relay outbound `"users":[{"user":"user","pass":"pass"}]` inside the `exit` server.
---
## 5 · Client link
```bash
LINK="vless://${UUID}@HOST_IP:${REALITY_PORT}?security=reality&encryption=none&flow=xtls-rprx-vision&type=tcp&sni=${SNI}&fp=chrome&pbk=${PUBLIC_KEY}&sid=${SID}&spx=%2F#my-vpn"
echo "$LINK"
```
Replace `HOST_IP` with the single-VPS IP or the **relay** IP. Import into Shadowrocket /
v2rayN / Nekoray / sing-box. `sid` must exactly equal the server's `shortIds` entry.
---
## 6 · Verify end-to-end
1. **Listening:** `ss -tlnp | grep :${REALITY_PORT}` on each xray box.
2. **Connect** a client with the link.
3. **Egress (from the client, tunnel on):** `curl https://api.ipify.org`
- single → the VPS IP.
- relay+exit → the **exit's** IP (a consumer-ISP address), **not** the relay's. If it
returns the relay IP, the routing rule isn't matching — recheck inbound `tag:"in"`
and the rule's `inboundTag`.
4. **Handshake:** `journalctl -u xray -n50 --no-pager` shows no Reality/TLS errors.
---
## 7 · Troubleshooting & rollback
- **No connect:** port open end-to-end? (`ufw status`, provider SG). `dest` host and
`serverNames` must match: **`dest` is `host:443`, `serverNames` is the bare host, no
port.** The SNI must serve TLS 1.3 (`www.apple.com` is a safe default).
- **Egress = relay IP:** routing rule not matching (see §6.3).
- **Exit refused from relay:** exit firewall must `allow from $RELAY`; `systemctl
is-active gost` must be true; gost must bind `0.0.0.0`.
- **Rollback a box (by spec, never by index):**
```bash
ssh -p $SSH_PORT $HOST 'systemctl stop xray 2>/dev/null; f=/usr/local/etc/xray/config.json; ls -t $f.bak.* 2>/dev/null | head -1 | xargs -r -I{} cp {} $f'
ssh -p $SSH_PORT $HOST "ufw delete allow ${REALITY_PORT}/tcp" # NEVER delete the ssh allow rule
# exit: ssh ... "systemctl disable --now gost; ufw delete allow from ${RELAY} to any port ${EXIT_PORT} proto tcp"
```
If ever locked out, use the provider console/VNC to fix `ufw`.
---
## 8 · Benchmark a candidate VPS (run BEFORE you commit to a box)
Spin up an hourly/trial box, run the **identical** four tests on every candidate, then
destroy it — same params each time is what makes numbers comparable. Prefix the heavy
tests with `nice`/`ionice` so a benchmark can't starve a live service, and **benchmark
disks against a temp file, never a raw block device.**
```bash
# CPU — single- then all-core; "events per second" is the score
sudo apt install -y sysbench
nice -n19 sysbench cpu --cpu-max-prime=20000 --threads=1 --time=10 run
nice -n19 sysbench cpu --cpu-max-prime=20000 --threads=$(nproc) --time=10 run
# Memory bandwidth — read then write
sysbench memory --memory-block-size=1M --memory-total-size=10G --memory-oper=read run
sysbench memory --memory-block-size=1M --memory-total-size=10G --memory-oper=write run
# Disk — 4K random IOPS against a TEMP FILE (where budget tiers hide a ~2000-IOPS cap)
sudo apt install -y fio
nice -n19 ionice -c3 fio --name=r --filename=./t --size=256M --direct=1 --ioengine=libaio \
--iodepth=16 --bs=4k --rw=randread --runtime=10 --time_based --group_reporting
nice -n19 ionice -c3 fio --name=w --filename=./t --size=256M --direct=1 --ioengine=libaio \
--iodepth=16 --bs=4k --rw=randwrite --runtime=10 --time_based --group_reporting
rm -f ./t
# OpenVZ/LXC (no libaio/O_DIRECT): swap `--ioengine=psync` and drop `--direct=1`
# (page cache flatters the result — read it as a ceiling, not real speed).
# Network — real download throughput (bytes/sec ×8 ÷ 1e6 = Mbps); big payload, run 2–3×
curl -o /dev/null -w '%{speed_download}\n' --max-time 30 \
'https://speed.cloudflare.com/__down?bytes=524288000'
# Route quality — latency + loss, then the path class (which backbone it rides).
# Target = the network you care about (e.g. a China CT/CU/CM endpoint); 203.0.113.1 is a placeholder.
ping -c 20 203.0.113.1 # round-trip latency + packet loss %
curl nexttrace.io/nt -Lo nt && sudo bash nt # install nexttrace once
nexttrace -M 203.0.113.1 # traced hops → premium (e.g. CN2) vs standard route
```
Score each metric as a **% of the best box you've tested**, then average for one 0–100
rating (`value ÷ column-best × 100`). Disk read AND write both pinned near a flat
~2,000 IOPS is a provider QoS cap, not real speed — a genuine NVMe allowance does tens
of thousands. Re-run CPU at a few different hours to catch an oversubscribed host.
---
## Notes
- **SNI choice matters.** A weak/edge SNI can get a fresh IP's Reality handshake
throttled or blocked. Prefer a strong, ubiquitous TLS-1.3 site; keep `dest` host and
`serverNames` identical.
- **Vision scope.** `xtls-rprx-vision` hardens the **client↔relay** hop only; the
relay→exit leg is plain HTTP CONNECT (ufw-gated) and doesn't get Vision's zero-copy path.
- **shortId:** even-length hex, ≤16 chars (`openssl rand -hex 8` = 16 = the max).
- **More exits:** give each its own relay port + `shortId` + `exit` outbound + routing
rule; a client picks an exit by which relay port it connects to.
- **Ports:** if a port behaves badly on a fresh IP, try another high port before blaming
the backend.
Download it and install it wherever your agent loads skills — a Claude Code skill folder, a Codex prompt, or just a file you reference by path. It's a task the agent runs on request, so keep it out of your always-on system prompt.
Source + issues on GitHub. New to the architecture? Start with building a network.