@tinyrack/proxer
v0.12.0
Published
A reverse-tunnel CLI for Tinyrack.
Maintainers
Readme
Proxer
A small reverse-tunnel CLI for putting a private HTTP service behind a public URL you control.
Quick Start · Examples · Packaging
Proxer is for the familiar problem where a service is running on a laptop, mini PC, NAS, or office box, and you need a stable public URL without opening inbound ports back into that private network. You run one public Proxer server, then each private machine dials out to it with a WebSocket tunnel. Incoming HTTP, Server-Sent Events, and WebSocket traffic is routed back through that client-initiated connection.
Use this when
- You run small services behind NAT or a firewall and want a public host name for them.
- You want the tunnel server inside your own VPS, homelab edge, or business infrastructure.
- You need HTTP streaming, SSE, or WebSocket upgrades to pass through the tunnel.
- You prefer explicit host routing:
proxy.example.comfor the root route,demo.proxy.example.comfor a named route.
Do not use this when
- You need a fully managed tunnel provider with account dashboards, access policies, and global edge locations.
- You need raw TCP or UDP forwarding. Proxer is HTTP/WebSocket oriented.
- You cannot run a public server or reverse proxy that preserves the original
Hostheader. - You want Proxer to guess where traffic should go. Unknown hosts return
404.
Installation
npm install -g @tinyrack/proxerbrew install tinyrack-net/tap/proxerPrebuilt Linux, macOS, and Windows executables are published on the GitHub Releases page.
The OCI image is published to Docker Hub (tinyrack/proxer) and GHCR (ghcr.io/tinyrack-net/proxer):
docker run --rm tinyrack/proxer --versionQuick Start
These commands assume your-server.example.com reaches the public Proxer server. In production, put TLS in front with Caddy, Traefik, NGINX, or a load balancer, then point clients at the public wss:// URL.
Start the public server:
proxer server --listen 0.0.0.0:8080 --domain your-server.example.com --token dev-tokenStart something local on the client machine:
python3 -m http.server 3000 --bind 127.0.0.1Register a tunnel. By default, the server assigns a random subdomain and the client keeps using that assigned name while it reconnects during this run:
proxer http 3000 \
--server wss://your-server.example.com \
--token dev-tokenExample output:
subdomain: px-k7m3q9t2ab
public: https://px-k7m3q9t2ab.your-server.example.comRequests for that assigned host go to the client:
curl https://px-k7m3q9t2ab.your-server.example.com/Register a named subdomain route instead:
proxer http 3000 \
--server wss://your-server.example.com \
--subdomain demo \
--token dev-tokenThen request the matching host:
curl https://demo.your-server.example.com/Use cluster mode when multiple clients should share one named route:
proxer http 3000 \
--server wss://your-server.example.com \
--subdomain demo \
--mode cluster \
--token dev-tokenAll clients for a route must use the same mode. Cluster routes distribute HTTP requests round-robin and choose one tunnel when a WebSocket, SSE, or other long-lived stream opens.
Request the root-domain route only when you intend to occupy the root host:
proxer http 3000 \
--server wss://your-server.example.com \
--subdomain @ \
--token dev-tokenThen requests for https://your-server.example.com/ go to that client. Root routing is intended for servers started with --domain; without a configured domain, Proxer derives routes from the first host label, so --subdomain @ usually will not match the host you expect.
dev-token is only a demo value. For a real deployment, set a long random token with PROXER_TOKEN, a container secret, a Kubernetes secret, or your platform's secret manager. CLI arguments can land in shell history and process lists.
Docker
Run the public server in a container:
docker run --rm -p 8080:8080 tinyrack/proxer server --listen 0.0.0.0:8080 --domain proxy.example.com --token dev-tokenFor real deployments, keep the token out of shell history where possible:
docker run --rm -p 8080:8080 \
-e PROXER_TOKEN="$PROXER_TOKEN" \
tinyrack/proxer \
server --listen 0.0.0.0:8080 --domain proxy.example.comThe server keeps tunnel registrations in memory. If the container restarts, clients reconnect and register again.
For a tunnel client in Docker, remember that Proxer forwards to 127.0.0.1:<port> from inside the client process. On Linux, --network host is the usual way to let a client container reach a service on the Docker host:
docker run --rm --network host \
-e PROXER_TOKEN="$PROXER_TOKEN" \
tinyrack/proxer \
http 3000 --server ws://127.0.0.1:8080 --subdomain demoOn Docker Desktop, run the client on the host or in the same container/network namespace as the app. There is not currently a client flag for forwarding to host.docker.internal.
Kubernetes probes can use the built-in health endpoints:
livenessProbe:
httpGet:
path: /__proxer__/health/live
port: 8080
readinessProbe:
httpGet:
path: /__proxer__/health/ready
port: 8080Configuration
CLI flags win over PROXER_ environment variables. Built-in defaults are used last.
proxer server:
| CLI flag | Environment variable | Default |
| --- | --- | --- |
| --listen <host:port> | PROXER_LISTEN | 127.0.0.1:8080 |
| --domain <domain> | PROXER_DOMAIN | unset |
| --token <token> | PROXER_TOKEN | generated at startup |
| --trusted-proxy <proxy> | PROXER_TRUSTED_PROXIES | unset |
If the server token is omitted, Proxer prints the generated token as token: .... Copy that value to clients. proxer http always needs --token or PROXER_TOKEN.
proxer http <port>:
| CLI flag | Environment variable | Default |
| --- | --- | --- |
| --server <url> | PROXER_SERVER | ws://127.0.0.1:8080 |
| --subdomain <subdomain> | PROXER_SUBDOMAIN | auto-assigned random subdomain |
| --mode <single\|cluster> | PROXER_MODE | single |
| --token <token> | PROXER_TOKEN | required |
| --basic-auth-password <password> | PROXER_BASIC_AUTH_PASSWORD | unset |
| --basic-auth-username <username> | PROXER_BASIC_AUTH_USERNAME | unset |
The local port is positional and has no environment variable.
Omitting --subdomain and PROXER_SUBDOMAIN asks the server to assign a random subdomain such as px-k7m3q9t2ab. Use --subdomain demo or PROXER_SUBDOMAIN=demo for a chosen stable name. Use --subdomain @ or PROXER_SUBDOMAIN=@ only when you intentionally want root-domain routing on a server configured with --domain. Basic Auth can be combined with any route mode, including auto-assigned subdomains.
--basic-auth-password protects public HTTP, SSE, and WebSocket access to that tunnel. If --basic-auth-username is omitted, any Basic Auth username is accepted and only the password is checked. If a username is set, both username and password must match. These credentials protect public access to the proxied site; --token or PROXER_TOKEN is still required for tunnel registration.
Prefer environment variables or secret stores for real deployments. Passing --basic-auth-password directly can leak through shell history or process listings, and Basic Auth credentials are sent in request headers, so use HTTPS/WSS in front of Proxer.
--trusted-proxy is repeatable:
proxer server \
--listen 0.0.0.0:8080 \
--domain proxy.example.com \
--trusted-proxy loopback \
--trusted-proxy private \
--token "$PROXER_TOKEN"PROXER_TRUSTED_PROXIES is comma-separated:
PROXER_LISTEN=0.0.0.0:8080 \
PROXER_DOMAIN=proxy.example.com \
PROXER_TRUSTED_PROXIES=loopback,private,10.42.0.0/16 \
PROXER_TOKEN="$PROXER_TOKEN" \
proxer serverSupported trusted proxy values are loopback, private, IP literals, and CIDR ranges. Only trust proxies you control. A trusted reverse proxy must overwrite or strip inbound X-Forwarded-* and X-Real-IP headers before forwarding to Proxer, because Proxer trusts those headers from configured TCP peers.
Request Flow
Proxer uses one HTTP/WebSocket listener. Public traffic, health probes, and tunnel control all arrive on the same port.
Reserved paths:
/__proxer__/control
/__proxer__/health/live
/__proxer__/health/readyClients should pass only the server base URL, such as wss://proxy.example.com; Proxer appends /__proxer__/control internally. Paths under /__proxer__/ are never proxied to your app.
HTTP request for px-k7m3q9t2ab.proxy.example.com
-> public Proxer server
-> matching tunnel registered with the assigned subdomain
-> client forwards to 127.0.0.1:<port>
-> response returns over the same tunnel streamWebSocket upgrades follow the same host-routing rule, then become bidirectional tunnel streams. SSE and other streaming responses are forwarded in chunks.
Examples
HTTP
Terminal 1:
python3 -m http.server 3000 --bind 127.0.0.1Terminal 2:
proxer server --listen 127.0.0.1:8080 --domain proxy.localhost --token dev-tokenTerminal 3:
proxer http 3000 --server ws://127.0.0.1:8080 --subdomain demo --token dev-tokenCall the named demo route with the matching host:
curl -H 'Host: demo.proxy.localhost' http://127.0.0.1:8080/Omit --subdomain to let Proxer assign one:
proxer http 3000 --server ws://127.0.0.1:8080 --token dev-tokenThe client prints the assigned public URL:
subdomain: px-k7m3q9t2ab
public: http://px-k7m3q9t2ab.proxy.localhost:8080Call it with the host Proxer expects:
curl -H 'Host: px-k7m3q9t2ab.proxy.localhost' http://127.0.0.1:8080/Protect public access to a tunnel with Basic Auth:
PROXER_BASIC_AUTH_PASSWORD='secret' \
proxer http 3000 --server ws://127.0.0.1:8080 --token dev-tokenRequire a username and password:
PROXER_BASIC_AUTH_USERNAME='admin' \
PROXER_BASIC_AUTH_PASSWORD='secret' \
proxer http 3000 --server ws://127.0.0.1:8080 --subdomain demo --token dev-tokenServer-Sent Events
Start a small SSE app:
node --input-type=module <<'EOF'
import http from "node:http";
http
.createServer((request, response) => {
if (request.url !== "/events") {
response.writeHead(404);
response.end("Not found\n");
return;
}
response.writeHead(200, {
"cache-control": "no-cache",
"content-type": "text/event-stream",
});
response.write("data: one\n\n");
setTimeout(() => {
response.write("data: two\n\n");
response.end();
}, 1000);
})
.listen(3000, "127.0.0.1", () => {
console.log("SSE server listening on http://127.0.0.1:3000/events");
});
EOFRun the same server and client commands from the HTTP example, then stream events:
curl -N -H 'Host: demo.proxy.localhost' http://127.0.0.1:8080/eventsWebSocket
Start a WebSocket echo app:
node --input-type=module <<'EOF'
import http from "node:http";
import { WebSocketServer } from "ws";
const server = http.createServer();
const wss = new WebSocketServer({ server });
wss.on("connection", (socket) => {
socket.on("message", (data, isBinary) => {
socket.send(data, { binary: isBinary });
});
});
server.listen(3000, "127.0.0.1", () => {
console.log("WebSocket echo listening on ws://127.0.0.1:3000");
});
EOFRun the same server and client commands from the HTTP example, then connect through Proxer:
node --input-type=module <<'EOF'
import { WebSocket } from "ws";
const socket = new WebSocket("ws://127.0.0.1:8080/echo", {
headers: { host: "demo.proxy.localhost" },
});
socket.on("open", () => socket.send("hello"));
socket.on("message", (data) => {
console.log(data.toString());
socket.close();
});
EOFAgent Skill
For coding agents that read local skill files, Proxer can write a short proxer.md reference:
proxer skill install ~/.hermes/skills/proxer
proxer skill install ~/.hermes/skills/proxer --dry-run
proxer skill install ~/.hermes/skills/proxer --forceThe command writes <directory>/proxer.md. It does not contact a network service.
Development
mise exec -- pnpm install
mise exec -- pnpm run build
mise exec -- pnpm run typecheck
mise exec -- pnpm run test
mise exec -- pnpm run format:checkRun the CLI from this repository:
mise exec -- pnpm --filter @tinyrack/proxer start --help
mise exec -- pnpm --filter @tinyrack/proxer start server --listen 127.0.0.1:8080 --token dev-token
mise exec -- pnpm --filter @tinyrack/proxer start http 3000 --server ws://127.0.0.1:8080 --subdomain demo --token dev-tokenStandalone Executables
Build and smoke-test the standalone executable:
mise exec -- pnpm run pkg:build
mise exec -- pnpm run pkg:smoke -- --skip-buildThe default build writes packages/cli/dist/pkg/proxer. Release builds produce Linux, macOS, and Windows artifacts.
