Setting up your own VPN is not a task «for geeks». The whole process takes about half an hour and costs roughly as much as a cup of coffee per month. In return you get a channel that belongs to you alone: it isn't crowded with other people's traffic, it doesn't sell your browsing history, and it won't be shut down one day along with yet another popular service.

This guide walks through the entire path step by step: how to set up a VPN on a server from scratch, from buying a VPS to a working connection on your phone and computer. We built a VLESS + WebSocket over Cloudflare Tunnel setup — it takes more effort than the popular REALITY option, but it has one decisive advantage: it works where the alternatives fall apart, including mobile networks with active blocking. We'll explain why, and show the difference with real measurements.

Every command in this article was executed on a live server rather than copied from documentation: the outputs, the errors and the pitfalls are all real.

What you'll need

  • A VPS outside your country — the cheapest plan will do, a VPN barely consumes any resources.
  • A domain — any cheap one. It is required for the Cloudflare tunnel.
  • A Cloudflare account — free, the Free plan covers everything we need.
  • 30 minutes of your time. No sysadmin experience required: every command is ready to copy and paste.

Choosing a server

The hardware requirements are minimal — 1 CPU core, 1-2 GB of RAM, any disk. Other things matter more:

  • Location. The closer the server is to you physically, the lower the latency. Neighbouring countries typically give 40-90 ms, while the US or Singapore will add 150-250 ms, and that is noticeable.
  • Operating system. Go with Ubuntu 24.04 LTS — all commands below are written for it. Debian 12 works exactly the same way.
  • IPv6. Make sure your provider assigns an IPv6 address — almost all of them do it for free. You will need it, and there is a dedicated section about that below.

Step 1. Buying a server

We did everything on Aeza — all screenshots come from there. Any other provider will do, the sequence of steps is the same everywhere.

Location

First, choose where the server will live. The panel shows the expected latency right away:

Choosing a server location for your own VPN: list of cities with latency and price

Pick a location outside the country whose restrictions you want to bypass. In our case the best speed-to-price ratio came from Stockholm and Helsinki at 75 ms; Frankfurt gives 85 ms and Amsterdam 99 ms. London turned out to be the most expensive option.

Plan

Take the smallest one — it is more than enough for a VPN:

Choosing the minimal VPS plan for a VPN: 1 core, 2 GB RAM, 30 GB NVMe

The SWEs-1 plan: 1 core, 2 GB of RAM, 30 GB NVMe and up to 1 Gbit/s — around 6 USD per month. Hourly billing is also available, which is handy if you want to try things out without paying for a whole month upfront. Paying for three months in advance comes with a discount.

Operating system

Selecting Ubuntu 24.04 as the operating system when ordering a VPN server

Choose Ubuntu 24.04 — every command in this article is written for it. Debian 12 in the neighbouring option works identically.

Connection details

The server is created within a couple of minutes, and the credentials appear in the panel:

SSH connection details for the server: IP address, root login and password

You need three things: the IP address, the login (usually root) and the password. Save them — without them you cannot reach the server.

Don't overlook IPv6

On the «IP addresses» tab you can see that an IPv6 subnet is assigned along with the server:

An IPv6 address is assigned together with the server when buying a VPS

It comes for free and it is worth using: there is a separate section below explaining exactly why IPv6 matters and which services refuse to work without it.

Step 2. Domain and Cloudflare

The domain only acts as a signboard for the tunnel — you won't be hosting a website on it. Any cheap zone works, and so does any registrar: we registered ours with the same hosting provider to keep everything in one panel.

Registering a domain for a VPN over Cloudflare Tunnel

Adding the domain to Cloudflare

Sign up at dash.cloudflare.com and click Add a Site:

Adding a domain to Cloudflare using the Add a Site button

Enter the domain name, press Continue and choose the Free plan — its capabilities are sufficient:

Entering the domain and selecting the free Cloudflare plan

Cloudflare will scan the existing DNS records and show them as a list.

You don't need to add anything here. If the domain is new and the list is empty, that's exactly as expected — carry on. If the registrar has already created its own records (a parking page A record, for instance), they are not needed for the VPN: delete them or simply leave them, they won't interfere with the tunnel.

