DEV Community

Dmytro Tishchenko
Dmytro Tishchenko

Posted on Edited on Fully Autonomous

What a 30-second-old VPS does to your install script

Most "run your own VPN" guides start the same way: rent a VPS, SSH in, paste twenty commands, edit a JSON file, open a port, copy a key back to your phone, and hope nothing was mistyped. For people who live in a terminal that's fine. For everyone else it's where they give up.

I build Lume, a VPN client for Xray, AmneziaWG and OpenVPN. Its "Set Up My Server" screen asks for three things: the server's IP, a user name and a password or private key. About 90 seconds later there is a working VLESS Reality server and a config in the app, already tested with real traffic.

This post is about what happens in those 90 seconds, and what a brand-new VPS throws at you along the way. The engine is Go (it runs on iPhone, Android, Mac and Windows through gomobile and a Windows service); the server side is plain bash and systemd.

The shape of it

phone ──SSH──▶ phase 1: probe.sh     → facts (arch, busy ports, saved keys, Reality target)
phone          generate keys + configs locally
phone ──SSH──▶ phase 2: install.sh   → download, verify, install, start services
phone ──TLS──▶ port check
phone ──VLESS▶ real request through the new tunnel → exit IP
Enter fullscreen mode Exit fullscreen mode

The scripts talk back to the app over stdout with a tiny line protocol, so the progress screen shows real steps instead of a spinner:

step() { echo "::step $*"; }       # a step started
kv()   { echo "::kv $1=$2"; }      # a fact for the app
die()  { echo "::error $*"; exit 1; }
Enter fullscreen mode Exit fullscreen mode

Everything else the script prints is shown as a log line. The app counts the step "..." lines in the assembled script before running it, so the progress bar knows the total up front.

1. The phone does the thinking, the server only runs things

Keys are generated on the device: the X25519 Reality key pair, the client UUID and the short ID. The server never needs xray x25519, openssl or anything else to make keys, and the app already has the public key it needs for the client config.

Phase 1 is read-only. It reports the CPU architecture, which TCP and UDP ports are taken, whether a previous Lume install left state behind, and which Reality target this server can reach (more on that below). The app then builds the server config, uploads it into a mktemp -d directory with umask 077, and runs phase 2.

2. A fresh VPS isn't ready when the hoster says it is

The most common real-world flow is "create a server, copy the IP, paste it into the app" — within seconds. That's when everything goes wrong:

  • SSH isn't up yet. For the first minute or two the port refuses or resets. Instead of failing, the app treats "connection refused / reset / EOF" as "still booting" and retries every 5 seconds for up to 3 minutes, with the step retitled to Waiting for the server to start.
  • cloud-init and unattended upgrades hold the package manager. Run apt-get install at that moment and it fails with Could not get lock /var/lib/dpkg/lock-frontend. So the probe waits for first-boot setup, and apt waits for the lock instead of dying:
if command -v cloud-init >/dev/null && ! cloud-init status 2>/dev/null | grep -qE "status: (done|disabled|error)"; then
    echo "Waiting for the first-boot setup to finish"
    timeout 300 cloud-init status --wait >/dev/null 2>&1 || echo "cloud-init is still running, continuing"
fi

apt-get -o DPkg::Lock::Timeout=180 install -y -qq "$@"
Enter fullscreen mode Exit fullscreen mode

This is a big part of why setup takes ~90 seconds on a new server and much less on one that has been running for a while.

3. Don't assume the server can reach GitHub

The obvious way to install Xray is to download the official release from GitHub. Some hosting networks can't reach GitHub reliably, and the full Xray binary includes every protocol and transport, while this setup only needs one combination.

So the installer prefers a minimal server binary I build myself: built from the same xray-core as the app, linking only VLESS inbound, Reality over TCP, and freedom/blackhole outbounds. The app converts the JSON config to protobuf before uploading, so even Xray's JSON loader (which pulls in everything) stays out of the build:

// Only the features the Lume server config uses.
_ "github.com/xtls/xray-core/proxy/vless/inbound"
_ "github.com/xtls/xray-core/transport/internet/reality"
_ "github.com/xtls/xray-core/transport/internet/tcp"
_ "github.com/xtls/xray-core/proxy/freedom"
_ "github.com/xtls/xray-core/proxy/blackhole"
Enter fullscreen mode Exit fullscreen mode

The download is 4.7 MB (xz) instead of 15 MB for the official zip — different compression, so not a perfect comparison. Binaries live behind a CDN at content-addressed URLs (the path contains the hash, so a file is never overwritten), and the installer checks SHA-256 before using anything. If the CDN is unreachable, it falls back to the official Xray release from GitHub, pinned to a version and a checksum.

4. Let the server pick its own Reality target

VLESS Reality makes the server look like a well-known HTTPS site: a client without the right key is forwarded to the real site. That only works if the server can actually reach that site over TLS 1.3, and from some data centres some sites are slow or unreachable.

So the app sends a short list of candidates and the server tries them in order:

for sni in $LUME_SNI_CANDIDATES; do
    if echo | timeout 6 openssl s_client -connect "$sni:443" -servername "$sni" -tls1_3 >/dev/null 2>&1; then
        kv sni "$sni"; break
    fi
done
Enter fullscreen mode Exit fullscreen mode

5. Running it twice must not break anyone's config

People run setup again: to add a protocol, because the first attempt timed out, or just because. If a second run generated new keys, every config already shared with family members would silently stop working.

State lives in /etc/lume/*.env (keys, UUID, port, Reality target). The probe reports it, and the app reuses it, so a repeat run produces the same config. The port is chosen once from 443, 8443, 2053 and 2083, skipping anything already in use, and then kept.

The server runs exactly the protocols chosen this time. Ones that weren't chosen are stopped, but their keys stay in /etc/lume, so adding them back later restores the old configs.

6. A small, boring systemd unit

Xray runs as a dynamic user with no write access to the system, and reads its config through systemd credentials rather than from a world-readable path:

[Service]
ExecStart=/usr/local/lib/lume/lume-xray-server -config ${CREDENTIALS_DIRECTORY}/xray.pb
LoadCredential=xray.pb:/etc/lume/xray.pb
DynamicUser=yes
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
Restart=always
Enter fullscreen mode Exit fullscreen mode

The installer also opens the port in ufw or firewalld if one of them is active, and switches on BBR congestion control.

7. "Installed" is not "works"

systemctl is-active says nothing about whether a phone on mobile data can connect. The classic trap is a firewall the server itself doesn't know about: Hetzner Cloud Firewall, AWS security groups and the like.

So after the install the app checks from the device:

  1. The port. A TLS handshake to the server with the Reality target as SNI. If it fails, the error names the likely culprit: the server is installed, but port 443 is not reachable from this device. Check your hosting provider's firewall, for example Hetzner Cloud Firewall or an AWS security group, and allow TCP 443.
  2. Real traffic. The app starts the new client config in an in-process Xray instance with no inbounds, so it can't clash with a VPN that's already running, and fetches Cloudflare's trace page through it:
httpClient := &http.Client{
    Transport: &http.Transport{
        DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
            dest, err := xnet.ParseDestination("tcp:" + addr)
            if err != nil {
                return nil, err
            }
            return core.Dial(ctx, inst, dest) // straight into the new tunnel
        },
    },
}
Enter fullscreen mode Exit fullscreen mode

Only when that request comes back through the tunnel (the log shows the exit IP it reports) does the config get saved.

8. Credentials, and what phones do to private keys

The password or key is used for this one SSH connection. It isn't stored, logged or sent anywhere except to the server. The app remembers the server's SSH host key fingerprint, so a later run against the same address stops if the key has changed. If you rebuilt the server on purpose, you tell the app to forget the old key.

A surprise: pasting a private key on a phone is hard. iOS turns -- into an em dash, so -----BEGIN OPENSSH PRIVATE KEY----- arrives as something the SSH library rejects. Notes apps add non-breaking spaces, messengers collapse line breaks into spaces. The app now repairs the pasted text before parsing it: it replaces the dashes, normalises line endings and rewraps the base64 body. The error message for a key that still can't be read says what to do: paste the whole key file, including the BEGIN and END lines.

Lume isn't open source, so you can't check this yourself, and that's a fair concern for a root login. If you'd rather not take it on trust, use an SSH key created just for this and remove it from the server afterwards, or run setup on a fresh VPS that holds nothing else.

What I'd tell anyone building something similar

  • Test on servers that are 30 seconds old, not on the one you've had for a year. The boot-time problems above never show up on a machine that has been running for a while.
  • Make re-running safe before anything else. Users will run it twice.
  • Check the result from the client's side of the network. The server can't see the cloud firewall in front of it.
  • Write error messages for the person holding the phone, not for the person reading the logs.

Try it

Lume is free on Google Play and Windows; the iPhone and Mac versions are in App Store review. On Android the free version shows a short ad while a server is being set up; the Windows app has no ads. The free version installs one protocol per server; installing Xray, AmneziaWG and OpenVPN XOR side by side is part of Lume Pro.

Set Up My Server is meant for Debian and Ubuntu VPSes where you can log in as root (or as a sudo user).

If you try it on a hoster where it fails, I'd like to hear about it in the comments: which provider, which OS image, and what the last step said.

Top comments (0)