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-a1b2.tunnl.gg) - Automatic SSL via 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 tunnel | 10/s (burst 20) | Token bucket rate limiting |
| Request body size | 128 MB | Max upload size |
| Response body size | 128 MB | Max response size |
| Connections per minute | 10 | New SSH connections per IP |
| Inactivity timeout | 30 min | Tunnel closes after inactivity |
| Max tunnel lifetime | 24 hours | Absolute tunnel lifetime limit |
| Block duration | 1 hour | Temporary IP block after abuse |
| Violations before block | 10 | Rate limit violations before tunnel kill + IP block |
tunnl.gg/
├── cmd/tunnl/ # Application entry point
├── internal/
│ ├── config/ # Configuration and constants
│ │ └── config.go
│ ├── server/ # Server implementation
│ │ ├── server.go # Server struct, tunnel registry
│ │ ├── ssh.go # SSH connection handling
│ │ ├── http.go # HTTP/HTTPS handlers
│ │ ├── stats.go # Stats tracking and endpoint
│ │ └── abuse.go # Abuse tracking and IP blocking
│ ├── subdomain/ # Subdomain generation/validation
│ │ └── subdomain.go
│ └── tunnel/ # Tunnel and rate limiter
│ ├── tunnel.go
│ └── ratelimiter.go
├── 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
# Copy certificates
sudo cp /etc/letsencrypt/live/yourdomain.com/fullchain.pem data/certs/
sudo cp /etc/letsencrypt/live/yourdomain.com/privkey.pem data/certs/
sudo chown -R $USER:$USER data/certs
# 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.24+
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-allsudo nano /etc/systemd/system/tunnl.service[Unit]
Description=Tunnl.gg SSH Tunnel Service
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/tunnl
ExecStart=/opt/tunnl/tunnl
Restart=always
RestartSec=5
Environment=SSH_ADDR=:22
Environment=HTTP_ADDR=:80
Environment=HTTPS_ADDR=:443
Environment=STATS_ADDR=127.0.0.1:9090
Environment=HOST_KEY_PATH=/opt/tunnl/host_key
Environment=TLS_CERT=/etc/letsencrypt/live/yourdomain.com/fullchain.pem
Environment=TLS_KEY=/etc/letsencrypt/live/yourdomain.com/privkey.pem
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/opt/tunnl
[Install]
WantedBy=multi-user.targetsudo mkdir -p /opt/tunnl
sudo cp bin/tunnl /opt/tunnl/
sudo chmod +x /opt/tunnl/tunnl
sudo 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 |
# Expose local port 8080
ssh -t -R 80:localhost:8080 proxy.tunnl.ggssh -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-a1b2.tunnl.ggQuery 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,
"subdomains": ["happy-tiger-a1b2", "calm-eagle-c3d4", "swift-wolf-e5f6"]
}| 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 test |
Run tests |
make clean |
Remove build artifacts |
┌─────────────────────────────────────────────────────────────────┐
│ 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-a1b2 ─────┘
│ Client │
└────┬─────┘
│
▼
┌──────────┐
│ App:8080 │
└──────────┘
- Client runs
ssh -t -R 80:localhost:8080 proxy.tunnl.gg - Server generates subdomain (e.g.,
happy-tiger-a1b2) and shows URL - Browser requests
https://happy-tiger-a1b2.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