One point deserves special attention, because this is where beginners stumble: do not create an A record pointing to your server's IP. This contradicts the familiar logic of «a domain must point at a server», but with a tunnel the server address is never published in DNS at all — that is the whole point. The required record will be created later by the cloudflared tunnel route dns command, and it will appear in the panel with the type Tunnel and the Proxied status. That is correct, and you must not switch it to «DNS only» — the tunnel would stop working.

«DNS only» (the grey cloud) is needed in exactly one case: if besides the tunnel you keep something on the same domain that works directly over IP — a regular website, mail, or a second VPN profile on port 443. In that case the corresponding A record has to stay grey, otherwise Cloudflare will proxy it and direct connections will break.

Changing NS records at the registrar

Open the panel where the domain was bought, find the DNS server settings and replace the current values with the two addresses from Cloudflare:

Replacing the domain NS servers with Cloudflare nameservers in the registrar panel

Two tips that will save you hours of waiting:

  • The two addresses must differ. If you accidentally enter the same one twice, the domain registry will silently reject the change, and you will keep staring at unchanged records wondering why nothing happens. Check with your own eyes that the first and the second address are different.
  • Propagation takes from 15 minutes to several hours. The registrar panel may show the new values long before the actual delegation updates.

You can check the real state from the server:

dig NS your-domain.com +trace

Once the reply contains the Cloudflare addresses, you're good. Cloudflare will also send an email confirming that the domain is active.

Step 3. Connecting to the server

Servers are managed over SSH — a secure connection to the command line.

Windows: open PowerShell (right-click Start → Terminal) and type, substituting your address:

ssh root@203.0.113.10

macOS and Linux: the same command in Terminal.

Android and iOS: install the free Termius app, add a new host, enter the IP, login and password.

On the first connection the system will ask you to confirm the server's authenticity — type yes. Then enter the password. Note that nothing is displayed on screen while you type the password, not even asterisks — that's normal. A prompt like root@server:~# means you're in.

Step 4. Installing Xray

Xray is the program that will serve your connections. First update the package list and install what the installer needs:

apt update && apt install -y curl unzip

Do not skip this command. On a fresh server the package list is empty, and the Xray installer fails with error: Installation of unzip failed, please check your network — even though the network is perfectly fine. We ran into this while verifying this very guide.

Now the installation itself:

bash -c "$(curl -L https://github.com/XTLS/Xray-install/raw/main/install-release.sh)" @ install

Verify it:

xray version

You should see a line like Xray 26.3.27 ... A unified platform for anti-censorship.

Step 5. Configuring Xray

We only need one value — the user identifier, which doubles as your VPN password:

xray uuid

You'll get a string like 06a8ca77-f130-44f4-9773-29b88e79e893. Copy it somewhere safe.

Open the configuration file:

nano /usr/local/etc/xray/config.json

Delete everything inside (hold Ctrl+K until the file is empty) and paste the following, substituting your UUID:

{
  "log": { "loglevel": "warning" },
  "inbounds": [
    {
      "listen": "127.0.0.1",
      "port": 10000,
      "protocol": "vless",
      "settings": {
        "clients": [ { "id": "YOUR_UUID", "email": "main" } ],
        "decryption": "none"
      },
      "streamSettings": {
        "network": "ws",
        "wsSettings": { "path": "/ws-7f3a91" }
      },
      "sniffing": { "enabled": true, "destOverride": ["http", "tls"] }
    }
  ],
  "outbounds": [
    { "tag": "v4", "protocol": "freedom", "settings": { "domainStrategy": "UseIPv4v6" } },
    { "tag": "v6", "protocol": "freedom", "settings": { "domainStrategy": "UseIPv6v4" } }
  ],
  "routing": {
    "rules": [
      {
        "type": "field",
        "domain": ["domain:claude.ai", "domain:claude.com", "domain:anthropic.com"],
        "outboundTag": "v6"
      }
    ]
  }
}

Save with Ctrl+O, Enter, Ctrl+X.

Note three details. We listen on 127.0.0.1 only — this port must not be exposed, the connection arrives from the inside through the tunnel. Replace the /ws-7f3a91 path with your own random one, the less obvious the better. The block with two outbounds and a routing rule gets its own section below; for now just leave it as is.

Check and start:

