A minimal SSH tunneling service. Expose your local apps to the internet with a single command.
ssh -t -R 80:localhost:8080 proxy.tunnl.ggNote: The
-tflag is required to allocate a TTY, which allows the server to display your tunnel URL.
- Memorable subdomain per connection (e.g.,
https://happy-tiger-a1b2c3d4.tunnl.gg) - QR code of the URL in the terminal, for opening the tunnel on a phone
- Live, colored request log in your terminal: path and query, status, size, timing, and visitor, with plain-words explanations when a request fails
- Optional stable URL tied to your SSH key (
stable@), no account needed - Options in the
sshcommand: password protection, IP allowlists, and a Host header rewrite for dev servers that block unknown hosts - HTTPS for every tunnel with a wildcard certificate you provide (e.g. from Let's Encrypt)
- WebSocket support
- Comprehensive rate limiting and abuse protection
- Phishing protection via interstitial warning page
- Built-in stats/metrics endpoint
- No authentication required
- Zero configuration for clients
| Limit | Value | Description |
|---|---|---|
| Tunnels per IP | 3 | Max concurrent tunnels per IP address |
| Total tunnels | 1000 | Server-wide tunnel 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 |
| Request body size | 128 MB | Max upload size |
| Response body size | 128 MB | Max response size |
| In-flight requests | 256 per visitor, 512 per tunnel | Concurrent proxied requests; excess gets 429 (visitor) or 503 (tunnel) |
| Request idle timeout | 2 minutes | Proxied requests are canceled after 2 minutes without data in either direction; long polls and streams can run longer while data flows |
| WebSocket transfer | 1 GB per direction | Max data per WebSocket connection |
| WebSocket idle timeout | 2 hours | WebSocket closed after inactivity |
| WebSockets per tunnel | 100 | Max concurrent WebSockets per tunnel |
| WebSockets per visitor | 20 | Max concurrent WebSockets per visitor IP across all tunnels |
| SSH handshake timeout | 30 seconds | Max time for SSH handshake to complete |
| Concurrent handshakes | 3 per IP, 100 total | In-progress SSH handshakes; 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 for one |
| Connections per minute | 10 | New SSH connections per IP |
| Inactivity timeout | 2 hours | Tunnel closes after 2 hours with no requests or open WebSockets |
| Max tunnel lifetime | 24 hours | Absolute tunnel lifetime limit |
| Block duration | 1 hour | Temporary IP block after abuse |
| Violations before block | 10 | SSH connection rate violations before IP block |
tunnl.gg/
├── cmd/tunnl/ # Application entry point
├── pkg/
│ ├── clientip/ # Visitor IP resolution behind trusted proxies
│ │ └── clientip.go
│ ├── config/ # Configuration and constants
│ │ └── config.go
│ ├── serve/ # Server startup, shared with hosted builds
│ │ └── serve.go
│ ├── server/ # Server implementation
│ │ ├── server.go # Server struct, tunnel registry
│ │ ├── accounts.go # Optional accounts for hosted deployments
│ │ ├── ssh.go # SSH connection handling
│ │ ├── http.go # HTTP/HTTPS handlers
│ │ ├── stats.go # Stats tracking and endpoint
│ │ ├── abuse.go # Abuse tracking and IP blocking
│ │ ├── connlimit.go # Concurrent connection limits
│ │ └── deadlines.go # Idle timeouts for proxied requests
│ ├── subdomain/ # Subdomain generation/validation
│ │ └── subdomain.go
│ └── tunnel/ # Tunnel, SSH channels, and rate limiter
│ ├── tunnel.go
│ ├── channel.go
│ ├── ratelimiter.go
│ └── requestlogger.go
├── .golangci.yml # Linter configuration
├── Dockerfile # Multi-stage build (scratch image)
├── docker-compose.yml # Production deployment
└── Makefile # Build commands
- Docker and Docker Compose
- A domain with DNS pointing to your server
- SSL certificates (see below)
A yourdomain.com → YOUR_SERVER_IP
A *.yourdomain.com → YOUR_SERVER_IP
# Install certbot
sudo apt install certbot
# Get wildcard certificate (requires DNS challenge)
sudo certbot certonly --manual --preferred-challenges dns \
-d yourdomain.com -d '*.yourdomain.com'
# Or use HTTP challenge for single domain first
sudo certbot certonly --standalone -d yourdomain.com# Clone the repository
git clone https://github.com/klipitkas/tunnl.gg.git
cd tunnl.gg
# Create data directories
mkdir -p data/certs data/hostkey
# Copy certificates
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
# Start the service
docker compose up -d
# View logs
docker compose logs -fYour server's SSH likely uses port 22. Move it so tunnl can use it:
sudo nano /etc/ssh/sshd_config
# Change: Port 22 → Port 2222
sudo ufw allow 2222/tcp
sudo systemctl restart sshdTest the new port before closing your session:
ssh -p 2222 user@your-server# Requires Go 1.26.8+
git clone https://github.com/klipitkas/tunnl.gg.git
cd tunnl.gg
# Build optimized binary (~6MB)
make build-small
# Or build for all platforms
make build-allRun the service as a dedicated unprivileged user. It only needs the
CAP_NET_BIND_SERVICE capability 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/tunnlLet's Encrypt keys are only readable by root, 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.shsudo nano /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.targetsudo systemctl daemon-reload
sudo systemctl enable --now tunnl| Environment Variable | Default | Description |
|---|---|---|
SSH_ADDR |
:22 |
SSH server listen address |
HTTP_ADDR |
:80 |
HTTP server listen address |
HTTPS_ADDR |
:443 |
HTTPS server listen address |
STATS_ADDR |
127.0.0.1:9090 |
Stats endpoint (localhost only) |
HOST_KEY_PATH |
host_key |
Path to SSH host key |
TLS_CERT |
/etc/letsencrypt/live/tunnl.gg/fullchain.pem |
TLS certificate path |
TLS_KEY |
/etc/letsencrypt/live/tunnl.gg/privkey.pem |
TLS private key path |
DOMAIN |
tunnl.gg |
Domain name for the service |
TRUSTED_PROXIES |
unset | Proxies whose headers identify visitors: CIDRs/IPs (X-Forwarded-For) and/or cloudflare (CF-Connecting-IP). See Behind a Proxy |
# Expose local port 8080
ssh -t -R 80:localhost:8080 proxy.tunnl.ggBy default each connection gets a new random URL. Connect as stable to get a URL
tied to your SSH key instead, which 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 a key withssh-keygen -t ed25519). - Reconnecting with the same key while an old connection is still up (for example after your laptop slept) replaces the old connection.
- The URL can't be worked out from your public key, but changes if the server's host key changes.
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. Vite, Django's ALLOWED_HOSTS, Rails and webpack-dev-server reject unknown hosts ("Blocked request"); this fixes it 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 allow= 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 to responses unless the app sets its own. cors=* allows any site, without cookies. Repeat cors= to add more. |
ssh ... proxy.tunnl.gg help lists them. Invalid options end the session with an
error. The options are visible to the server, and auth= is saved in your shell
history like any command. A server can restrict which options anonymous clients may
use (Server.SetFreeLimits); by default they may use all of them.
On a deployment with accounts (Server.SetAccounts), an account's key connects as
pro@ and gets the account's default subdomain for that key. To open another of the
account's subdomains, name it as the bind address; each connection is one tunnel, so
several commands run several subdomains at once:
ssh -t -R myapp:80:localhost:3000 pro@proxy.tunnl.gg
ssh -t -R api:80:localhost:8000 pro@proxy.tunnl.ggA 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.
ssh -t -R 80:192.168.1.100:3000 proxy.tunnl.ggssh -t -R 80:localhost:8080 -o ServerAliveInterval=60 proxy.tunnl.ggBrowser requests show a phishing warning (cookie-based, lasts 1 day). To skip programmatically:
curl -H "tunnl-skip-browser-warning: 1" https://happy-tiger-a1b2c3d4.tunnl.ggRate limits, WebSocket limits, and the X-Forwarded-For header sent to tunneled apps
all use the visitor's IP address. By default the server trusts no proxies and uses the
address of the TCP connection, which is right when it faces the internet directly
(including most on-prem setups).
When HTTPS traffic reaches the server through proxies, list them in TRUSTED_PROXIES
so the visitor's address is taken from their headers:
| 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
(cloudflare.com/ips, built in). 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, so only list proxies
you control, and firewall ports 80 and 443 so only those proxies can reach the server.
Query server statistics (localhost only):
# Basic stats
curl http://127.0.0.1:9090/
# Include active subdomains
curl "http://127.0.0.1:9090/?subdomains=true"Response:
{
"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"]
}| Command | Description |
|---|---|
make build |
Standard optimized build |
make build-small |
Maximum size optimization (~6MB) |
make build-tiny |
With UPX compression (if installed) |
make build-all |
Cross-compile for Linux/macOS |
make build-dev |
Fast build with debug symbols |
make dev |
Run a local server on unprivileged ports (see Local Development) |
make test |
Run tests |
make lint |
Run golangci-lint (v2) |
make vuln |
Check reachable code for known vulnerabilities |
make clean |
Remove build artifacts |
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 localhostThe session shows the tunnel URL, e.g. https://happy-tiger-a1b2c3d4.localhost.
Add the dev HTTPS port to reach it: curl -k https://happy-tiger-a1b2c3d4.localhost:8443
(*.localhost resolves to your machine in browsers and curl).
┌─────────────────────────────────────────────────────────────────┐
│ TUNNL SERVER │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌───────────┐ ┌───────────┐ │
│ │ SSH :22 │ │ HTTP :80 │ │HTTPS :443 │ │Stats :9090│ │
│ │ │ │ │ │ │ │ │ │
│ │ Accepts -R │ │ ACME + 301 │ │ TLS term │ │ Metrics │ │
│ │ connections │ │ redirect │ │ Rev proxy │ │ (local) │ │
│ └──────┬──────┘ └─────────────┘ └─────┬─────┘ └───────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ Tunnel Registry ││
│ │ map[subdomain]*Tunnel ││
│ └─────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────┘
│ │
▼ │
┌──────────┐ HTTPS request to │
│ SSH Conn │ ←─ happy-tiger-a1b2c3d4 ─────┘
│ Client │
└────┬─────┘
│
▼
┌──────────┐
│ App:8080 │
└──────────┘
- Client runs
ssh -t -R 80:localhost:8080 proxy.tunnl.gg - Server generates subdomain (e.g.,
happy-tiger-a1b2c3d4) and shows URL - Browser requests
https://happy-tiger-a1b2c3d4.tunnl.gg - Server looks up tunnel, proxies request via SSH to client
- Client forwards to
localhost:8080
You can run multiple instances on the same server using different ports:
# Instance 1 (production) - default ports
./tunnl
# Instance 2 (dev) - alternate ports
SSH_ADDR=:2223 HTTP_ADDR=:8080 HTTPS_ADDR=:8443 STATS_ADDR=127.0.0.1:9091 \
HOST_KEY_PATH=./host_key_dev ./tunnlConnect to dev instance: ssh -t -R 80:localhost:8080 proxy.tunnl.gg -p 2223
# Check service status
docker compose ps
# or
sudo systemctl status tunnl
# Check ports
sudo ss -tlnp | grep -E ':(22|80|443)'
# Check firewall
sudo ufw statusFirst-time clients must accept the host key:
ssh -t -R 80:localhost:8080 proxy.tunnl.gg
# Are you sure you want to continue connecting (yes/no)? yesThe -t flag is required:
# Wrong
ssh -R 80:localhost:8080 proxy.tunnl.gg
# Correct
ssh -t -R 80:localhost:8080 proxy.tunnl.gg# Check certificate files
ls -la data/certs/
# Renew certificates
sudo certbot renew
# Copy renewed certs and restart
sudo cp /etc/letsencrypt/live/yourdomain.com/*.pem data/certs/
docker compose restartMIT
