Nix-based proxy for vless+reality
  • Shell 53.7%
  • Nix 46.3%
Find a file
Doug Sparling b4ff9216fb NixOS Xray VLESS+Reality proxy on Linode
A small NixOS flake for a personal censorship-circumvention proxy on a Linode
Nanode: Xray VLESS+Reality on 443/8443/2053, BBR, zram swap, and sops-nix for
service secrets. Host-specific details (IP, pinned SSH host key, SSH key) live
in an admin-only sops file, and the scripts read them from there, so the repo
carries no addresses or key material in the clear.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 10:50:14 +08:00
hosts/nixproxy NixOS Xray VLESS+Reality proxy on Linode 2026-10-02 10:50:14 +08:00
scripts NixOS Xray VLESS+Reality proxy on Linode 2026-10-02 10:50:14 +08:00
secrets NixOS Xray VLESS+Reality proxy on Linode 2026-10-02 10:50:14 +08:00
.gitignore NixOS Xray VLESS+Reality proxy on Linode 2026-10-02 10:50:14 +08:00
.sops.yaml NixOS Xray VLESS+Reality proxy on Linode 2026-10-02 10:50:14 +08:00
flake.lock NixOS Xray VLESS+Reality proxy on Linode 2026-10-02 10:50:14 +08:00
flake.nix NixOS Xray VLESS+Reality proxy on Linode 2026-10-02 10:50:14 +08:00
README.md NixOS Xray VLESS+Reality proxy on Linode 2026-10-02 10:50:14 +08:00

linode-nix-vless-proxy

A small NixOS (26.05) VPS on Akamai/Linode running an Xray VLESS+Reality server, as a personal censorship-circumvention proxy. It's meant to be cheap (a 1 GB Nanode, $5/mo), reproducible from one flake, and safe to keep in a public repo: every secret and every host-specific detail is encrypted with sops + age.

Linode has no first-party NixOS image, so the install goes through a custom installer disk booted in rescue mode, following Akamai's "Install and Configure NixOS on a Linode" guide with adjusted sizes.

Layout

Path What it is
flake.nix, hosts/nixproxy/ The NixOS config. xray.nix is the proxy.
secrets/xray.yaml UUID, Reality private key, short id. Encrypted to you and the server (sops-nix decrypts it on the host).
secrets/server.yaml Server IP, pinned SSH host key, SSH private key. Encrypted to you only; the server can't read it.
scripts/ Provisioning, ssh, client config and a connectivity test. They read secrets/server.yaml, so nothing host-specific is hard-coded.
client/ Generated client material. Gitignored.

Disk layout on a Nanode (25600 MB):

Disk Size Role
installer 1792 MB NixOS minimal ISO written with dd
swap 512 MB swap
nixos 23296 MB root filesystem

Two config profiles: installer (direct-disk, root /dev/sdc) and boot (GRUB 2, root /dev/sda), with all Linode boot helpers disabled.

Setting up your own

You need sops, age, linode-cli (with a token) and ssh. Nix isn't needed locally.

  1. Make an age key if you don't have one: age-keygen -o ~/Library/Application\ Support/sops/age/keys.txt (on Linux: ~/.config/sops/age/keys.txt). Put the public key in .sops.yaml as admin.

  2. Run scripts/01-provision.sh, then create the two profiles it describes, boot the installer and install the flake. Take the server's age key from its SSH host key (ssh-keyscan <server-ip> | ssh-to-age) and add it to .sops.yaml as nixproxy.

  3. Create the secrets (sops secrets/xray.yaml, sops secrets/server.yaml):

     # secrets/xray.yaml        (xray uuid / xray x25519 / openssl rand -hex 8)
     xray:
       uuid: ...
       private_key: ...
       short_id: ...
    
     # secrets/server.yaml
     server:
       ip: <server-ip>
       known_hosts: "<server-ip> ssh-ed25519 AAAA..."
       ssh_key: |
         -----BEGIN OPENSSH PRIVATE KEY-----
         ...
    
  4. Put the matching public key in hosts/nixproxy/configuration.nix under openssh.authorizedKeys.keys, and change the doug username if you like.

  5. scripts/ssh.sh now connects with no further setup. scripts/client-config.sh builds the client JSON and a vless:// share link from the files in client/ (uuid.txt, reality-public-key.txt, short-id.txt).

Findings: port 443 is filtered from some China networks

From one China WiFi, nc to the server on :443 timed out 5/5 while :22 connected 5/5, and a packet capture on the server showed the :443 SYNs never arrived (one connection that did arrive stalled partway through the server's TLS flight). The server side was clean: no cloud firewall, host firewall open, no rate limits. So the drop is on the path, and it's specific to the port.

Fix: the same Reality inbound also listens on 8443 and 2053 (ports in hosts/nixproxy/xray.nix). Over :8443 a speedtest through the tunnel gave about 10 Mbit/s down, 7.8 up, 256-328 ms.

That said, it's not a clean fix. On :8443 the tunnel runs fine for 10-20 minutes, then slows to a crawl and eventually passes nothing. Reconnecting sometimes helps, and switching ports helped once. This looks like per-flow throttling on the path rather than a server problem, but it isn't confirmed yet. Xray also warns that non-443 Reality ports may draw extra attention, so once one port is confirmed stable, drop the others.

Clients must use the alternate port: edit the share link's port before importing.

Deploying changes

The flake lives on the server at ~/nixproxy (no local nix). Sync only the files you changed, since the server's flake.lock is the source of truth (it carries sops-nix); pull it back with rsync after a build. Then:

scripts/ssh.sh 'cd nixproxy && sudo nixos-rebuild switch --flake .#nixproxy'
scripts/ssh.sh 'sudo systemctl restart xray'

The explicit restart is needed when only the rendered secret JSON changes, because the unit itself is unchanged and NixOS won't restart it. nixos-rebuild build first is a safe way to check a change evaluates.

Limitations and things to revisit

  • Encrypted secrets in a public repo are permanent. Anyone can copy secrets/*.yaml and the history forever, so their safety rests entirely on your age private key. Back it up, and if it ever leaks, rotate the Xray UUID/keys and the SSH key, not just the age key.
  • The IP isn't a secret, only less casual. It's in every client's share link and visible to anyone on the path. Keeping it out of the repo just stops drive-by scanning from the README.
  • 1 GB is tight for rebuilds. The running system uses ~280 MB, but the live installer and flake evaluation together thrashed a 1 GB machine during the original install. If a rebuild hangs, add a swapfile or resize up for the rebuild (billing is hourly).
  • Consider a domain for the client address so a blocked IP can be rerolled by changing one A record. Never use your own domain as the Reality SNI/dest.
  • The SSH key was a bootstrap key. Once you pick a permanent one, remove the bootstrap key from the config and drop security.sudo.wheelNeedsPassword = false with it.