Pinggy Tunnel: Expose Localhost Over SSH Without Installs
Zero-install localhost tunnels over SSH via Pinggy.
Written by Neura Market from the official Hermes Agent documentation for Pinggy Tunnel. Commands, paths, and version numbers are reproduced from the source unchanged.
Read the official documentationPinggy Tunnel gives Hermes Agent a way to put a local service on the public internet using nothing but the SSH client already on the machine. It is an optional DevOps skill (version 0.1.0, MIT licensed, authored by Teknium) that wraps Pinggy's SSH reverse-tunnel service. Reach for it when you need a public URL for a webhook receiver, a dev server, an MCP endpoint, or a quick demo, and you would rather not install a tunnel daemon to get one.
What it does
The skill turns a local port into a reachable HTTP or HTTPS address by opening an SSH reverse tunnel to a.pinggy.io:443. Your stock SSH client does the work: there is no binary to download, no service to register, and no daemon to keep running. Pinggy accepts the connection and hands back a public URL that forwards traffic to the local port you named.
The free tier needs no account. Tunnels last 60 minutes, the subdomain is random, and no signup is required. A Pro tier at $3/mo is available as an opt-in and is activated with a token.
Because the transport is plain SSH, the skill works anywhere an SSH client exists. That includes Linux, macOS, and Windows 10 and later, which all ship ssh by default.
Before you start
Two things must be true before a tunnel is useful.
sshmust be on PATH. You can confirm this withssh -V. On Linux, macOS, and Windows 10+ it is already there, so no additional install is needed.- A local service must already be listening on
127.0.0.1:before you start the tunnel. Pinggy will return URLs even if nothing is listening, but those URLs will answer with 502 until the local origin comes up.
Optionally, you can set a PINGGY_TOKEN environment variable to use paid Pro features: a persistent subdomain, a custom domain, multiple tunnels, and no 60-minute cap. The free tier requires no credentials at all.
Quick reference
The source collects the common tunnel shapes in one block. Each line is a complete SSH invocation; the username portion selects the tunnel type or carries access-control keywords.
# Plain HTTP/HTTPS tunnel for port 8000 (free tier)
ssh -p 443 -o StrictHostKeyChecking=no -o ServerAliveInterval=30 \
-R0:localhost:8000 free@a.pinggy.io
# TCP tunnel (databases, raw SSH, etc.)
ssh -p 443 -o StrictHostKeyChecking=no -R0:localhost:5432 tcp@a.pinggy.io
# TLS tunnel (Pinggy can't decrypt — bring your own certs at origin)
ssh -p 443 -o StrictHostKeyChecking=no -R0:localhost:443 tls@a.pinggy.io
# Basic auth gate (b:user:pass)
ssh -p 443 -o StrictHostKeyChecking=no -R0:localhost:8000 \
"b:admin:secret+free@a.pinggy.io"
# Bearer token gate (k:token)
ssh -p 443 -o StrictHostKeyChecking=no -R0:localhost:8000 \
"k:mysecrettoken+free@a.pinggy.io"
# IP whitelist (w:CIDR)
ssh -p 443 -o StrictHostKeyChecking=no -R0:localhost:8000 \
"w:203.0.113.0/24+free@a.pinggy.io"
# Enable CORS + force HTTPS redirect
ssh -p 443 -o StrictHostKeyChecking=no -R0:localhost:8000 \
"co+x:https+free@a.pinggy.io"
# Pro tier (persistent URL, no 60-min cap)
ssh -p 443 -o StrictHostKeyChecking=no -R0:localhost:8000 "$PINGGY_TOKEN+a.pinggy.io"
The first three lines choose the tunnel protocol by username: free@ for HTTP/HTTPS, tcp@ for raw TCP such as databases or SSH, and tls@ for TLS where Pinggy cannot decrypt and you supply certificates at the origin. The remaining lines stack access-control keywords onto the username, separated by +. The Pro line uses the token itself as the username and drops free@.
Procedure: start a tunnel and get the URL
The model is expected to use the terminal tool for this. The tunnel has to stay alive for the whole share, so it runs as a background process and the public URL is parsed from its output.
1. Confirm a local origin is up
curl -sI http://127.0.0.1:8000/ | head -1
# expect HTTP/1.x 200 (or any non-connection-refused response)
If nothing is listening yet, start it first (for example python -m http.server 8000 --bind 127.0.0.1). Pinggy will return a URL pointed at nothing, and the user will see 502 until the origin comes up.
2. Launch the tunnel as a background process
Use terminal(background=True) and capture output to a logfile. Pinggy prints the URLs on stdout and then keeps the connection open.
LOG=~/.hermes/cache/scratch/pinggy-8000.log
nohup ssh -p 443 \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
-o ServerAliveInterval=30 \
-o ServerAliveCountMax=3 \
-R0:localhost:8000 free@a.pinggy.io \
> "$LOG" 2>&1 &
echo $! > ~/.hermes/cache/scratch/pinggy-8000.pid
StrictHostKeyChecking=no together with UserKnownHostsFile=/dev/null skips the first-run host-key prompt. ServerAliveInterval=30 keeps the SSH session from being torn down by an idle NAT.
3. Parse the URL out of the log
sleep 4
grep -oE 'https://[a-z0-9-]+\.[a-z]+\.pinggy\.link' ~/.hermes/cache/scratch/pinggy-8000.log | head -1
Expected output looks like:
You are not authenticated.
Your tunnel will expire in 60 minutes.
http://yqycl-98-162-69-48.a.free.pinggy.link
https://yqycl-98-162-69-48.a.free.pinggy.link
Hand the https://...pinggy.link URL to the user.
4. Verify
curl -sI https://<the-url>/ | head -3
# expect 200/302/whatever the local origin actually returns
If you get 502 Bad Gateway, the SSH session is up but the local origin is not listening. Fix step 1 first.
5. Teardown
kill "$(cat ~/.hermes/cache/scratch/pinggy-8000.pid)"
# or, if the pid file got lost:
pkill -f 'ssh -p 443 .* free@a\.pinggy\.io'
If you have a session_id from terminal(background=True), prefer process(action='kill', session_id=...).
Access control via username keywords
Pinggy stacks control flags into the SSH username, separated by +. Always quote the whole user@host argument when it contains a +.
| Keyword | Effect |
|---|---|
b:user:pass | HTTP Basic auth gate |
k:token | Bearer-token header gate (Authorization: Bearer ) |
w:CIDR | IP whitelist (single IP or CIDR, repeatable) |
co | Add Access-Control-Allow-Origin: * (CORS) |
x:https | Force HTTPS, auto-redirect HTTP to HTTPS |
a:Name:Value | Add request header |
u:Name:Value | Update request header |
r:Name | Remove request header |
qr | Print a QR code of the URL to stdout (handy for mobile sharing) |
Combine freely: "b:admin:secret+co+x:https+free@a.pinggy.io".
Web debugger (optional)
Pinggy can mirror inbound traffic to localhost:4300 for inspection. Add a local forward to the SSH command:
ssh -p 443 -L4300:localhost:4300 -R0:localhost:8000 free@a.pinggy.io
Then open http://localhost:4300 in a browser to see live request/response pairs.
Recipes
These composite patterns each start an origin, start a tunnel, parse the URL, and hand it back. They are self-contained.
Recipe 1: receive a webhook callback
Use this when an external service (Stripe, GitHub, Discord, AgentMail, and so on) needs to POST to a publicly reachable URL during a local task.
# 1. Tiny capturing server: every request gets appended to ~/.hermes/cache/scratch/webhook-hits.log
cat >~/.hermes/cache/scratch/webhook-server.py <<'PY'
import http.server, json, datetime, pathlib
LOG = pathlib.Path("~/.hermes/cache/scratch/webhook-hits.log").expanduser()
class H(http.server.BaseHTTPRequestHandler):
def _capture(self):
n = int(self.headers.get("content-length") or 0)
body = self.rfile.read(n).decode("utf-8", "replace") if n else ""
rec = {"t": datetime.datetime.utcnow().isoformat(), "path": self.path,
"method": self.command, "headers": dict(self.headers), "body": body}
with LOG.open("a") as f: f.write(json.dumps(rec) + "\n")
self.send_response(200); self.send_header("content-type","application/json")
self.end_headers(); self.wfile.write(b'{"ok":true}\n')
def do_GET(self): self._capture()
def do_POST(self): self._capture()
def log_message(self,*a,**k): pass
http.server.HTTPServer(("127.0.0.1", 18080), H).serve_forever()
PY
nohup python ~/.hermes/cache/scratch/webhook-server.py >~/.hermes/cache/scratch/webhook-server.log 2>&1 &
echo $! >~/.hermes/cache/scratch/webhook-server.pid
# 2. Tunnel — bearer-token-gate so randos can't pollute the capture log
nohup ssh -p 443 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o ServerAliveInterval=30 \
-R0:localhost:18080 "k:$(openssl rand -hex 12)+free@a.pinggy.io" \
>~/.hermes/cache/scratch/webhook-pinggy.log 2>&1 &
echo $! >~/.hermes/cache/scratch/webhook-pinggy.pid
sleep 5
URL=$(grep -oE 'https://[a-z0-9-]+\.[a-z]+\.pinggy\.link' ~/.hermes/cache/scratch/webhook-pinggy.log | head -1)
echo "Webhook URL: $URL"
# 3. While the agent works, watch hits land
tail -f ~/.hermes/cache/scratch/webhook-hits.log
Hand $URL to the service that needs to call you. Teardown: kill $(cat ~/.hermes/cache/scratch/webhook-server.pid) $(cat ~/.hermes/cache/scratch/webhook-pinggy.pid).
Recipe 2: expose an MCP server over HTTP/SSE
Use when a remote MCP client (Claude Desktop on another machine, a teammate's editor, and so on) needs to reach an MCP server running on the local box. Only works for MCP servers that speak HTTP transport; stdio-mode servers cannot be tunneled.
# 1. Start the MCP server in HTTP mode (example: a FastMCP server on port 8765)
nohup python my_mcp_server.py --transport http --port 8765 \
>~/.hermes/cache/scratch/mcp-server.log 2>&1 &
echo $! >~/.hermes/cache/scratch/mcp-server.pid
# 2. Tunnel with a bearer token — MCP traffic should not be open to the internet
TOKEN=$(openssl rand -hex 16)
nohup ssh -p 443 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o ServerAliveInterval=30 \
-R0:localhost:8765 "k:$TOKEN+free@a.pinggy.io" \
>~/.hermes/cache/scratch/mcp-pinggy.log 2>&1 &
echo $! >~/.hermes/cache/scratch/mcp-pinggy.pid
sleep 5
URL=$(grep -oE 'https://[a-z0-9-]+\.[a-z]+\.pinggy\.link' ~/.hermes/cache/scratch/mcp-pinggy.log | head -1)
echo "MCP URL: $URL"
echo "Bearer token: $TOKEN"
The remote client connects to $URL with Authorization: Bearer $TOKEN. Hermes' own native MCP client config: {"transport": "http", "url": "", "headers": {"Authorization": "Bearer "}}.
Recipe 3: expose a local LLM endpoint (Ollama / vLLM / llama.cpp)
Share a local model with a remote caller (another agent, a phone, a teammate). Ollama listens on :11434, vLLM and llama.cpp typically on :8000.
# Pre-req: the model server is already running on 127.0.0.1:11434 (Ollama default)
TOKEN=$(openssl rand -hex 16)
nohup ssh -p 443 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o ServerAliveInterval=30 \
-R0:localhost:11434 "k:$TOKEN+co+free@a.pinggy.io" \
>~/.hermes/cache/scratch/llm-pinggy.log 2>&1 &
echo $! >~/.hermes/cache/scratch/llm-pinggy.pid
sleep 5
URL=$(grep -oE 'https://[a-z0-9-]+\.[a-z]+\.pinggy\.link' ~/.hermes/cache/scratch/llm-pinggy.log | head -1)
echo "Endpoint: $URL"
echo "Token: $TOKEN"
# Verify
curl -s "$URL/api/tags" -H "Authorization: Bearer $TOKEN" | head
co enables CORS so a browser caller can hit the endpoint. Drop co for backend-only callers. For an OpenAI-compatible vLLM/llama.cpp endpoint, callers use base URL $URL/v1 with Authorization: Bearer $TOKEN, but note that Pinggy strips and replaces nothing in the body, so the model server itself sees Pinggy's token; the local server should be configured to ignore auth (it is already on 127.0.0.1) and let Pinggy do the gating.
Recipe 4: share a dev server with a one-shot password
The fastest "let a teammate poke at my running app" pattern. Random password, prints once, dies when you Ctrl-C.
PASS=$(openssl rand -base64 12 | tr -d '+/=' | head -c 12)
echo "Dev server password: $PASS"
ssh -p 443 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o ServerAliveInterval=30 \
-R0:localhost:3000 "b:dev:$PASS+co+x:https+free@a.pinggy.io"
# URL prints to the terminal. Share URL + password. Ctrl-C to tear down.
b:dev:$PASS gates the URL with HTTP Basic auth. x:https forces TLS. co adds CORS for SPA frontends.
Verification
# End-to-end: spin up a trivial origin, tunnel it, hit it, tear down
python -m http.server 18000 --bind 127.0.0.1 >~/.hermes/cache/scratch/origin.log 2>&1 &
ORIGIN_PID=$!
nohup ssh -p 443 \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
-R0:localhost:18000 free@a.pinggy.io >~/.hermes/cache/scratch/pinggy-verify.log 2>&1 &
SSH_PID=$!
sleep 5
URL=$(grep -oE 'https://[a-z0-9-]+\.[a-z]+\.pinggy\.link' ~/.hermes/cache/scratch/pinggy-verify.log | head -1)
echo "URL: $URL"
curl -sI "$URL/" | head -1
kill "$SSH_PID" "$ORIGIN_PID"
Expected: a pinggy.link URL and HTTP/2 200 on the curl head.
When not to use it
If the host already has cloudflared configured, prefer the cloudflared-quick-tunnel skill. Cloudflare quick tunnels do not expire after 60 minutes, which makes them a better fit for shares that need to outlive a single hour.
Limits and gotchas
- 60-minute hard cap on the free tier. The SSH session terminates at the 60-minute mark and the URL goes dead. For longer shares, use
PINGGY_TOKEN(Pro) or auto-restart with a shell loop, keeping in mind that the URL changes on every restart for free-tier. - Free-tier URL is random and changes on restart. Do not bookmark it or paste it into a config file. Re-parse from the log each time.
- Concurrent free tunnels are limited to one per source IP. Starting a second tunnel from the same machine usually kills the first. Pro tier lifts this.
+in usernames must be quoted. A baressh ... b:admin:secret+free@a.pinggy.ioworks in bash but breaks under shells that treat+specially or when assembled programmatically. Always wrap in double quotes.- Do not tunnel anything sensitive without an access-control flag. A bare HTTP tunnel is reachable by anyone with the URL. Use
b:,k:, orw:for non-public services. process(action='log')may miss SSH banner output. Pinggy prints the URLs and then the SSH session goes interactive. Always redirect to a logfile andgrepthe file directly, the same pattern ascloudflared-quick-tunnel.- Host-key prompt on first run. Default OpenSSH config asks the user to accept Pinggy's host key. Always pass
-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/nullfor unattended runs. - TCP and TLS tunnels return a
.a.pinggy.online:pair, not an https URL. Parse with a different regex (tcp://and a port). Do not assume every Pinggy tunnel is HTTP. - Pro mode requires the token as the username, not a flag. Use
"$PINGGY_TOKEN+a.pinggy.io"(nofree@). With a token you can also add:persistentfor a stable subdomain; seepinggy.io/docs/.
Related
cloudflared-quick-tunnel: the preferred option whencloudflaredis already configured, since its quick tunnels do not expire after 60 minutes.- The
terminaltool andprocess(action='kill', session_id=...): how the tunnel is launched in the background and torn down cleanly. - Hermes' native MCP client configuration, for pointing a remote client at a tunneled MCP server.