# 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.