tunnl.gg - Expose local applications to the internet with a single SSH command
You're working on an app at localhost:3000 and suddenly need to show it to a client, test Stripe webhooks, or check how it works on a colleague's phone. What do you do? Deploy to staging? Set up ngrok with tokens and accounts? Or maybe just type a single SSH command and have a public URL in 2 seconds?
tunnl.gg is a minimalist SSH-based tunneling tool that does exactly one thing - and does it well. It exposes your local application to the internet without any installation, without creating an account, without configuring tokens. The only thing you need is SSH, which you already have on your machine.
What is tunnl.gg?
tunnl.gg is a free, open-source SSH tunneling service written in Go. It lets you expose any application running on localhost (or your local network) at a public HTTPS address with an automatic SSL certificate. The entire process comes down to a single command:
ssh -t -R 80:localhost:8080 proxy.tunnl.ggAfter running this command, you get a random subdomain, e.g., https://crisp-cedar-c8b5a1d2.tunnl.gg, where your local application is accessible from anywhere in the world.
The project was created by klipitkas and is available on GitHub under the MIT license.
Why tunnl.gg?
Zero installation, zero configuration
This is tunnl.gg's biggest advantage. You don't need to:
- Install any client or CLI
- Create an account
- Generate API tokens
- Configure anything
SSH is preinstalled on virtually every operating system - macOS, Linux, and even Windows 10+ has a built-in SSH client.
Comparison with alternatives
| Feature | tunnl.gg | ngrok | localtunnel | Cloudflare Tunnel |
|---|---|---|---|---|
| Installation | No (SSH) | Yes (CLI) | Yes (npm) | Yes (CLI) |
| Account | No | Yes | No | Yes |
| Free plan | Yes (100%) | Limited | Yes | Yes |
| HTTPS | Automatic | Automatic | Automatic | Automatic |
| Custom subdomain | No | Paid | Optional | Yes |
| WebSocket | Yes | Yes | Yes | Yes |
| Open source | Yes (MIT) | No | Yes | Client (Apache 2.0) |
| Self-hosting | Yes | No | Yes | No |
When to choose tunnl.gg?
tunnl.gg works perfectly when you:
- Need to quickly show something to a client or colleague
- Are testing webhooks (Stripe, GitHub, Slack)
- Are debugging the mobile version of your app on a physical device
- Are working on OAuth integration and need a public callback URL
- Don't want to install yet another tool on your system
How to use tunnl.gg?
Basic usage
Exposing a local server on port 8080:
ssh -t -R 80:localhost:8080 proxy.tunnl.ggThe -t flag is required - it allocates a pseudo-terminal (TTY), allowing the server to display your tunnel URL. After connecting, you'll see:
Tunnel established!
https://crisp-cedar-c8b5a1d2.tunnl.ggExposing different ports
If your app runs on port 3000 (e.g., Next.js, React dev server):
ssh -t -R 80:localhost:3000 proxy.tunnl.ggApp on port 5173 (Vite):
ssh -t -R 80:localhost:5173 proxy.tunnl.ggExposing a remote host
You can also expose an application running on another machine in your local network:
ssh -t -R 80:192.168.1.100:3000 proxy.tunnl.ggStable connection
For longer sessions, it's worth adding keep-alive so the SSH connection doesn't get interrupted:
ssh -t -R 80:localhost:8080 -o ServerAliveInterval=60 proxy.tunnl.ggBypassing the warning page
tunnl.gg displays a warning page (interstitial) on the first browser visit - this is phishing protection. If you're using an API or curl, you can skip it:
curl -H "tunnl-skip-browser-warning: 1" https://subdomain.tunnl.ggArchitecture and how it works
tunnl.gg consists of four main components:
1. SSH server (port 22)
Accepts SSH connections with the -R flag (remote port forwarding). It doesn't require authentication - this is an intentional design decision for a free service. For each connection:
- Generates a memorable subdomain in
adjective-noun-hexformat (e.g.,happy-tiger-a1b2c3d4) - Creates an internal TCP listener
- Registers the tunnel in the central registry
- Sends the URL to the client via the SSH channel
2. HTTP server (port 80)
Redirects all traffic to HTTPS with a 301. It issues no certificates itself: you supply them in advance, with Certbot for instance, since the code carries no built in ACME support.
3. HTTPS server (port 443)
Terminates TLS and proxies requests back through SSH tunnels to users' local applications.
4. Stats endpoint (port 9090)
Accessible only from localhost, provides metrics: active tunnels, unique IPs, total requests, blocked addresses.
Request flow
Browser → HTTPS (tunnl.gg) → TLS termination → SSH tunnel → localhost:port- Browser sends a request to
https://happy-tiger-a1b2c3d4.tunnl.gg - HTTPS server terminates TLS
- Based on the subdomain, it finds the corresponding SSH tunnel
- Proxies the request through the SSH connection
- The request reaches your local application
- The response travels back the same way
Limits and restrictions
tunnl.gg has sensible limits to protect against abuse:
| Limit | Value | Description |
|---|---|---|
| Tunnels per IP | 3 | Maximum 3 simultaneous tunnels |
| Total tunnels | 1000 | Global server limit |
| Requests | 10/s (burst 20) | Rate limiting per tunnel |
| Request body | 128 MB | Maximum upload size |
| Response body | 128 MB | Maximum download size |
| WebSocket | 1 GB/direction | Data limit per connection |
| Tunnel lifetime | 24h | Maximum duration |
| Inactivity timeout | 2h | Auto-close on no traffic |
| Connections/min per IP | 10 | Flood protection |
| IP block | 1h | Abuse penalty |
Technical limitations
- Only supports HTTP/HTTPS traffic (no TCP/UDP)
- No custom subdomains - always random
- TLS is terminated server-side (server sees unencrypted traffic)
- Single-server architecture (no scaling)
- No tunnel authentication
Self-hosting
tunnl.gg is fully open-source and can be deployed on your own server. This is a great option if you:
- Need control over your data
- Want your own domain
- Need higher limits
Requirements
- Docker and Docker Compose
- A domain with DNS A records (root and wildcard)
- SSL certificate (Let's Encrypt)
DNS configuration
Add two A records pointing to your server's IP:
tunnl.yourdomain.com → SERVER_IP
*.tunnl.yourdomain.com → SERVER_IPSSL certificate
Generate a wildcard certificate with Certbot:
certbot certonly --manual --preferred-challenges dns \
-d "tunnl.yourdomain.com" \
-d "*.tunnl.yourdomain.com"Docker Compose
No prebuilt image is published in any registry, so you build it from the cloned repository:
services:
tunnl:
build: .
ports:
- "22:22"
- "80:80"
- "443:443"
environment:
- DOMAIN=tunnl.yourdomain.com
- TLS_CERT=/certs/fullchain.pem
- TLS_KEY=/certs/privkey.pem
volumes:
- ./data/certs:/certs:ro
- ./data/host_key:/host_key
restart: unless-stoppedEnvironment variables
| Variable | Default | Description |
|---|---|---|
| SSH_ADDR | :22 | SSH server address |
| HTTP_ADDR | :80 | HTTP server address |
| HTTPS_ADDR | :443 | HTTPS server address |
| STATS_ADDR | 127.0.0.1:9090 | Metrics endpoint |
| HOST_KEY_PATH | host_key | SSH host key path |
| 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 | Service domain |
Building from source
The project requires Go 1.24+:
git clone https://github.com/klipitkas/tunnl.gg.git
cd tunnl.gg
make buildAvailable targets:
make build # Optimized version
make build-small # Maximum size optimization (~6 MB)
make build-tiny # The same plus upx packing when upx is installed
make build-dev # Fast build with debug symbols
make build-all # Linux and macOS, two architectures each (no Windows)Security
Subdomain generation
tunnl.gg generates subdomains in the adjective-noun-hex8 format, yielding ~4.4 trillion possible combinations (32 adjectives x 32 nouns x 4 billion hex values). Enumeration is impractical.
Phishing protection
On the first browser visit, a warning page (interstitial) is displayed, informing the user that the content comes from a tunnel. After accepting, a cookie valid for 24 hours is set.
Security headers
Every response includes headers:
X-Content-Type-Options: nosniffX-Frame-Options: DENYX-XSS-Protection: 1; mode=blockReferrer-Policy: strict-origin-when-cross-origin
What to watch out for
- No SSH authentication means anyone can create a tunnel
- TLS terminated server-side - the operator can theoretically see the traffic
- Don't use it for transmitting sensitive data through the public instance
- Self-hosting solves these concerns
Practical use cases
Testing webhooks
ssh -t -R 80:localhost:3000 proxy.tunnl.ggThen in the Stripe dashboard, provide the URL: https://your-subdomain.tunnl.gg/api/webhooks/stripe
Client demo
Quick demo without deploying:
ssh -t -R 80:localhost:3000 -o ServerAliveInterval=60 proxy.tunnl.ggSend the URL to your client - it works on any device with a browser.
Testing on mobile devices
Instead of finding the IP on your local network:
ssh -t -R 80:localhost:5173 proxy.tunnl.ggOpen the URL on your phone - HTTPS works out of the box.
For a phone on the same Wi-Fi this is more than the situation needs, since the app then travels across the public internet only to come back to a device standing next to you. Portless settles the same problem without leaving the local network, putting a named address in place of hunting for the machine's IP. The tunnel only wins once the phone sits on a different network or in somebody else's hands.
Debugging OAuth integrations
Many OAuth providers require a public callback URL:
ssh -t -R 80:localhost:3000 proxy.tunnl.ggSet the callback URL in the provider's config to https://your-subdomain.tunnl.gg/auth/callback.
What a tunnel will not replace
The boundary deserves naming, since tunnels get used for things they do not suit, and it always ends the same way.
A tunnel is not a staging environment. It lives while your laptop is on, connected, and holding an open session, and at most twenty four hours. A client who received the link on Friday afternoon and opened it on Monday sees an error. For something meant to stand for a week you need a deployment preview on Vercel or Railway, where each branch gets its own address and depends on nothing of yours.
Nor is a tunnel a way to expose a service permanently. No SSH authentication means anyone can create a tunnel on the public instance, and a random subdomain is the only barrier in front of whatever you exposed. That is protection by an unguessable address rather than access control. If an admin panel with no login sits behind the tunnel, you have published it.
The third boundary concerns encryption. Traffic is encrypted between the browser and the service, and between the service and your machine, while on the intermediate server it is decrypted. The operator of a public instance can technically see its contents. For a demo that is irrelevant; for a customer's personal data it is not, and then the right answer is your own instance or a Cloudflare tunnel with authentication.
The practical rule reads like this: tunnel what lasts hours and what you could show a stranger without consequence. Everything beyond that deserves a real deployment.
A few things that save time
The Host header arrives from the tunnel domain rather than from your localhost. Some frameworks reject such requests as disallowed, so if you see a header error instead of your application, add the domain to the allowed hosts list in the dev server configuration.
Hot module reloading travels through the same tunnel, but the client tries connecting to an address it knows from the build configuration. When a phone preview loads once and stops reacting to changes, that is usually the cause rather than a tunnel fault.
Absolute addresses in code break immediately. Anything with http://localhost:3000 hard coded points, on the far side of a tunnel, at the recipient's own machine. Relative paths solve it with no configuration.
When testing webhooks, remember that reconnecting gives you a new subdomain and the address in the provider's dashboard goes stale. For longer work on an integration that argues for your own instance with a fixed domain.
It also pays to check how your application behaves over a slower connection. Traffic passes through an intermediate server, so latency runs noticeably higher than hitting localhost, and that is often the only chance to see your own interface under conditions close to what a user on a phone in the field gets.
FAQ
Is tunnl.gg free?
Yes, 100%. There are no paid plans. The project is open-source under the MIT license.
Do I need an account?
No. There's no registration, login, tokens, or API keys.
Can I choose my own subdomain?
No, subdomains are randomly generated. If you need custom subdomains, consider ngrok or self-hosting with code modifications.
How long does a tunnel last?
Maximum 24 hours. After 2 hours of inactivity, the tunnel is automatically closed.
Does it support WebSocket?
Yes, with a 1 GB data limit in each direction and a 2-hour inactivity timeout.
Can I host it on my own server?
Yes, tunnl.gg is fully open-source. Self-hosting instructions can be found in the GitHub repository.
How does it differ from ngrok?
ngrok is a commercial tool with a rich feature set (dashboard, replay, custom domains). tunnl.gg is a minimalist, free alternative - zero installation, zero accounts, zero configuration.
When to reach for something else
When you need a fixed subdomain, a dashboard showing requests, and the ability to replay them, ngrok offers more, and during intensive integration work that difference earns its keep. For a service meant to stand longer and carry access control, a tunnel from a content delivery vendor is the right pick. And for something that should simply run without your computer, no tunnel is the answer, a deployment is.
Source code and self hosting instructions sit in the project repository, and the architecture description in a separate document.