Skip to content

Repository files navigation

tunnl.gg: your localhost, public in one command

Tunnl.gg

A minimal SSH tunneling service. Expose your local apps to the internet with a single command.

ssh -t -R 80:localhost:8080 proxy.tunnl.gg

Note: The -t flag is required to allocate a TTY, which allows the server to display your tunnel URL.

Features

  • 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 ssh command: 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

Limits & Protection

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

Project Structure

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

Quick Start with Docker

Prerequisites

  • Docker and Docker Compose
  • A domain with DNS pointing to your server
  • SSL certificates (see below)

1. DNS Configuration

A    yourdomain.com      → YOUR_SERVER_IP
A    *.yourdomain.com    → YOUR_SERVER_IP

2. Obtain SSL Certificates

# 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

3. Deploy

# 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 -f

4. Move Server SSH (Important!)

Your 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 sshd

Test the new port before closing your session:

ssh -p 2222 user@your-server

Manual Installation

Build from Source

# 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-all

Systemd Service

Run 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/tunnl

Let'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.sh
sudo 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.target
sudo systemctl daemon-reload
sudo systemctl enable --now tunnl

Configuration

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

Usage

Basic

# Expose local port 8080
ssh -t -R 80:localhost:8080 proxy.tunnl.gg

Keep the Same URL

By 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 with ssh-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.

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. 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.

Choose a Subdomain (accounts)

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.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.

Expose a Different Host

ssh -t -R 80:192.168.1.100:3000 proxy.tunnl.gg

Keep Connection Alive

ssh -t -R 80:localhost:8080 -o ServerAliveInterval=60 proxy.tunnl.gg

Bypass Interstitial Warning

Browser 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.gg

Behind a Proxy

Rate 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.

Stats Endpoint

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"]
}

Makefile Commands

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

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 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).

How It Works

┌─────────────────────────────────────────────────────────────────┐
│                        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 │
   └──────────┘
  1. Client runs ssh -t -R 80:localhost:8080 proxy.tunnl.gg
  2. Server generates subdomain (e.g., happy-tiger-a1b2c3d4) and shows URL
  3. Browser requests https://happy-tiger-a1b2c3d4.tunnl.gg
  4. Server looks up tunnel, proxies request via SSH to client
  5. Client forwards to localhost:8080

Running Multiple Instances

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 ./tunnl

Connect to dev instance: ssh -t -R 80:localhost:8080 proxy.tunnl.gg -p 2223

Troubleshooting

Connection Refused

# 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 status

Host Key Verification Failed

First-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)? yes

No Output / Connection Hangs

The -t flag is required:

# Wrong
ssh -R 80:localhost:8080 proxy.tunnl.gg

# Correct
ssh -t -R 80:localhost:8080 proxy.tunnl.gg

Certificate Issues

# 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 restart

License

MIT

About

A minimal SSH tunneling service. Expose your local apps to the internet with a single command.

Topics

Resources

Stars

658 stars

Watchers

8 watching

Forks

Releases

Packages

Used by

Contributors

Languages