Self-hosted edition
Self-host Serveo
Run the familiar SSH reverse-tunnel interface on your own server. One binary, public-key access through an authorized_keys file or your own auth plugin, and no database or control plane.
Free for up to three concurrent tunnels. Email trevor@serveo.net for a larger signed license.
This edition currently supports SSH clients only. Serveo's hosted WireGuard and browser-extension transports are not included.
Latest release
Choose a build
Each archive includes the binary, systemd unit, protocol reference, and plugin examples. SHA-256 checksums are published alongside the downloads. Windows binaries are not available yet.
Quick start
From download to first tunnel
- 1
Point DNS
Point the domain and its wildcard to your server.
- 2
Add keys
List the public keys allowed to create tunnels.
- 3
Start Serveo
Begin on port 2222, then test HTTP and HTTPS.
# Linux x86-64; choose the matching archive for your machine.
sha256sum --check --ignore-missing SHA256SUMS
tar -xzf serveo-self-hosted-linux-amd64.tar.gz
cd serveo-self-hosted-linux-amd64
sudo install -m 0755 serveo /usr/local/bin/serveo
# First installation only. Do not regenerate an existing host key.
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 -u serveo ssh-keygen -q -t ed25519 -N '' -f /var/lib/serveo/ssh_host_ed25519_key
sudo install -o root -g serveo -m 0640 /path/to/approved-client-keys.pub /etc/serveo/authorized_keysPrepare approved-client-keys.pub from the public keys allowed to connect, then install your wildcard certificate as /etc/serveo/fullchain.pem and its private key as /etc/serveo/privkey.pem, readable by the service account. On macOS, use the local quick start below instead of the Linux account/systemd commands. The macOS build is not notarized. After verifying the checksum and attempting to launch it, use System Settings, Privacy & Security, Open Anyway only if you trust this download. See Apple's per-app approval instructions; do not disable Gatekeeper globally or override a malware warning.
Complete installation and operations reference
macOS local quick start
This local HTTP trial needs no DNS, administrator access or TLS certificate. It uses ports 2222, 8080 and 3000; choose different ports if they are already occupied. For a public HTTPS server, use your real domain and wildcard certificate with the TLS flags below.
# Terminal 1. Keep the printed directory path for the other terminals.
workdir="$(mktemp -d)"
cd "$workdir"
pwd
umask 077
arch="$(uname -m)"
if [ "$arch" = x86_64 ]; then arch=amd64; fi
archive="serveo-self-hosted-darwin-$arch.tar.gz"
curl -fLO "https://serveo.net/downloads/self-hosted/$archive"
curl -fLO https://serveo.net/downloads/self-hosted/SHA256SUMS
grep " $archive$" SHA256SUMS | shasum -a 256 -c -
tar -xzf "$archive"
cp "serveo-self-hosted-darwin-$arch/serveo" ./serveo
ssh-keygen -q -t ed25519 -N '' -f ./client_key
ssh-keygen -q -t ed25519 -N '' -f ./host_key
cp client_key.pub authorized_keys
./serveo -telemetry=false \
-authorized_keys=./authorized_keys \
-private_key_path= -ed25519_private_key_path=./host_key \
-ssh_domain=localhost -user_content_domain=localhost \
-ssh_listen_addr=127.0.0.1:2222 \
-http_listen_addr=127.0.0.1:8080 \
-https_listen_addr= -monitoring_addr=In a second terminal, run your application on port 3000. For a disposable backend, use Python 3 if installed:
# Terminal 2; replace the path with the directory printed above.
cd /path/printed/by/terminal-1
mkdir www
printf "Serveo is working\n" > www/index.html
python3 -m http.server 3000 --bind 127.0.0.1 --directory wwwIn a third terminal, create the tunnel. On first connection, compare the fingerprint shown by SSH with ssh-keygen -lf host_key.pub from your trial directory before accepting it.
# Terminal 3; use the same trial directory.
cd /path/printed/by/terminal-1
ssh -F /dev/null -i ./client_key -o IdentitiesOnly=yes \
-o UserKnownHostsFile=./known_hosts -o ExitOnForwardFailure=yes \
-p 2222 -R demo:80:localhost:3000 localhostKeep those processes running and test from a fourth terminal:
curl --noproxy "*" -H "Host: demo.localhost" http://127.0.0.1:8080/The response should say Serveo is working. Stop each process with Ctrl-C when finished. This loopback-only trial does not make your Mac reachable from the internet.
Configuration
Run without a database
Published self-hosted builds enter this mode automatically. Configuration comes from command-line flags and reloadable files.
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.pemOnce it is running, users keep the normal Serveo interface:
ssh -p 2222 -R 80:localhost:3000 tunnels.example.comOfficial builds send one anonymous, best-effort startup event containing the version, platform, and whether a plugin or license is configured. No persistent installation ID, email, key, domain, hostname, or license ID is sent, and the analytics record does not store the connection's source IP. Disable it with -telemetry=false.
The event goes to https://serveo.net/api/analytics/self-hosted-start. Free mode also prints one brief license link at startup; a valid license suppresses it. Keep monitoring on loopback with -monitoring_addr=127.0.0.1:6060.
Keys and TLS
Client access
Put one plain OpenSSH public key per line in authorized_keys. Comments become identity labels. Key options are rejected rather than silently misapplied. The file must not be group- or world-writable and is limited to 4 MiB. It is checked on new connections, without a signal. Empty, missing or malformed files deny new key-based connections.
Public HTTPS
Use a certificate covering both your tunnel domain and its wildcard. Renew externally and restart Serveo to load the new certificate. Preserve the Serveo SSH host key across upgrades and migrations so clients do not see host-identification warnings. SSH and HTTPS need separate listener ports in this edition.
Reload and upgrades
On Linux, edit the included serveo.service with your paths and domain, install it under /etc/systemd/system/, then run systemctl daemon-reload and systemctl enable --now serveo. It runs as the dedicated account with permission to bind low ports.
Atomically replace keys; new connections see the changes immediately. No signal or watcher is needed. For a changed license file, use systemctl reload serveo or send SIGHUP. Invalid licenses retain the last good license; invalid key files reject new connections. Existing SSH sessions stay authenticated, including after key revocation; use the plugin identity-disconnect method for immediate offboarding, or restart.
SIGHUP also initializes and swaps in a replacement plugin; a failed replacement keeps the old one. TLS certificates, changed flags, and binary upgrades require a restart and drop active sessions. Keep the previous binary for rollback. License reload and plugin replacement are independent operations. Changing whether the plugin owns authentication also requires a restart.
The three-tunnel limit counts accepted forwards, not HTTP requests. Public TCP listeners bind all interfaces on ports 1024 and above (or an allocated port with -R 0:localhost:3000). Use a firewall and application authentication: public visitors are not authenticated by SSH, and self-hosted web tunnels have no interstitial.
Extensibility
Bring your own policy
Start one trusted command with -plugin-command. Serveo discovers its subscriptions, then exchanges bounded JSON-RPC messages over stdin and stdout. Plugins can check authentication, tunnel starts and private TCP access, observe lifecycle events, list active tunnels, disconnect a tunnel or identity, and reload file-backed configuration.
serveo -plugin-command="python3 /opt/serveo/plugin.py" [other options...]Choose who owns authentication
- Declare
auth_mode: "plugin"in the initialization result to own authentication. Serveo then skipsauthorized_keysentirely and requires an auth hook withon_error: deny. - Otherwise the hook runs before file authorization, and both must approve. Without a plugin, ordinary file authorization applies. No extra auth-mode flag is needed.
- SSH verifies key possession before the hook receives
public_key,identity.fingerprint, andauthorized_key. The last field is false when the plugin owns auth. Map the verified key to your account directory, not the untrusted SSH username.
serveo -plugin-command="python3 /opt/serveo/plugin.py" [listener and host-key options...]Useful policies
- Return
identity_idandmax_tunnelsfrom auth to share an atomic account quota across multiple keys. Zero means no extra cap; the signed instance limit always applies. - Use
tunnel.checkfor allowed hostnames, protocols and ports. Header options are mutable, so their snapshot is not an enforcement point for later HTTP requests. - Use
private-tcp.connect.checkto compare the consumer identity with the target owner. Without it, authenticated users can access each other's private aliases. - Update your policy to deny a disabled account, then call
serveo.identities.disconnectwith itsplugin:-prefixed ID to close its current SSH sessions.
Auth grants last for the session. Return consistent quotas for each account, and disconnect/re-authenticate sessions after changing a grant. Lifecycle events are best effort, not an exact billing ledger. Plugins run with Serveo's OS permissions and are not sandboxed.
Full JSON-RPC protocol, schemas, timeouts and failure behavior
These examples accept authorized-file keys or exact fingerprints in SERVEO_PLUGIN_FINGERPRINTS. Plugin-only mode with an empty list denies everyone. Replace this lookup with your directory integration. For slow external calls, use bounded workers and keep reading stdin while work runs.
Python example
#!/usr/bin/env python3
"""Minimal Serveo policy plugin. Keep stdout reserved for JSON-RPC."""
import json
import os
import sys
def send(message):
print(json.dumps(message, separators=(",", ":")), flush=True)
def result(request, value):
send({"jsonrpc": "2.0", "id": request["id"], "result": value})
for line in sys.stdin:
try:
request = json.loads(line)
method = request.get("method")
if method == "plugin.initialize":
result(request, {
"protocol_version": 1,
"auth_mode": "keys", # Set to "plugin" to replace authorized_keys.
"name": "python-example",
"version": "1.0.0",
"subscriptions": [
{"event": "auth.check", "timeout_ms": 1000, "on_error": "deny"},
{"event": "tunnel.check", "timeout_ms": 1000, "on_error": "deny"},
{"event": "tunnel.started"},
{"event": "tunnel.stopped"},
{"event": "private-tcp.connect.check", "timeout_ms": 1000, "on_error": "deny"},
],
})
send({"jsonrpc": "2.0", "id": "status-1", "method": "serveo.status", "params": {}})
elif method == "plugin.auth.check":
# Replace this allowlist with your account/key directory lookup.
params = request["params"]
fingerprints = os.environ.get("SERVEO_PLUGIN_FINGERPRINTS", "").split(",")
result(request, {"allow": params["authorized_key"] or params["identity"]["fingerprint"] in fingerprints})
elif method == "plugin.private-tcp.connect.check":
params = request["params"]
result(request, {"allow": params["identity"]["id"] == params["tunnel"]["identity"]["id"]})
elif method == "plugin.tunnel.check":
result(request, {"allow": True})
elif method in ("plugin.tunnel.started", "plugin.tunnel.stopped"):
tunnel = request["params"]["tunnel"]
print(f"{method}: {tunnel['id']} {tunnel['endpoint']}", file=sys.stderr)
elif method is None and request.get("id") == "status-1":
print(f"Serveo status: {request.get('result')}", file=sys.stderr)
except Exception as error:
print(f"plugin error: {error}", file=sys.stderr)Node.js example
#!/usr/bin/env node
import readline from 'node:readline';
const send = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);
const result = (request, value) => send({ jsonrpc: '2.0', id: request.id, result: value });
const lines = readline.createInterface({ input: process.stdin });
lines.on('line', (line) => {
try {
const request = JSON.parse(line);
switch (request.method) {
case 'plugin.initialize':
result(request, {
protocol_version: 1,
auth_mode: 'keys', // Set to 'plugin' to replace authorized_keys.
name: 'node-example',
version: '1.0.0',
subscriptions: [
{ event: 'auth.check', timeout_ms: 1000, on_error: 'deny' },
{ event: 'tunnel.check', timeout_ms: 1000, on_error: 'deny' },
{ event: 'tunnel.started' },
{ event: 'tunnel.stopped' },
{ event: 'private-tcp.connect.check', timeout_ms: 1000, on_error: 'deny' },
],
});
send({ jsonrpc: '2.0', id: 'status-1', method: 'serveo.status', params: {} });
break;
case 'plugin.auth.check':
// Replace this allowlist with your account/key directory lookup.
result(request, { allow: request.params.authorized_key ||
(process.env.SERVEO_PLUGIN_FINGERPRINTS || '').split(',').includes(request.params.identity.fingerprint) });
break;
case 'plugin.private-tcp.connect.check':
result(request, { allow: request.params.identity.id === request.params.tunnel.identity.id });
break;
case 'plugin.tunnel.check':
result(request, { allow: true });
break;
case 'plugin.tunnel.started':
case 'plugin.tunnel.stopped':
console.error(request.method, request.params.tunnel.id, request.params.tunnel.endpoint);
break;
default:
if (!request.method && request.id === 'status-1') {
console.error('Serveo status:', request.result);
}
}
} catch (error) {
console.error('plugin error:', error.message);
}
});Go example
package main
import (
"bufio"
"encoding/json"
"fmt"
"os"
"strings"
)
type message struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Result any `json:"result,omitempty"`
}
func main() {
encoder := json.NewEncoder(os.Stdout)
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
var request message
if err := json.Unmarshal(scanner.Bytes(), &request); err != nil {
fmt.Fprintln(os.Stderr, "plugin error:", err)
continue
}
switch request.Method {
case "plugin.initialize":
_ = encoder.Encode(message{JSONRPC: "2.0", ID: request.ID, Result: map[string]any{
"protocol_version": 1,
"auth_mode": "keys", // Set to "plugin" to replace authorized_keys.
"name": "go-example",
"version": "1.0.0",
"subscriptions": []map[string]any{
{"event": "auth.check", "timeout_ms": 1000, "on_error": "deny"},
{"event": "tunnel.check", "timeout_ms": 1000, "on_error": "deny"},
{"event": "tunnel.started"},
{"event": "tunnel.stopped"},
{"event": "private-tcp.connect.check", "timeout_ms": 1000, "on_error": "deny"},
},
}})
_ = encoder.Encode(message{JSONRPC: "2.0", ID: json.RawMessage(`"status-1"`), Method: "serveo.status", Params: json.RawMessage(`{}`)})
case "plugin.auth.check", "plugin.private-tcp.connect.check":
var params struct {
AuthorizedKey bool `json:"authorized_key"`
Identity struct {
ID string `json:"id"`
Fingerprint string `json:"fingerprint"`
} `json:"identity"`
Tunnel struct {
Identity struct {
ID string `json:"id"`
} `json:"identity"`
} `json:"tunnel"`
}
allow := false
if json.Unmarshal(request.Params, ¶ms) == nil {
if request.Method == "plugin.auth.check" {
// Replace this allowlist with your account/key directory lookup.
allow = params.AuthorizedKey
for _, fp := range strings.Split(os.Getenv("SERVEO_PLUGIN_FINGERPRINTS"), ",") {
allow = allow || (fp != "" && fp == params.Identity.Fingerprint)
}
} else {
allow = params.Identity.ID != "" && params.Identity.ID == params.Tunnel.Identity.ID
}
}
_ = encoder.Encode(message{JSONRPC: "2.0", ID: request.ID, Result: map[string]any{"allow": allow}})
case "plugin.tunnel.check":
_ = encoder.Encode(message{JSONRPC: "2.0", ID: request.ID, Result: map[string]any{"allow": true}})
case "plugin.tunnel.started", "plugin.tunnel.stopped":
fmt.Fprintln(os.Stderr, request.Method, string(request.Params))
case "":
if string(request.ID) == `"status-1"` {
fmt.Fprintln(os.Stderr, "Serveo status:", request.Result)
}
}
}
}Decision hooks have explicit timeouts and fail-open or fail-closed policies. Lifecycle events never block tunnel cleanup. A malformed protocol stream disables the plugin but leaves existing tunnels running.
Use your coding agent
Copy a complete prompt
These prompts give an LLM the constraints it needs without asking it to improvise risky infrastructure changes.
Install safely
Set up self-hosted Serveo on this server using the official documentation at https://serveo.net/self-hosted/. Use a dedicated unprivileged service account, preserve administrative SSH access, configure apex and wildcard DNS, obtain a wildcard TLS certificate, and start Serveo on SSH port 2222 first. Ask me before changing DNS, firewall rules, privileged ports, or replacing an existing service. Verify an authorized key, an unauthorized key, HTTP, HTTPS, and the three-tunnel free limit.Write a plugin
Write a Serveo self-hosted plugin using https://serveo.net/self-hosted/plugin-protocol.txt and the inline examples at https://serveo.net/self-hosted/#plugins. Ask whether authorized_keys should remain a hard allowlist or the plugin should own auth (declare auth_mode: "plugin" in plugin.initialize). Map cryptographically verified public-key fingerprints to enabled accounts; never trust the SSH username alone. Return stable identity_id and consistent max_tunnels grants. Subscribe to private-tcp.connect.check if private aliases need ACLs. Keep stdout exclusively for newline-delimited JSON-RPC and diagnostics on stderr. Bound external calls, fail closed for auth, and test allow, deny, timeout, malformed input, reload, per-account quota, and identity disconnection. Do not use lossy lifecycle events as an exact billing ledger.Troubleshoot an instance
Troubleshoot this self-hosted Serveo instance using https://serveo.net/self-hosted/. Start read-only: inspect the exact command, service logs, listeners, DNS, certificate names and expiry, authorized_keys permissions, and an SSH client run with -vvv. Do not restart the service or alter firewall, DNS, keys, certificates, or licenses until you explain the evidence and the smallest safe fix.Larger installations
Need more than three tunnels?
A license is a one-time purchase. Its readable JSON contains the customer email, perpetual term, and concurrent-tunnel allowance, signed by Serveo and verified with a public key embedded in the binary. Older expiring licenses remain supported.
Email for a license