xray -test -config /usr/local/etc/xray/config.json
systemctl restart xray
systemctl status xray

Important: use restart, not enable --now. The installer has already started Xray with its own empty config, so the start command does nothing — the service reports active (running), your config is never applied and the VPN doesn't work. This is the nastiest trap in the whole setup: everything looks fine while the port isn't listening at all. You can check like this:

ss -ltnp | grep 10000

If the line is there, your config has been applied.

Step 6. Cloudflare Tunnel

Now we connect the server to the Cloudflare network. The server establishes an outbound connection itself — no inbound ports need to be opened, and the real IP of the server is never published anywhere.

Installation

curl -sL -o /tmp/cf.deb https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
dpkg -i /tmp/cf.deb
cloudflared --version

Authorisation

cloudflared tunnel login

The command prints a link. Open it in a browser where you are logged into Cloudflare and confirm access for your domain. The certificate will be downloaded to the server automatically — no API keys to create anywhere.

Click the confirmation immediately. If you take a few minutes, the session expires and the download fails with Failed to fetch resource while the network is perfectly healthy. The fix is to run the command again and click straight away.

Creating the tunnel

cloudflared tunnel create my-vpn

The output contains the tunnel identifier — a long string like 202d14e6-f484-4c06-bcc0-63e96faad983. Now create the configuration file:

nano /root/.cloudflared/config.yml

Paste this, substituting your identifier, subdomain and path:

tunnel: YOUR_TUNNEL_ID
credentials-file: /root/.cloudflared/YOUR_TUNNEL_ID.json

ingress:
  - hostname: vpn.your-domain.com
    service: http://127.0.0.1:10000
  - service: http_status:404

The last line is mandatory: everything except the specified subdomain will receive a plain 404.

Binding the subdomain and starting up

The DNS record is created automatically, there is no need to open the Cloudflare panel:

cloudflared tunnel route dns my-vpn vpn.your-domain.com
cloudflared service install
systemctl enable --now cloudflared
systemctl status cloudflared

Check the journal:

journalctl -u cloudflared -n 20 --no-pager

You should see Registered tunnel connection lines — usually four, one per connection to the nearest Cloudflare nodes. They also reveal the city: location=fra19 means Frankfurt.

What it looks like in the Cloudflare panel

Open the DNS section of your domain — there should be exactly one record, created automatically:

NameTypeContentStatus
vpn.your-domain.comTunnelyour tunnel nameProxied

If the record is there and the tunnel reports active, the server side is completely ready. No other records are required for the VPN to work.

Step 7. Connecting your devices

Build the connection link from this template, substituting your UUID, subdomain and path:

vless://YOUR_UUID@vpn.your-domain.com:443?encryption=none&security=tls&sni=vpn.your-domain.com&fp=safari&alpn=http%2F1.1&type=ws&host=vpn.your-domain.com&path=%2Fws-7f3a91#My-VPN

What matters here:

ParameterValue
sni and hostyour subdomain, both identical
paththe path from the config, the slash is written as %2F
alpn=http/1.1mandatory, otherwise the client tries HTTP/2 and WebSocket fails
flowabsent — it is not used with WebSocket

Android

You need v2rayNG. It may be unavailable in Google Play, so the reliable route is downloading the APK from the project releases page on GitHub. There are many files there — pick arm64-v8a, it fits virtually every modern phone:

Downloading the v2rayNG arm64-v8a APK from the GitHub releases page

After installing, copy your link, tap «+» in the top right corner and choose «Import from clipboard»:

Importing a VPN profile from the clipboard in the v2rayNG app on Android

The profile appears in the list — select it and tap the round button in the bottom right. A line like «Success: connection took 108 ms» with the server country will appear at the bottom of the screen.

iPhone and iPad

Streisand, V2Box or Shadowrocket (the latter is paid) — importing from the clipboard works the same way.

Windows

v2rayN from the project releases page on GitHub: launch it, press Ctrl+V to import the link, then pick «Global» mode from the tray.

macOS

V2Box from the App Store or Hiddify.

Step 8. Verifying

Connect and search for «my ip» — the search engine will show both of your addresses right away:

Checking the IP address after connecting to your own VPN: server IPv4 and IPv6

You should see your server's addresses, not your home ones. Any other service works too — ifconfig.co, whatismyip.com.

Be sure to test both types of network: Wi-Fi first, then mobile data. Mobile networks are where problems usually surface, if there are any.

IPv6: why it matters and why the config has two outbounds

Let's return to that outbounds block with the v4 and v6 tags. It isn't decoration — it solves a very specific problem.

Some services treat data-centre IPv4 addresses with suspicion. A telling example is the Claude app on Android: over the server's regular IPv4 it refuses to open at all, while the same site works fine in a browser. The reason is that Cloudflare serves such addresses a JavaScript challenge («Just a moment...»); a browser solves it silently, a native app cannot and hits an error.

Over the same server's IPv6 address there is no challenge. Hence the configuration: all traffic goes over IPv4, while the Claude domains go over IPv6. You can verify it through the tunnel:

curl --socks5-hostname 127.0.0.1:PORT https://claude.ai/cdn-cgi/trace

The reply should contain the server's IPv6 address, while ordinary sites keep seeing the IPv4 one.

Adding your own domains to the IPv6 rule

You decide which domains go over IPv6. Open the config:

nano /usr/local/etc/xray/config.json

Find the routing block and add the domains you need to the domain array, separated by commas:

"routing": {
  "rules": [
    {
      "type": "field",
      "domain": [
        "domain:claude.ai",
        "domain:claude.com",
        "domain:anthropic.com",
        "domain:example.com",
        "domain:another-service.net"
      ],
      "outboundTag": "v6"
    }
  ]
}

Watch the commas: they go between the lines but never after the last one — that is the most common editing mistake, and it makes the config unreadable.

The prefix before the domain defines how it is matched:

EntryWhat it matches
domain:example.comthe domain and all its subdomains: example.com, api.example.com, cdn.example.com
full:example.coman exact match only, subdomains are untouched
keyword:exampleany domain containing that word

In most cases domain: is what you want — services almost always use several subdomains for their API, images and authentication, and all of them should travel the same route. If you send only the main domain over IPv6 while the API stays on IPv4, the app may behave oddly: some requests go through, others don't.

After editing, always validate the syntax and restart:

xray -test -config /usr/local/etc/xray/config.json
systemctl restart xray

If you get an error instead of Configuration OK, it is almost certainly an extra or a missing comma. Xray keeps running with the old configuration until restarted, so you won't lose connectivity.

Checking that a domain goes over IPv6

For sites behind Cloudflare there is a convenient service page — connect through the VPN and open:

https://the-domain-you-check/cdn-cgi/trace

The ip= line contains the address the site sees. If it holds your server's IPv6 address, the rule works. For sites without Cloudflare any IP checking service will do: compare what it shows during normal browsing and when visiting the service in question.

Another way is to look at the server itself and see which protocol the connection used:

tail -f /var/log/xray/access.log

Connection lines carry a [v4] or [v6] marker, so you can see the chosen route for every request at a glance. This requires the access log to be enabled, as described in the troubleshooting section.

Sending all traffic over IPv6

If you want all traffic to go over IPv6, replace the outbounds block with a single line and delete the routing block:

"outbounds": [
  { "protocol": "freedom", "settings": { "domainStrategy": "UseIPv6v4" } }
]

Mind the side effect: IP checking sites will then show the IPv6 address instead of the IPv4 one you bought, and providers' IPv6 subnets are often geolocated differently from their IPv4 ranges — you may end up «in another country».

If something doesn't work

Enable verbose logging: replace the log block in the config with the one below, restart Xray and try connecting.

"log": {
  "loglevel": "info",
  "access": "/var/log/xray/access.log",
  "error": "/var/log/xray/error.log"
}

Then watch what happens:

tail -f /var/log/xray/error.log

The main scenarios:

What you seeCauseFix
the log is empty, the tunnel never came upa problem with cloudflaredsystemctl status cloudflared, check journalctl
connected, but sites don't loadapp settingssee below
works, then dropsAndroid battery saversee below

You can also check the tunnel from the outside — open https://vpn.your-domain.com in a browser. You should get a 404 page from Cloudflare: that means the domain resolves, the tunnel is up, and only the path is wrong — which is exactly how it should behave.

