Setting up a personal VLESS VPN on GCP with XTLS-Reality
/ 18 min read
Why bother with VLESS when WireGuard and OpenVPN already exist? A few reasons.
The EU is moving to restrict VPN use, and the proposals on the table target exactly the kind of traffic WireGuard and OpenVPN produce — protocols with distinctive network signatures that DPI boxes can fingerprint in milliseconds. Whatever you think of the policy, the practical implication is that the VPN app on your phone right now has a shelf life.
And the bigger trend behind it: every government wants to be China. China is the one country that actually pulled off building its own internet, and every regulator that watched it happen took notes. By 2030 the “global internet” will be sharded into a handful of loosely connected regional nets, each with its own rules about what’s allowed to cross the border.
China started, Russia followed, the EU is catching up. The US is heading in that direction too, though with a twist: they’re making platforms liable for what users do via VPN. The EU is taking notes.
If you want access to all of them from wherever you happen to be, you need a protocol that censors can’t fingerprint — and the only one that’s consistently survived contact with the Great Firewall is VLESS over XTLS-Reality.
So: a real-world walkthrough of running your own censorship-resistant proxy on Google Cloud — including all the gotchas I hit (and the Claude tokens I burned through figuring them out) so you don’t have to.
What you’ll build
- A GCP VM running the 3X-UI panel with an XTLS-Reality inbound on port 443
- A WARP outbound for routing specific traffic through CloudFlare instead of your server IP
- A working
vless://URL you can paste into any modern client
Theory: what are VLESS, XTLS-Reality, Xray, and 3X-UI?
Before the setup, a quick map of the landscape. If you’ve used mainstream VPN tools like Outline, AmneziaVPN, or a commercial WireGuard client, some of this is new.
Why not just use WireGuard or OpenVPN?
WireGuard and OpenVPN are great protocols — fast, well-audited, easy to set up. But they were designed for corporate remote access, not censorship circumvention. They have distinctive network signatures (packet sizes, handshake patterns, port usage) that deep packet inspection (DPI) systems can identify and block. Countries that actively censor the internet have been blocking these protocols for years.
Shadowsocks tried to fix this by encrypting traffic so it looks random. That worked for a while until DPI systems got smart enough to detect “random-looking” traffic as suspicious by itself. If your traffic doesn’t look like anything recognizable, it looks like a proxy.
The modern approach: pretend to be HTTPS
The current generation of censorship-resistant protocols takes a different approach: don’t hide that you’re doing TLS, hide that you’re doing a proxy. HTTPS to a major website is the most common traffic on the internet. If your proxy traffic is indistinguishable from HTTPS to Google, there’s nothing for DPI to flag.
This is what VLESS does. It’s a minimal proxy protocol (successor to VMess) designed to run inside a real TLS connection to what looks like a real website. The protocol itself is thin and fast — the security comes from the TLS layer around it.
XTLS-Reality: the clever part
Normal VLESS-over-TLS has a subtle problem: when you encrypt already-encrypted traffic (like HTTPS pages), you get a detectable “TLS-in-TLS” signature. DPI systems can spot this.
XTLS-Reality solves both problems at once:
- No TLS-in-TLS: XTLS detects when the inner traffic is already TLS 1.3 encrypted and skips its own encryption layer.
- Real certificate from a real site: Reality proxies the TLS handshake to an actual third-party website (like
www.microsoft.com). The client sees that real site’s real certificate. If a censor connects to your server to probe, they get forwarded to the real site and see a legitimate website. There’s no fake certificate to detect.
So an observer looking at your traffic sees what appears to be a normal HTTPS connection to Microsoft. Your server IP looks like it’s hosting a Microsoft website. There’s no protocol fingerprint, no weird certificate, no detectable proxy pattern.
As of 2026, Reality is not blocked anywhere by DPI alone. Blocks happen only when operators break the rules. One of the big ones is sharing too widely.
Why sharing breaks Reality
The protocol itself stays invisible, but traffic patterns don’t. A single user’s proxy looks like one person browsing one website — unremarkable. Ten of your friends sharing the same server looks similar. A hundred people looks like a business with employees.
But a thousand strangers connecting to what’s supposedly a static Microsoft marketing page is a giant red flag:
- Real websites have a bell curve of traffic across geographies and times. A proxy has concentrated traffic from one country at predictable hours.
- Real HTTPS sessions are short and bursty (page load, done). Proxy sessions are long and steady (minutes to hours of streaming).
- Real visitors to
www.microsoft.comdon’t do much. Proxy users transfer gigabytes. - TLS connection counts and byte volumes to a single IP skew way outside normal website statistics.
DPI systems don’t need to decrypt the traffic to notice any of this. Researchers have published papers on detecting proxy servers purely from connection-metadata anomalies — exactly what operators in China do to identify and block servers before they get widely used.
So the practical rule: keep it under ~10 people who are geographically dispersed. Your traffic stays in the noise floor of normal internet activity. Once you start listing your server in public Telegram channels or selling access, you’ve turned it into a detectable anomaly — the protocol can’t save you from that.
Xray vs 3X-UI
Xray-core is the actual proxy server software — a fork of the original V2Ray project. It implements VLESS, VMess, Trojan, Shadowsocks, XTLS, Reality, and a dozen other protocols. It’s a single Go binary configured by a JSON file. Powerful but fiddly to configure by hand.
3X-UI is a web panel that generates Xray configurations for you. You click buttons in a UI, it writes the Xray JSON, restarts the Xray process, and shows you traffic stats. It’s a fork of the original X-UI project with more features and better maintenance.
When you see “inbound” and “outbound” in 3X-UI, those are Xray terms:
- Inbound: a port/protocol your server listens on for clients
- Outbound: where your server sends traffic after receiving it (direct to the internet, through WARP, blocked, etc.)
Where WARP fits in
CloudFlare WARP is a free VPN service by CloudFlare. On a server, you can run it as a local SOCKS/WireGuard proxy. Your Xray config uses it as one of its possible outbounds.
Why would you send traffic through WARP instead of directly out your server?
- Some services block or rate-limit datacenter IPs. Going through WARP makes you look like a residential CloudFlare user.
- You want to avoid your VPS IP appearing in access logs of specific destinations.
- You want to keep your VPS IP “clean” — only used for the Reality masquerade, never making outbound requests that could link it to suspicious activity.
WARP is optional. You can skip it and route all traffic directly. But it’s easy to set up and useful, so this guide includes it.
Prerequisites
- A Google Cloud Platform account
- A credit card (GCP’s free tier covers part of it)
- ~30 minutes
Part 1 — Create the GCP VM
- Go to Google Cloud Console → Compute Engine → Create Instance
- Configuration:
- Name:
proxy-server(or whatever) - Region: anywhere outside your country
- Machine type:
e2-small(~$13/month) ore2-microfor free tier - Boot disk: Debian 12, 10 GB SSD
- Firewall: check both Allow HTTP and Allow HTTPS
- Name:
- Click Create
- Note the External IP — you’ll use it everywhere
- Optional: reserve a static IP (Networking → IP Addresses) so it doesn’t change on restart
Open the firewall ports
GCP has its own firewall separate from the OS. Go to VPC Network → Firewall → Create Firewall Rule:
- Name:
allow-proxy-ports - Direction: Ingress
- Targets: All instances in the network
- Source IP ranges:
0.0.0.0/0 - Protocols and ports: TCP →
443, 11223
(Replace 11223 with whatever random port you pick for the 3X-UI panel.)
Part 2 — Install 3X-UI
SSH into the VM (GCP’s browser SSH button works fine). Then:
sudo apt update && sudo apt full-upgrade -ysudo rebootReconnect after ~30 seconds, then:
sudo apt install docker.io docker-compose git curl bash openssl nano -yClone 3X-UI and start it:
git clone https://github.com/MHSanaei/3x-ui.gitcd 3x-uisudo docker-compose up -dThis pulls the latest image tag. As of this writing that’s 3X-UI v2.9.x bundled with Xray-core v26.4.x.
A word on version pinning
The docker-compose.yml references ghcr.io/mhsanaei/3x-ui:latest. Pinning to an older image tag (e.g. v2.0.2) was the recommended fix in earlier guides because the current Xray ships with a few behaviours that break compatibility with popular client apps:
- Post-quantum TLS (X25519MLKEM768) in the Reality target — current client apps (V2Box, Hiddify, Streisand, FoxRay, Nekobox) all fail the handshake with
tls: unknown certificate. The server sees repeated connection attempts that never complete. - Vision Seed defaults (
900, 500, 900, 256) that some clients stumble on - mldsa65 post-quantum signature fields that, if populated, produce URLs like
vless://...encryption=mlkem768x25519plus.native.0rtt...which most client apps refuse to parse
Rather than pinning to an old version and missing bug fixes, you can stay on latest and avoid these features in the inbound configuration. The Gotcha callouts in Part 7 show exactly what to leave empty. This is the approach this guide uses.
If you’d rather downgrade, change the image tag in docker-compose.yml before running docker-compose up -d:
image: ghcr.io/mhsanaei/3x-ui:v2.0.2Just be aware you’ll miss security fixes and later client compatibility improvements.
Verify it’s running:
sudo docker psNote the container name (likely 3xui_app or 3x-ui-3x-ui-1).
Gotcha: sudo bash <(curl ...) doesn’t work
You’ll see this pattern in many guides. It breaks under sudo because process substitution doesn’t survive privilege escalation. Download first, then run:
curl -sSL https://example.com/some-script.sh -o /tmp/script.shsudo bash /tmp/script.shPart 3 — Install WARP
The standard installer:
curl -sSL https://raw.githubusercontent.com/hamid-gh98/x-ui-scripts/main/install_warp_proxy.sh -o /tmp/install_warp.shsudo bash /tmp/install_warp.shEnter 40000 when prompted for a port.
Note: guides often say to run warp u first to uninstall an existing WARP. Fresh GCP VMs don’t have WARP, so that command fails with “command not found”. Ignore it.
Part 4 — Generate the panel TLS certificate
The 3X-UI panel needs a certificate for HTTPS. Self-signed is fine for personal use.
cd ~openssl req -x509 -newkey rsa:4096 -nodes -sha256 -keyout private.key -out public.key -days 3650 -subj "/CN=APP"Copy into the container:
sudo docker cp private.key 3xui_app:/private.keysudo docker cp public.key 3xui_app:/public.keyGotcha: wrong container name
If you get No such container: 3xui_app:, your container has a different name. Find it:
sudo docker ps --format "{{.Names}}"Use that name in the docker cp commands.
Part 5 — Configure the panel
Open in browser: http://YOUR_IP:2053/ (HTTP, not HTTPS yet).
Login with admin / admin.
Go to Panel Settings:
- Panel Port: pick a random number like
11223(not40000— that’s WARP’s port) - Panel Certificate Public Key Path:
/public.key - Panel Certificate Private Key Path:
/private.key - Panel URL Root Path:
/somerandomsecret/(make up your own, must start and end with/)
Save → Restart Panel.
Reconnect at: https://YOUR_IP:11223/somerandomsecret/ (now HTTPS, with your custom port and path). Accept the self-signed cert warning.
Go to Security Settings and change the admin password.
Gotcha: 404 after saving settings
After saving, the panel URL changes completely. If you get 404:
-
Check you’re using https (not http)
-
Check the new port — and watch this closely, because it can shift between restarts. If you set a port but the panel restart fails (bad cert path, port conflict, etc.), 3X-UI silently falls back to another port. Always confirm the actual listening port before troubleshooting anything else:
Terminal window sudo docker logs 3xui_app 2>&1 | grep "Web server running"That tells you the port Xray/x-ui is actually serving on right now, which may not match what you typed in the UI.
-
Include the secret path with trailing slash
-
Confirm your cloud provider’s firewall allows TCP traffic on that exact port. Some providers (GCP, AWS, Oracle Cloud) have a separate firewall layer outside the VM. If you changed the panel port from
2053to11223, you need to open11223in the provider firewall too — and opening “HTTPS” in the provider console usually only opens443, not your custom port.
If you forgot what you set, recover it from the database:
sudo docker exec 3xui_app grep -a "webBasePath" /etc/x-ui/x-ui.dbOr reset to defaults:
sudo docker exec 3xui_app /app/x-ui setting -resetsudo docker exec 3xui_app /app/x-ui setting -username admin -password adminsudo docker restart 3xui_appPart 6 — Configure the WARP outbound
Gotcha: WARP IPv6 on GCP
GCP VMs don’t have IPv6 by default. The WARP installer sets up an IPv6 endpoint that will never work, flooding your logs with errors like:
Failed to send handshake initiation: write udp [::]:43058->[2606:4700:d0::a29f:c001]:2408: sendto: network is unreachableIn the panel → Xray Settings → Outbounds → click the WARP button at top. If no outbound exists yet, click Add Outbound in the WARP dialog.
Then edit the WARP outbound:
- Address: remove IPv6, keep only
172.16.0.2/32 - Endpoint: change
engage.cloudflareclient.com:2408to162.159.193.10:2408(forces IPv4) - Allowed IPs: remove
::/0, keep only0.0.0.0/0
Save → Restart Xray. Test WARP with the lightning bolt icon — should pass.
Part 7 — Create the VLESS Reality inbound
Inbounds → Add Inbound:
| Field | Value |
|---|---|
| Protocol | vless |
| Listen IP | LEAVE EMPTY |
| Port | 443 |
| Client Email | any identifier |
| Flow | xtls-rprx-vision (appears after selecting Reality) |
| Transmission | TCP (RAW) |
| Security | Reality |
| uTLS | chrome |
| Target | www.microsoft.com:443 |
| SNI / Server Names | www.microsoft.com |
| Public/Private Key | click Get New Cert |
| mldsa65 Seed / Verify | LEAVE EMPTY — don’t click Get New Seed |
| Min/Max Client Ver | LEAVE EMPTY |
| Vision Seed | click Reset to clear defaults |
Gotcha: bind: cannot assign requested address
If you set Listen IP to your external GCP IP, Xray will fail to bind because GCP uses NAT — the external IP isn’t actually on any network interface on the VM. Leave Listen IP empty so Xray binds to 0.0.0.0 (all interfaces).
Check logs and port:
sudo docker logs 3xui_app 2>&1 | tail -10sudo ss -tlnp | grep 443You should see xray-linux-amd6 listening on *:443.
Gotcha: post-quantum TLS breaks current clients
3X-UI v2.9.3+ ships with Xray 26+ which enables post-quantum TLS (X25519MLKEM768) by default. Current client apps (V2Box, Hiddify, Streisand, etc.) can’t handle it and fail with tls: unknown certificate.
Symptoms:
- Client URL contains
encryption=mlkem768x25519plus.native.0rtt— that’s the marker - Clients connect but fail during handshake
- Server logs show
TLS handshake error ... tls: unknown certificate
To avoid it:
-
Leave mldsa65 Seed and mldsa65 Verify empty (don’t click Get New Seed)
-
Leave Min/Max Client Ver empty
-
Pick a donor site that doesn’t negotiate post-quantum. Test with:
Terminal window sudo docker exec 3xui_app /app/bin/xray-linux-amd64 tls ping www.microsoft.comIf the output says
TLS Post-Quantum key exchange: true, pick a different donor. Most Google properties now use post-quantum. Microsoft properties currently don’t.
Choosing a donor site
The “Target” and “SNI” fields decide which real website your server masquerades as. Requirements:
- Supports TLS 1.3 and HTTP/2
- Has a proper landing page (not a redirect)
- Isn’t negotiating post-quantum TLS (see above)
- Ideally hosted in the same cloud provider as your server, for latency
Safe choices: www.microsoft.com, www.asus.com, www.samsung.com, www.cisco.com, www.amd.com, www.nvidia.com.
Things to watch out for:
- Redirects break everything. Country-localised subdomains like
www.microsoft.co.ukorwww.google.co.ukusually 301 to their main site, which breaks the Reality handshake. Always test withcurl -I https://your-donor-sitefirst — if you see a301/302response, pick a different site. - Some big sites actively block proxy misuse. Amazon (including
www.amazon.com,www.amazon.co.uk) caught on to people using their domains as Reality donors and now behaves in ways that break the handshake. It worked a year ago, it doesn’t anymore. Same story will probably happen to other popular choices over time — if a site that worked stops working, try a different one. - Your own domains are a bad idea. The whole point is that a censor investigating the site can’t link it to you. Using
yourname.comdefeats that. - Small sites are fragile — if they change their TLS config or renew their cert, your proxy breaks with no warning.
Part 8 — Routing rules
⚠️ This part is not optional. If you skip it, your proxy will technically work but will be much easier to identify and block. Read on for why.
The “round trip” detection problem
Here’s the pattern censorship systems look for to find proxy servers without decrypting anything:
- Alice in country X browses a website hosted in country X
- Her request goes: X → your VPS (outside X) → back to country X
- The censor sees a foreign IP making lots of requests back into their country at residential-internet hours
That U-shape is a dead giveaway for a proxy. A real foreign tourist browsing the site wouldn’t do it at that volume and consistency. China’s Great Firewall already uses this signal to identify and block proxy servers before they ever get reported. Other countries will learn the trick.
The fix is: when the proxy needs to fetch a site hosted in your country, don’t let your VPS make the request directly. Route it through CloudFlare WARP, so the fetch looks like it originated from CloudFlare’s network instead of your VPS IP. The U-shape collapses. No round trip is visible.
The same logic applies to any site where having your VPS IP in the access logs would be bad — whether for pattern-matching reasons or because the destination actively flags datacenter IPs (Google, banks, some government sites).
The default routing rules
3X-UI starts you with three default rules. Don’t change these:
inboundTag: api→api(internal panel traffic)ip: geoip:private→blocked(private network addresses)protocol: bittorrent→blocked(BitTorrent traffic — keep this to avoid complaints to your cloud provider)
Rules you should add
In Xray Settings → Routing Rules, add these two rules targeted at your country. The examples below use ru as the country code — replace with your actual country code (cn for China, ir for Iran, by for Belarus, etc.):
Rule 4 — route your country’s domains through WARP:
- Domain:
geosite:category-gov-ru,regexp:.*\.ru$ - Outbound Tag:
warp
Rule 5 — route your country’s IPs through WARP:
- IP:
geoip:ru - Outbound Tag:
warp
Rule 4 catches traffic by domain name (anything ending in .ru, plus the curated list of .ru government sites). Rule 5 catches traffic by destination IP in case DNS doesn’t give a matching domain. You need both.
Optional additions if you also want to avoid datacenter-IP blocks on western services:
- Domain:
geosite:google,geosite:openai→warp— some Google and OpenAI services limit or block datacenter IPs
Save Settings → Restart Xray.
Verifying the WARP routing works
SSH to your server and watch logs while you browse a site in your country from the client:
sudo docker logs -f 3xui_app 2>&1 | grep -i warpYou should see connections being dispatched via the warp outbound. If everything goes through direct, your rules aren’t matching — double-check the country code and rule positions.
Part 9 — Export and test
Export the Reality URL: Inbounds → three dots on your Reality inbound → Export All URLs.
Gotcha: auto-filled hostname in exported URL
If you access the panel via a domain name, the exported URL will use that domain — but Reality needs a direct IP connection. Manually replace the domain with the server’s IP in the URL:
vless://UUID@YOUR.SERVER.IP.HERE:443?type=tcp&encryption=none&security=reality&pbk=...Client apps
Both apps below are free.
For macOS: Rabbithole VPN Client — App Store, modern UI, handles Reality well. My daily driver on Mac.
For iOS: Streisand — App Store, clean UI, good VLESS/Reality support. My daily driver on phone.
Other clients I tested on macOS that also work:
- Command-line xray (
brew install xray) — best for troubleshooting, shows detailed debug info - FoxRay — has TUN mode on macOS (Rabbithole/Streisand don’t)
- V2Box — works once you clear post-quantum fields on the server
- Hiddify — had issues parsing newer URL formats in my testing
Paste the vless:// URL into the app, connect, then verify at ipinfo.io — should show your GCP IP.
Debugging commands cheat sheet
# What ports are listeningsudo ss -tlnp
# Xray logs (follow mode)sudo docker logs -f 3xui_app 2>&1 | tail -30
# Current Xray configsudo docker exec 3xui_app cat /app/bin/config.json
# Check if donor site supports TLS 1.3 and post-quantum statussudo docker exec 3xui_app /app/bin/xray-linux-amd64 tls ping www.microsoft.com
# Verify Reality key pair matches (regenerate public from private)sudo docker exec 3xui_app /app/bin/xray-linux-amd64 x25519 -i "YOUR_PRIVATE_KEY"
# Restart containersudo docker restart 3xui_appTest with command-line xray when clients misbehave
When client apps fail mysteriously, test from the command line on your Mac to isolate server vs client issues.
Install xray:
brew install xrayCreate ~/xray-test.json:
{ "log": { "loglevel": "debug" }, "inbounds": [ { "port": 10808, "listen": "127.0.0.1", "protocol": "socks", "settings": { "udp": true } } ], "outbounds": [ { "protocol": "vless", "settings": { "vnext": [{ "address": "YOUR.SERVER.IP", "port": 443, "users": [{ "id": "YOUR_UUID", "flow": "xtls-rprx-vision", "encryption": "none" }] }] }, "streamSettings": { "network": "tcp", "security": "reality", "realitySettings": { "fingerprint": "chrome", "serverName": "www.microsoft.com", "publicKey": "YOUR_PUBLIC_KEY", "shortId": "YOUR_SHORT_ID", "spiderX": "/" } } } ]}Run:
xray run -c ~/xray-test.jsonIn another terminal:
curl -x socks5://127.0.0.1:10808 https://ipinfo.io/ipShould return your GCP IP. If it does, the server works and any client app problems are on the client side.
Operational notes
- GCP costs:
e2-smallis about $13/month. Set a billing alert. Traffic is 1 GB egress free per month, then ~$0.12/GB. Watch your usage. - Reverse DNS: GCP lets you set a PTR record on the VM page. Change it to match your donor site’s domain for better masquerading.
- SSH port: move SSH off port 22 to something in the 40000+ range. Port 22 is a common DPI trigger.
- Don’t install other VPN protocols on this VPS. WireGuard/OpenVPN on the same IP would ruin the Reality masquerade.
- Don’t share widely. A small number of users is invisible. A hundred users routing through one IP becomes an anomaly.
Summary of gotchas
- Download script +
sudo bash— neversudo bash <(...) - Find the actual Docker container name before using
docker cp - Panel URL changes after saving — remember the port and path
- Remove IPv6 from WARP on GCP (no IPv6 on the VM)
- Leave Listen IP empty on inbounds — GCP external IP is NAT’d
- Post-quantum TLS breaks current clients — leave mldsa65 empty, pick donor sites carefully
- Exported URL uses the hostname you used to reach the panel — swap to the IP manually
- Check
xray tls ping DONORbefore picking a donor site
Credits
Based on articles by MiraclePTR and the “Personal proxy for dummies” author on Habr: