tunnl.gg
Expose a local web server to the internet with one SSH command. Nothing to install, no
account: you get a public HTTPS URL, and with stable@ the same URL every time.
ssh -t -R 80:localhost:8080 proxy.tunnl.gg
This repository is the open-source (MIT) server behind tunnl.gg. Use the hosted service for free, or run your own.
Contents
- Using tunnl.gg
- Running your own server
- Contributing
- License
Using tunnl.gg
Features
- One command, nothing to install: uses the SSH client already on macOS, Linux and Windows 10+. No account, no auth token.
- HTTPS and WebSockets on a subdomain like
https://happy-tiger-a1b2c3d4.tunnl.gg. - Stable URLs: connect as
stable@and your SSH key gets the same URL every time. - A live view in your terminal: the URL, a QR code for phones, and a colored log of every request (path, status, size, timing, visitor), with plain-words explanations when one fails.
- Options in the command: Host header rewrite for dev servers, passwords, IP allowlists, CORS, and JSON output for scripts and AI agents.
- Safe by default: rate limits, abuse protection, a phishing warning page for browsers, and tunnels kept out of search engines.
Quick start
# Expose the app on port 8080
ssh -t -R 80:localhost:8080 proxy.tunnl.gg
tunnl prints the public URL and a QR code, then logs requests as they arrive. Press
Ctrl+C to stop. The -t flag gives the session a terminal, which the server needs to show
the URL (for scripts, see JSON output).
The first time you connect, ssh asks you to confirm the server's host key. The ED25519 key
of proxy.tunnl.gg is:
SHA256:5uQ6VobmUpY4wyMmm5jnAwFtt3aX06WYDDM4ER+2WUw
It's also published as a DNSSEC-signed SSHFP record, so ssh can check it for you: add
-o VerifyHostKeyDNS=yes. With a resolver that validates DNSSEC, ssh then accepts the key
without asking.
Keep the same URL
Each connection gets a new random URL. Connect as stable instead, and the URL is tied to
your SSH key and stays the same every time you reconnect:
ssh -t -R 80:localhost:8080 stable@proxy.tunnl.gg
- Any SSH key works; there's nothing to register. Without one,
stable@is refused: create one withssh-keygen -t ed25519. - Each key has one live tunnel. Reconnecting with the same key (say, after your laptop slept) replaces the old connection, so two machines need two keys.
- The URL can't be worked out from your public key, but it changes if the server's host key changes.
Options
Add options after the host, as name=value:
ssh -t -R 80:localhost:5173 proxy.tunnl.gg host=localhost auth=me:secret allow=203.0.113.7
| Option | What it does |
|---|---|
host=localhost:3000 |
Sends this Host header to your app instead of the public one. Fixes "Blocked request" from Vite, Django's ALLOWED_HOSTS, Rails and webpack-dev-server without changing their config. X-Forwarded-Host still carries the public host. |
auth=user:password |
Visitors must sign in with HTTP basic authentication. The password isn't passed on to your app. Each visitor gets 10 wrong passwords, then one every 10 seconds. |
allow=203.0.113.7,198.51.100.0/24 |
Only visitors from these IPs or networks get through; others get 403. Repeat to add more. |
cors=https://app.example.com |
Lets pages on these sites call the tunnel from a browser, with cookies. tunnl answers CORS preflights, even on a password-protected tunnel, and adds the headers unless the app sets its own. cors=* allows any site, without cookies. Repeat to add more. |
output=json |
Prints JSON lines instead of the banner and request table. See JSON output. |
ssh ... proxy.tunnl.gg help lists them. An invalid option ends the session with an error.
Options are visible to the server, and auth= ends up in your shell history like any
command. A server can restrict which options anonymous clients may use
(Server.SetFreeLimits; all are allowed by default). output= only changes what the
client sees, so every client may use it.
JSON output for scripts and agents
With output=json, the session prints one JSON object per line instead of the banner, QR
code and table. Scripts, CI jobs and AI coding agents (Claude Code, Cursor, Codex) can read
the URL and watch requests without parsing terminal output. It works on every tunnel.
Run it in the background with -n -T (no input, no terminal) and read the URL from the
first line:
ssh -n -T -o ServerAliveInterval=30 -R 80:localhost:3000 stable@proxy.tunnl.gg output=json > tunnel.jsonl &
until grep -q '"event":"tunnel"' tunnel.jsonl 2>/dev/null; do sleep 1; done
head -1 tunnel.jsonl | jq -r .url
The first line describes the tunnel; then come one line per request, and notices such as the warning before a time limit or the summary when tunnl closes the tunnel:
{"event":"tunnel","url":"https://brave-fox-7c41e09b.tunnl.gg","subdomain":"brave-fox-7c41e09b","stable":true,"idle_timeout_seconds":7200,"max_lifetime_seconds":86400}
{"event":"request","time":"2026-10-09T08:14:03Z","method":"POST","path":"/webhooks/stripe","status":200,"bytes":2,"duration_ms":41,"visitor":"54.187.174.169"}
{"event":"notice","message":"Stopped. 1 request, 2 B served."}
| Field | Meaning |
|---|---|
event |
tunnel (first line), request, or notice |
url, subdomain |
Where the tunnel is reachable |
stable |
Whether the URL comes back on every reconnect (stable@, or an account's subdomain) |
idle_timeout_seconds, max_lifetime_seconds |
When the server closes the tunnel; 0 means never |
options |
Options in effect, like host or auth |
warning |
Set when the server ignored part of the request, like a subdomain name without an account |
status, bytes, duration_ms |
The app's response; left out when there was none |
from_tunnl |
true when tunnl answered the request itself (rate limit, warning page, wrong password) |
detail |
Why a request failed, like the app not answering |
Lines end in \r\n, which JSON parsers read as whitespace. Errors in the command itself,
like an unknown option, are printed as text before the session ends. The
guide for AI coding agents has instructions to
paste into AGENTS.md or CLAUDE.md.
Recipes
Expose another machine on your network:
ssh -t -R 80:192.168.1.100:3000 proxy.tunnl.gg
Reconnect automatically, keeping the same URL through Wi-Fi drops and time limits:
while true; do
ssh -t -o ServerAliveInterval=30 -o ExitOnForwardFailure=yes \
-R 80:localhost:8080 stable@proxy.tunnl.gg
sleep 5
done
To run it as a background service with launchd or systemd, see Keep a tunnel running.
Skip the warning page that browsers see once a day (once a week for stable URLs), for your own tools and tests:
curl -H "tunnl-skip-browser-warning: 1" https://happy-tiger-a1b2c3d4.tunnl.gg
Webhooks, curl and other non-browser clients never see it.
Choose a subdomain (deployments with accounts): with Server.SetAccounts, an account's
key connects as pro@ and gets its default subdomain. To open another of the account's
subdomains, name it as the bind address; each command is one tunnel:
ssh -t -R myapp:80:localhost:3000 pro@proxy.tunnl.gg
ssh -t -R api:80:localhost:8000 pro@proxy.tunnl.gg
A name the account doesn't have ends the session with an error. Anonymous clients keep their random or stable URL; a name they give is ignored, and the banner says so.
Limits and protection
| Limit | Value | Description |
|---|---|---|
| Tunnels per IP | 3 | Concurrent tunnels per IP address |
| Total tunnels | 1000 | Server-wide |
| Inactivity timeout | 2 hours | A tunnel closes after 2 hours with no requests or open WebSockets |
| Max tunnel lifetime | 24 hours | Absolute limit |
| Requests per visitor | 25/s (burst 200) | Per visitor IP (IPv6 by /64), per tunnel; excess gets 429 |
| Requests per tunnel | 50/s (burst 400) | Across all visitors; excess gets 429 |
| In-flight requests | 256 per visitor, 512 per tunnel | Concurrent proxied requests; excess gets 429 (visitor) or 503 (tunnel) |
| Request and response bodies | 64 MB each | |
| Daily transfer | 5 GB per IP | Request and response bodies and WebSocket data, both ways (IPv6 by /64). Stable URLs count against both the IP and the SSH key, so neither a new key nor a new address starts over. After that requests get 429 until 00:00 UTC, and browsers a page saying when the tunnel is back |
| Request idle timeout | 2 minutes | A proxied request is canceled after 2 minutes without data either way; long polls and streams can run longer while data flows |
| WebSockets | 100 per tunnel, 20 per visitor IP | Concurrent connections |
| WebSocket transfer | 1 GB per direction | Per connection |
| WebSocket idle timeout | 2 hours | |
| SSH connections | 10 per minute per IP | New connections |
| SSH handshakes | 3 per IP, 100 total; 30 second timeout | In progress at once; excess connections are dropped |
| Unanswered channel opens | 32 per tunnel | Connections the SSH client hasn't accepted yet; further requests wait up to 10 seconds, or get 502 at once if the client has answered none for 5 seconds |
| SSH keepalive | 45 seconds | The connection closes after 45 seconds with nothing from the client; the server asks for a reply after 15 quiet seconds |
| Abuse blocks | 1 hour, after 10 violations | Temporary IP block after repeated SSH rate limit violations |
Browsers see a warning page before reaching a tunnel (once a day, or once a week for stable
URLs), to protect them from phishing. Every response from a tunnel, the app's and tunnl's
own, carries X-Robots-Tag: noindex, nofollow whatever the app sends: a tunnel is
someone's machine for a while, not a website, and indexing it would make phishing pages
easy to find.
Running your own server
How it works
ssh -R 80:localhost:8080 https://happy-tiger-a1b2c3d4.yourdomain.com
┌─────────────┐ ┌─────────┐
│ Your laptop │ │ Browser │
│ app :8080 │ └────┬────┘
└──────┬──────┘ │
│ SSH (:22) │ HTTPS (:443)
▼ ▼
┌────────────────────────────────────────────────────────┐
│ tunnl server │
│ SSH :22 ──► tunnel registry ◄── HTTPS :443 │
│ subdomain → tunnel │
│ HTTP :80 redirects to HTTPS · stats on 127.0.0.1:9090│
└────────────────────────────────────────────────────────┘
- The client runs
ssh -t -R 80:localhost:8080 proxy.yourdomain.com. - The server picks a subdomain (random, or derived from the key for
stable@) and prints the URL. - A browser requests
https://happy-tiger-a1b2c3d4.yourdomain.com. - The server finds the tunnel and sends the request through the SSH connection.
- The SSH client passes it to
localhost:8080and the response travels back.
Before you start
A server with ports 22, 80 and 443 free. tunnl takes port 22, so move your own SSH first (below).
A domain with both records pointing at the server:
A yourdomain.com → YOUR_SERVER_IP A *.yourdomain.com → YOUR_SERVER_IPA wildcard TLS certificate for
yourdomain.comand*.yourdomain.com. tunnl doesn't obtain certificates itself. Wildcards need a DNS challenge, for example with certbot:sudo apt install certbot sudo certbot certonly --manual --preferred-challenges dns \ -d yourdomain.com -d '*.yourdomain.com'--manualcertificates don't renew on their own; a certbot DNS plugin for your DNS provider (such aspython3-certbot-dns-cloudflare) renews them automatically.Move your server's own SSH off port 22. Test the new port before closing your session:
sudo nano /etc/ssh/sshd_config # change: Port 22 → Port 2222 sudo ufw allow 2222/tcp sudo systemctl restart sshd ssh -p 2222 user@your-server # in a new terminal
Install with Docker
git clone https://github.com/klipitkas/tunnl.gg.git
cd tunnl.gg
mkdir -p data/certs data/hostkey
sudo cp /etc/letsencrypt/live/yourdomain.com/fullchain.pem data/certs/
sudo cp /etc/letsencrypt/live/yourdomain.com/privkey.pem data/certs/
# The container runs as UID 65534: let it read the certificates and
# store the SSH host key it generates on first start
sudo chown -R 65534:65534 data/certs data/hostkey
docker compose up -d
docker compose logs -f
Set DOMAIN in docker-compose.yml to your domain first. After renewing the certificate, copy
the new files into data/certs/ and run docker compose restart. docker compose up tunnl-dev starts a second instance on other ports for staging.
Install with systemd
Build the binary (Go 1.26.9+):
git clone https://github.com/klipitkas/tunnl.gg.git
cd tunnl.gg
make build-small # ~6 MB; make build-all cross-compiles for Linux and macOS
Run it as an unprivileged user. It only needs CAP_NET_BIND_SERVICE to bind ports 22, 80
and 443:
# Service user, binary (root-owned so the service can't replace it),
# and a certificate directory readable by the service
sudo useradd --system --no-create-home --shell /usr/sbin/nologin tunnl
sudo install -d -m 0755 /opt/tunnl
sudo install -m 0755 bin/tunnl /opt/tunnl/tunnl
sudo install -d -o root -g tunnl -m 0750 /etc/tunnl
Let's Encrypt keys are readable by root only, so copy them for the service with a certbot deploy hook, which also runs after every renewal:
sudo tee /etc/letsencrypt/renewal-hooks/deploy/tunnl.sh > /dev/null <<'HOOK'
#!/bin/sh
set -e
LINEAGE="${RENEWED_LINEAGE:-/etc/letsencrypt/live/yourdomain.com}"
install -o root -g tunnl -m 0644 "$LINEAGE/fullchain.pem" /etc/tunnl/fullchain.pem
install -o root -g tunnl -m 0640 "$LINEAGE/privkey.pem" /etc/tunnl/privkey.pem
systemctl try-restart tunnl
HOOK
sudo chmod 0755 /etc/letsencrypt/renewal-hooks/deploy/tunnl.sh
sudo /etc/letsencrypt/renewal-hooks/deploy/tunnl.sh
Save this as /etc/systemd/system/tunnl.service:
[Unit]
Description=Tunnl.gg SSH Tunnel Service
After=network.target
[Service]
Type=simple
User=tunnl
Group=tunnl
ExecStart=/opt/tunnl/tunnl
Restart=always
RestartSec=5
# Stores the SSH host key in /var/lib/tunnl
StateDirectory=tunnl
WorkingDirectory=/var/lib/tunnl
Environment=SSH_ADDR=:22
Environment=HTTP_ADDR=:80
Environment=HTTPS_ADDR=:443
Environment=STATS_ADDR=127.0.0.1:9090
Environment=HOST_KEY_PATH=/var/lib/tunnl/host_key
Environment=TLS_CERT=/etc/tunnl/fullchain.pem
Environment=TLS_KEY=/etc/tunnl/privkey.pem
Environment=DOMAIN=yourdomain.com
# Only when behind a proxy, see "Behind a proxy"
#Environment=TRUSTED_PROXIES=cloudflare
# Bind privileged ports without running as root
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=true
LockPersonality=true
MemoryDenyWriteExecute=true
SystemCallArchitectures=native
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now tunnl
Configuration
All settings are environment variables:
| Variable | Default | Description |
|---|---|---|
DOMAIN |
tunnl.gg |
The domain tunnels are served under |
SSH_ADDR |
:22 |
SSH listen address |
HTTP_ADDR |
:80 |
HTTP listen address (redirects to HTTPS) |
HTTPS_ADDR |
:443 |
HTTPS listen address |
STATS_ADDR |
127.0.0.1:9090 |
Stats endpoint; keep it on localhost |
MAX_TOTAL_TUNNELS |
1000 |
Positive server-wide tunnel admission cap; size from measured workload and available resources |
MAX_IN_FLIGHT_PER_VISITOR |
256 |
Positive concurrent HTTP request limit per visitor across all tunnels; excess requests receive HTTP 429 |
PPROF_ADDR |
unset (disabled) | Optional private Go profiler, e.g. 127.0.0.1:9092; accepts only numeric loopback addresses |
PPROF_TRACE_WINDOW |
unset (disabled) | With PPROF_ADDR, keeps about this much execution trace in memory, e.g. 10s, for the profiler's /debug/pprof/trace-window; costs a few percent of CPU |
HOST_KEY_PATH |
host_key |
SSH host key, generated on first start. Keep it: stable URLs and clients' known_hosts depend on it |
TLS_CERT |
/etc/letsencrypt/live/tunnl.gg/fullchain.pem |
TLS certificate |
TLS_KEY |
/etc/letsencrypt/live/tunnl.gg/privkey.pem |
TLS private key |
TRUSTED_PROXIES |
unset | Proxies whose headers identify visitors. See Behind a proxy |
MAX_TOTAL_TUNNELS limits reserved tunnel slots, including connections still
setting up. It does not raise per-IP or per-account limits. A higher cap allows
more connected tunnels; usable traffic capacity still depends on workload,
CPU, memory and bandwidth. The stats response includes max_total_tunnels.
MAX_IN_FLIGHT_PER_VISITOR counts proxied HTTP requests that are still running,
across every tunnel visited from the same IP (IPv6 visitors are grouped by /64).
People sharing a public IP share this allowance. Set a positive integer and
restart to change it; an unset or empty value uses 256. Zero, negative and
non-integer values fail startup. WebSockets and requests-per-second limits are
separate. The stats response reports max_in_flight_per_visitor.
For a temporary CPU profile, set PPROF_ADDR=127.0.0.1:9092, restart, then run
curl 'http://127.0.0.1:9092/debug/pprof/profile?seconds=30' -o cpu.pprof on the
server during representative traffic. Inspect it with go tool pprof cpu.pprof.
Heap, allocation, goroutine, mutex and block profiles are available on the same
listener. While it's on, the runtime samples one in 100 contended lock waits and
blocking events in proportion to their length, every one of 100µs or more.
With PPROF_TRACE_WINDOW=10s as well, curl http://127.0.0.1:9092/debug/pprof/trace-window -o trace.out
saves about the last 10 seconds of execution trace, for example right after a
stall; open it with go tool trace trace.out.
Unset PPROF_ADDR and restart to disable the listener after measuring.
To run a second instance on the same machine, give it other ports and its own host key:
SSH_ADDR=:2223 HTTP_ADDR=:8080 HTTPS_ADDR=:8443 STATS_ADDR=127.0.0.1:9091 \
HOST_KEY_PATH=./host_key_dev ./tunnl
Behind a proxy
Rate limits, WebSocket limits and the X-Forwarded-For header sent to apps all use the
visitor's IP address. By default the server trusts no proxies and uses the TCP
connection's address, which is right when it faces the internet directly (including most
on-prem setups). When HTTPS traffic arrives through proxies, list them in
TRUSTED_PROXIES:
| Setup | TRUSTED_PROXIES |
|---|---|
| Directly on the internet, or on-prem with no proxy | unset |
| Behind Cloudflare | cloudflare |
| Behind your own reverse proxy or load balancer | its IPs or CIDRs, e.g. 10.0.0.0/8 |
| Your proxy behind Cloudflare | cloudflare,10.0.0.0/8 |
cloudflare trusts CF-Connecting-IP only from Cloudflare's published ranges (built in,
from cloudflare.com/ips). Other entries trust
X-Forwarded-For, read right to left past trusted proxies, so addresses a visitor adds
themselves are ignored. Headers from any other address are ignored too, so list only
proxies you control, and firewall ports 80 and 443 so only they can reach the server.
With cloudflare trusted, idle HTTPS connections stay open for 910 seconds instead of
120, just past the 900 seconds Cloudflare keeps an idle connection to the origin. Cloudflare
then closes them itself and reuses them until it does, instead of making a new TLS
connection after every 2 idle minutes.
Stats endpoint
Server statistics, on localhost only:
curl http://127.0.0.1:9090/
curl "http://127.0.0.1:9090/?subdomains=true" # with the active subdomains
{
"active_tunnels": 3,
"unique_ips": 2,
"total_connections": 15,
"total_requests": 1247,
"blocked_ips": 1,
"total_blocked": 5,
"total_rate_limited": 23,
"active_websockets": 4,
"active_requests": 12,
"subdomains": ["happy-tiger-a1b2c3d4", "calm-eagle-e5f6a7b8", "swift-wolf-d9e0f1a2"]
}
curl http://127.0.0.1:9090/metrics serves these counts and more in the Prometheus
text format, for a local collector: proxied requests' latency and size histograms
and results, requests tunnl answered itself by reason, how long tunnel clients take
to open channels, visitors' HTTPS connections by protocol and TLS resumption, SSH
clients by software and negotiated algorithms, every open tunnel's requests and bytes
per minute, and Go runtime metrics such as scheduling latency. None of it names a
tunnel, visitor or client.
Troubleshooting
Connection refused. Check that the service runs and listens, and that the firewall lets the ports through:
docker compose ps # or: sudo systemctl status tunnl
sudo ss -tlnp | grep -E ':(22|80|443)'
sudo ufw status
Host key verification. The first connection asks you to accept the server's host key:
answer yes. If the host key ever changes, clients see a warning and stable URLs change
too, so keep HOST_KEY_PATH across upgrades.
No URL shown, or the connection hangs. Add -t, so the session has a terminal:
ssh -R 80:localhost:8080 proxy.tunnl.gg # no URL
ssh -t -R 80:localhost:8080 proxy.tunnl.gg # works
For scripts without a terminal, use -T with output=json.
Certificate errors. Check that the files exist and haven't expired, then renew:
ls -la data/certs/ # Docker; /etc/tunnl/ with systemd
sudo certbot renew # the systemd deploy hook copies the new files
sudo cp /etc/letsencrypt/live/yourdomain.com/*.pem data/certs/ && docker compose restart
Contributing
Local development
make dev runs a server on your machine with a self-signed certificate and
DOMAIN=localhost, so you can try changes before deploying. In another terminal, start
something on port 3000 and open a tunnel to it:
ssh -p 2200 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-R 80:localhost:3000 localhost
The session shows the URL, e.g. https://happy-tiger-a1b2c3d4.localhost. Add the dev
HTTPS port to reach it: curl -k https://happy-tiger-a1b2c3d4.localhost:8443 (browsers and
curl resolve *.localhost to your machine).
Make commands
| Command | Description |
|---|---|
make build |
Optimized build |
make build-small |
Smallest build (~6 MB) |
make build-tiny |
Compressed with UPX, if installed |
make build-all |
Cross-compile for Linux and macOS |
make build-dev |
Fast build with debug symbols |
make dev |
Local server on unprivileged ports (see Local development) |
make test |
Run the tests |
make lint |
Run golangci-lint (v2) |
make vuln |
Check reachable code for known vulnerabilities |
make clean |
Remove build artifacts |
Project structure
tunnl.gg/
├── cmd/tunnl/ # Entry point
├── pkg/
│ ├── serve/ # Server startup, shared with hosted builds
│ ├── server/ # SSH and HTTP handling, tunnel registry, accounts, abuse
│ │ # protection, limits, stats
│ ├── tunnel/ # A tunnel: SSH channels, options, rate limits, request log
│ ├── subdomain/ # Subdomain generation and validation
│ ├── clientip/ # Visitor IPs behind trusted proxies
│ └── config/ # Configuration and limits
├── Dockerfile # Multi-stage build (scratch image)
├── docker-compose.yml # Production and dev instances
├── Makefile
└── .golangci.yml # Linter configuration
ARCHITECTURE.md describes the design in more depth.
