# Self-hosted Serveo Self-hosted Serveo is one binary that accepts SSH reverse tunnels and serves their HTTP and HTTPS traffic. It has no database, web console, PocketBase, containers, or external service dependency. The free edition permits three concurrent forwards across the instance. An optional signed license raises that limit. License files are readable JSON containing the customer email, license ID, perpetual term, and tunnel allowance; the binary verifies their Ed25519 signature using an embedded public key. ## Requirements - Linux or macOS and a domain whose apex and wildcard DNS records point to it. - A certificate covering that domain and its wildcard, such as `tunnels.example.com` and `*.tunnels.example.com`. - OpenSSH public keys for everyone allowed to create tunnels, either in an allowlist or checked by an authentication plugin. - Public TCP ports 80 and 443, plus an SSH port. Start with 2222 so the service cannot interfere with administrative SSH on port 22. ## Download and install Download your platform's archive and `SHA256SUMS` from . Verify the archive with `sha256sum --check --ignore-missing SHA256SUMS` on Linux, or the following on macOS (choose `darwin-amd64` for an Intel Mac): ```sh grep ' serveo-self-hosted-darwin-arm64.tar.gz$' SHA256SUMS | shasum -a 256 -c - ``` Extract the archive, change into its directory, and install `serveo`: ```sh sudo install -m 0755 serveo /usr/local/bin/serveo ``` The Linux/macOS builds support SSH clients only, not the hosted WireGuard or browser-extension transports. There is no Windows server build yet. Windows OpenSSH and PuTTY can connect as clients. macOS binaries are not notarized; verify the download before trying to launch it. For an unidentified-developer warning, use System Settings > Privacy & Security > Open Anyway only if you trust the download. Follow [Apple's per-app approval instructions](https://support.apple.com/en-us/102445), not a system-wide Gatekeeper bypass. Do not override a malware warning. ## Build from source From the repository root: ```sh make self-hosted sudo install -m 0755 bin/serveo-self-hosted /usr/local/bin/serveo ``` The build includes the whole `sshtunnel` package. Building only `sshtunnel/main.go` omits required package files and is unsupported. ## Configure For a local macOS trial without changing DNS or installing a service, see the complete macOS quick start on . The following production setup is for Linux. Create a private service directory and host key: ```sh sudo useradd --system --home-dir /var/lib/serveo --shell /usr/sbin/nologin serveo sudo install -d -o root -g serveo -m 0750 /etc/serveo sudo install -d -o serveo -g serveo -m 0700 /var/lib/serveo sudo install -o root -g serveo -m 0640 approved-client-keys.pub /etc/serveo/authorized_keys sudo -u serveo ssh-keygen -q -t ed25519 -N '' -f /var/lib/serveo/ssh_host_ed25519_key ``` Each non-comment line in `/etc/serveo/authorized_keys` must be a plain OpenSSH public key. Key options such as `command=` and `from=` are rejected because Serveo does not implement their OpenSSH semantics. The file must not be group- or world-writable. Install the TLS certificate and key so the `serveo` group can read them, but other users cannot (for example, owner `root`, group `serveo`, and mode `0640`). Run the instance directly: ```sh sudo /usr/local/bin/serveo \ -authorized_keys=/etc/serveo/authorized_keys \ -private_key_path= \ -ed25519_private_key_path=/var/lib/serveo/ssh_host_ed25519_key \ -ssh_domain=tunnels.example.com \ -user_content_domain=tunnels.example.com \ -ssh_listen_addr=:2222 \ -http_listen_addr=:80 \ -https_listen_addr=:443 \ -tls_cert_path=/etc/serveo/fullchain.pem \ -tls_key_path=/etc/serveo/privkey.pem \ -monitoring_addr=127.0.0.1:6060 \ -reserved_subdomains=admin,api,status,www ``` `approved-client-keys.pub` is a file you prepare from your clients' public keys, not their private keys. Never overwrite an existing SSH host key during an upgrade. The commands above are Linux-specific. On macOS, use a dedicated account and private writable directory, or first test under your own account with ports 2222, 8080 and 8443 and paths you own; `useradd` and systemd are not available. Restrict certificate keys, SSH host keys, and any plugin credentials to the service account and preserve the SSH host key across upgrades or moves so clients do not receive host-identification warnings. The included [`serveo.service`](serveo.service) is a starting systemd unit. Edit its domain and certificate paths, then install it: ```sh sudo install -m 0644 serveo.service /etc/systemd/system/serveo.service sudo systemctl daemon-reload sudo systemctl enable --now serveo systemctl status serveo --no-pager ``` ## Use The familiar command remains unchanged: ```sh ssh -p 2222 -R 80:localhost:3000 tunnels.example.com ssh -p 2222 -R demo:80:localhost:3000 tunnels.example.com ``` The first command receives an available generated name. The second requests `demo.tunnels.example.com`. Public TCP forwarding also works for ports at or above `-tcp_port_lower_bound` (1024 by default), or port `0` for an allocated port. Public TCP listeners bind all interfaces; firewall them deliberately. Private aliases use `ssh -R database:5432:localhost:5432 ...`; another authenticated SSH client connects with `ssh -W database:5432 ...`. Each accepted HTTP, public TCP, or private TCP forward consumes one instance slot. HTTP requests and connections through an existing forward do not each consume a slot. Multiple forwards on one SSH connection count separately. SSH authenticates the tunnel creator, not public web visitors. There is no free-user interstitial; protect sensitive applications at the application layer. Private aliases are accessible to other authenticated clients by default; use `private-tcp.connect.check` to restrict which identities may access each alias. Proxy exec options such as `--host-header=localhost:3000` are supported; see . This build does not multiplex SSH and TLS on port 443. Keep the SSH and HTTPS listeners on different ports. ## Plugins An optional long-running plugin can allow or deny authenticated connections and tunnel starts, observe tunnel start/stop events, and ask Serveo to list or disconnect active tunnels. Start it as a child process: ```sh serveo -plugin-command="python3 /opt/serveo/plugin.py" [other options...] ``` The plugin and Serveo exchange newline-delimited JSON-RPC 2.0 over standard input and output. No manifest file or HTTP listener is required. Plugin stderr is included in Serveo's service logs; stdout is reserved for the protocol. See [`plugin-protocol.md`](plugin-protocol.md) for the bounded protocol, timeouts, failure behavior, reload semantics, and control methods. Copyable starting points are available in [`plugins/python`](plugins/python), [`plugins/node`](plugins/node), and [`plugins/go`](plugins/go). No `-plugin-command` leaves the existing self-hosted request path unchanged. ### Authentication modes - By default, Serveo discovers ownership from the plugin's initialization result. `auth_mode: "plugin"` means the plugin handles auth itself and `authorized_keys` is ignored entirely, even if a stale path remains on the command line. The plugin must subscribe to `auth.check` with `on_error: deny`. - Otherwise, `-authorized_keys` is required. The auth hook runs first after key possession is proved, then the file is checked. A plugin may further restrict file-authorized keys, not override the file. Without a hook, ordinary file authorization applies. There is no anonymous or password fallback. - Advanced operator overrides: `-auth-mode=keys` always requires the file; `-auth-mode=plugin` forces plugin ownership and fail-closed auth. Neither is needed with a manifest declaring the intended mode. - SSH proves possession of the private key before invoking the plugin. The hook receives the public key and fingerprint, never the private key. It can query your account directory and return `identity_id`, `label`, and `max_tunnels`. Several SSH keys can share one account quota, still bounded by the license. - The supplied Python, Node, and Go examples accept keys in the file or listed in `SERVEO_PLUGIN_FINGERPRINTS` (comma-separated exact SHA256 fingerprints). With plugin-only auth and an empty list they reject everyone. Replace that lookup with your directory integration, not an unconditional allow. Example plugin-only launch (add the listener, key, and domain flags above): ```sh serveo -plugin-command="python3 /opt/serveo/plugin.py" ``` ## Reload and upgrade Key files are read for each new SSH connection; no watcher or signal is needed. Replace them atomically to avoid exposing a partially written file. An empty, missing, malformed, oversized (over 4 MiB), or unsafe-permission file rejects new file-authorized connections. Restoring a valid file restores admission on the next connection. Authoritative plugins never read the key file. After replacing an already configured license file, send `SIGHUP`: ```sh sudo systemctl reload serveo ``` A valid license reload changes admission for new forwards without disconnecting existing ones. Existing authenticated sessions retain their identity and may create forwards; to revoke them immediately, update plugin policy and call `serveo.identities.disconnect`, or restart the instance. A malformed license file is logged and the last good license configuration stays active. A malformed key file does not retain old authorization. If an older expiring license expires or any license limit is lowered below current usage, existing tunnels remain connected and new ones are rejected until usage falls below the effective limit. `SIGHUP` also replaces the plugin after successful initialization. A failed replacement leaves the previous plugin active. File configuration and plugin replacement are independent; this is not a transaction spanning both. Changing plugin auth ownership requires a server restart, not SIGHUP. `serveo.config.reload` reloads files without restarting the calling plugin. TLS certificates are loaded at startup, not on SIGHUP. After renewing them, restart Serveo. Changed command-line arguments also require a restart (and `systemctl daemon-reload` if you edit the unit). Binary upgrades require a restart and therefore disconnect active SSH sessions. Keep the previous binary for a quick rollback. ## License Without `-license`, the instance permits three concurrent forwards. To install a license supplied by Serveo: ```sh sudo install -o root -g serveo -m 0640 serveo-license.json /etc/serveo/license.json ``` Add `-license=/etc/serveo/license.json` to the service command, run `systemctl daemon-reload`, then restart. Future changes to that configured file can use `systemctl reload serveo`. An explicitly configured license that is malformed, altered, or signed by another key prevents startup. Licenses sold through Serveo are perpetual one-time purchases. Older expiring, correctly signed licenses fall back to the free limit after expiration without dropping existing tunnels or blocking key revocations. The signing private key is not part of this repository or the distributed binary. Only Serveo's public verification key is embedded. This is a straightforward license check for official binaries, not tamper-proof DRM. Commercial use must also be governed by the terms under which the binary is distributed. ## Telemetry Official builds send one best-effort startup event after the HTTP, HTTPS, and SSH listeners have opened successfully. It contains the release version, operating system, CPU architecture, Go runtime version, and whether a plugin or signed license is configured. It does not contain an email address, key, domain, hostname, license ID, raw IP address, or persistent installation ID. Delivery has a two-second timeout and never controls whether Serveo starts. The destination is `https://serveo.net/api/analytics/self-hosted-start`. As with any HTTPS request, Serveo's web server receives the source address for the connection, but the analytics event record does not store it. Disable the event completely with: ```sh serveo -telemetry=false [other options...] ``` Website download links also use Serveo's consent-controlled website analytics. ## Verify Run unit, race, and process-level tests before releasing a binary: ```sh go test ./sshtunnel/... go test -race -run 'Test(ReadAuthorized|Standalone|LoadHost)' ./sshtunnel bash self-hosted/tests/e2e-linux.sh go build -tags selfhosted -o /tmp/serveo-review ./sshtunnel python3 self-hosted/tests/integration.py --binary /tmp/serveo-review ``` To test a cross-compiled Linux artifact on a host without Go, set `SERVEO_BINARY=/path/to/serveo-linux` when running the acceptance script. The Linux/macOS acceptance test builds the real binary, starts it without a webapp, and checks authorized and rejected keys, HTTP and HTTPS proxying, reserved names, the three-forward free limit, cleanup, and live key reload. The Python suite additionally checks real child plugins in Python, Node and Go, directory-only authentication, allowlist intersection, cross-key account quotas, private TCP access, revocation, reload, malformed output, crashes, and timeouts. It needs OpenSSH, Python, Node, and Go to compile the Go example. Both suites use only synthetic keys and loopback fixtures, and disable telemetry.