VPN on Raspberry PI

31 Aug 2026

Configure Xray in Docker

  1. Generate UUID and Reality keypair:
    docker run --rm ghcr.io/xtls/xray-core:latest uuid
    docker run --rm ghcr.io/xtls/xray-core:latest x25519
    

    PrivateKey goes into config.json, Password (PublicKey) goes into the client link. Hash32 is not used.

  2. Create docker-compose.yml: ```yml services: xray: image: ghcr.io/xtls/xray-core:26.3.27 container_name: xray restart: always network_mode: host user: root # <— important cap_add:
    • NET_BIND_SERVICE # <— to take ports lower 1024 volumes:
    • ./config.json:/usr/local/etc/xray/config.json ```
  3. Create config.json — insert UUID and PrivateKey from step 1
    {
      "log": {
     "loglevel": "warning"
      },
      "policy": {
     "levels": {
       "0": {
         "handshake": 4,
         "connIdle": 120,
         "uplinkOnly": 2,
         "downlinkOnly": 5
       }
     }
      },
      "inbounds": [{
     "port": 443,
     "listen": "0.0.0.0",
     "protocol": "vless",
     "settings": {
       "clients": [{
         "id": "... UUID ...",
         "flow": "xtls-rprx-vision",
         "email": "user1"
       }],
       "decryption": "none"
     },
     "streamSettings": {
       "network": "tcp",
       "security": "reality",
       "realitySettings": {
         "show": false,
         "dest": "google.com:443",
         "serverNames": ["google.com"],
         "privateKey": "... private key ...",
         "shortIds": [""]
       },
       "sockopt": {
         "tcpKeepAliveIdle": 100,
         "tcpKeepAliveInterval": 30
       }
     },
     "sniffing": {
       "enabled": true,
       "destOverride": ["http", "tls", "quic"]
     }
      }],
      "outbounds": [{
     "protocol": "freedom",
     "settings": {
       "domainStrategy": "UseIPv4"
     }
      }]
    }
    

dest and serverNames define the site Reality masquerades as. google.com works, but it is the most obvious choice and is used to fingerprint Reality servers. Pick a less popular site that supports TLS 1.3 and HTTP/2, ideally hosted near your server.

  1. Start Docker:
    docker compose up -d
    docker compose logs -f xray
    

    Look for Xray ... started.

  2. Create VLESS link (one line, you may need to change SNI):
    vless://[YOUR_UUID]@[YOUR_DDNS_DOMAIN_OR_IP]:443?type=tcp&security=reality&encryption=none&sni=google.com&fp=chrome&pbk=[YOUR_PUBLIC_KEY]&flow=xtls-rprx-vision#TestVPN
    
  • sni must match serverNames from the config
  • pbk is the public key — safe to share, unlike PrivateKey
  • fp=chrome is required; Reality will not work without a fingerprint
  1. Import this link into V2BOX or another client.

Multiple users

The config above has a single client, so everyone shares one UUID and you can only revoke access for all of them at once. To revoke access per user, give each one their own entry:

"clients": [
  { "id": "... UUID 1 ...", "flow": "xtls-rprx-vision", "email": "alice" },
  { "id": "... UUID 2 ...", "flow": "xtls-rprx-vision", "email": "bob" }
]

The pbk in the link stays the same for everyone — only the UUID differs. Removing one entry disconnects that user and leaves the rest untouched.

Rotating keys / revoking access

  1. Generate a new UUID and update config.json:
    docker compose exec xray xray uuid
    

    Changing the UUID is enough to revoke access — clients keep the same pbk.

Regenerate the Reality keypair only if the private key leaked. This forces every client to update their link, since the public key changes too:

docker compose exec xray xray x25519
  1. Apply the changes:
    docker compose up -d --force-recreate
    

    Use --force-recreate rather than restart: config.json is bind-mounted as a file, so an editor that rewrites it (vim, sed -i) creates a new inode and the running container keeps reading the old one.

Verify the container picked up the new values:

docker exec xray grep -E '"id"|privateKey' /usr/local/etc/xray/config.json