Connected, but sites don't open

The cause is almost always in the app. Check three settings:

  • Per-app proxy. If «proxy selected apps only» is enabled and your browser isn't on the list, only the app's own service requests travel through the tunnel. It looks exactly like a completely broken VPN. Turn it off.
  • Routing mode — set it to global.
  • Fragmentation (Fragment) — turn it off. It is recommended everywhere as an anti-blocking measure, but here it only gets in the way.

The connection drops on its own

If the VPN works and then stops for no apparent reason, the battery saver is to blame. Xiaomi, Honor, Huawei, OPPO and Samsung are especially aggressive: they kill the background VPN service to save power.

Allow the app to autostart and run in the background, remove battery restrictions (set «No restrictions»), and pin the app card in the recents list.

REALITY on port 443: a faster alternative, but not for everyone

If you search for guides on running your own VPN, almost all of them suggest a different setup — VLESS + REALITY straight on port 443, with no domain and no Cloudflare. It is simpler to configure and faster in use, because traffic takes the shortest path without a detour through a CDN.

We configured that as well on the same server, and here is the honest outcome: it did not work on every network. From one home connection REALITY never came up at all, while the Cloudflare tunnel from the very same device worked immediately.

The reason lies in the size of the first packet. The client sends a TLS ClientHello, and its size depends on which browser it emulates. We measured this on a live server:

Emulated browserClientHello size
fp=firefox1893 bytes
fp=chrome1824 bytes
fp=randomized517 bytes

Modern Chrome and Firefox add a post-quantum key to the handshake, inflating it to nearly two kilobytes. On an ordinary network this is irrelevant. But on channels with a reduced segment size — mobile networks, home connections behind a provider tunnel, corporate networks — such a greeting no longer fits into a single packet and gets split into two. And this is where it gets interesting: the second chunk often simply never arrives. The connection hangs, and the server log fills with failed to read client hello.

The fp=randomized parameter helps: the greeting shrinks threefold and fits into one packet. But even that is not a silver bullet — in our test the connection then stumbled on the authentication stage instead.

The Cloudflare setup is immune to this by design: the phone establishes an ordinary TLS connection with the nearest CDN node, there is nothing special inside the greeting, and filters have nothing to latch onto. That is why this guide picks the tunnel as the main option, even though it takes more effort.

Maintenance

Adding a user

To share access with family, don't hand out your own link — create a separate profile. Generate a new UUID with xray uuid and add it to the clients array:

"clients": [
  { "id": "FIRST_UUID", "email": "main" },
  { "id": "SECOND_UUID", "email": "mom" }
]

After systemctl restart xray the profile is live. The link is built from the same template, only the UUID changes.

Adding a second server to the same domain

A single domain serves any number of servers — each gets its own subdomain (vpn2, vpn3 and so on). On the new server you repeat steps 4-6, creating the tunnel with a different name and a different subdomain.

Handy commands

systemctl status xray          # Xray state
systemctl status cloudflared   # tunnel state
systemctl restart xray         # restart after editing the config
journalctl -u cloudflared -n 50 --no-pager

And a rule that will save you hours: after every server restart, reconnect manually in the app. Old connections are dropped, and the client doesn't always notice — from the outside it looks like «it suddenly stopped working».

When your own VPN isn't the right tool

A personal VPN solves its own task perfectly — accessing websites through a channel of your own. But it has a boundary worth knowing about.

To any website your traffic looks like data-centre traffic: the IP belongs to a hosting provider, and that is visible in public databases. This doesn't matter for ordinary browsing, but wherever a site evaluates whether a visitor looks like a real person — sign-ups, marketplaces, social networks, advertising — a server address stands out immediately. Your TCP fingerprint gives you away as well: passive detectors tell a Linux server from a phone by connection parameters alone, without ever looking inside the traffic.

If that is precisely your goal — to look like an ordinary mobile network user — you need a different instrument. Mobile proxies work through real SIM cards in carrier networks: to the remote side such traffic is mobile subscriber traffic, because that is physically what it is. We covered how this infrastructure works from the inside in our article about the proxy farm.

The two approaches coexist nicely: your own VPN for personal access to websites and protection on public networks, mobile proxies for tasks where what matters is how the remote side sees you.