tunnl.gg is a free, open source networking & connectivity project written in Go and released under MIT. It has 681 GitHub stars, 53 forks and 1 open issues, and was last pushed 36 hours ago. On this registry it ranks #73 of 80 tracked projects in Networking & Connectivity, with 5 head-to-head comparisons available.

What is tunnl.gg?

tunnl.gg is a minimal, MIT-licensed SSH tunneling service that puts a local web server on the public internet with a single ssh -R command, built for developers who want a shareable HTTPS URL without installing a client or creating an account.

What it is

tunnl.gg is a reverse tunneling service built on SSH. This repository holds the open-source Go server behind the hosted service at tunnl.gg, released under the MIT licence, and the same server can be run by anyone. A client connects with the SSH binary already present on macOS, Linux and Windows 10+, and receives a public HTTPS URL on a subdomain such as https://happy-tiger-a1b2c3d4.tunnl.gg, with HTTPS and WebSockets handled on the way through.

The problem it addresses is the setup cost of sharing work in progress. Rather than installing a dedicated tunneling client and registering an account to obtain a token, a developer runs one command against proxy.tunnl.gg and gets a working public URL, so the thing it replaces is the client install and the account signup step.

Key capabilities

  • One-command exposure over SSH: ssh -t -R 80:localhost:8080 proxy.tunnl.gg prints the public URL and a QR code, then logs each request with its path, status, size, timing and visitor, with plain-words explanations when a request fails.
  • Stable URLs tied to an SSH key: connecting as stable@proxy.tunnl.gg returns the same URL on every reconnect with nothing to register, and each key holds one live tunnel, so a reconnect replaces the earlier session.
  • Host header rewriting through host=localhost:3000, which fixes "Blocked request" errors from Vite, Django's ALLOWED_HOSTS, Rails and webpack-dev-server while X-Forwarded-Host still carries the public host.
  • Access control in the command itself: auth=user:password puts HTTP basic authentication in front of visitors, allowing ten wrong passwords before one attempt every ten seconds, and allow=203.0.113.7,198.51.100.0/24 returns 403 to everyone outside the listed addresses and networks.
  • JSON output for scripts and AI agents, as an alternative to the interactive terminal view.
  • Verifiable host identity: the ED25519 key of proxy.tunnl.gg is published with the fingerprint SHA256:5uQ6VobmUpY4wyMmm5jnAwFtt3aX06WYDDM4ER+2WUw and as a DNSSEC-signed SSHFP record, so -o VerifyHostKeyDNS=yes can check it automatically.
  • Safe-by-default operation: rate limits, abuse protection, a phishing warning page for browsers, tunnels kept out of search engines, and a stats endpoint on self-hosted servers.

Who uses it and how

  • Individual developers demoing an app on port 8080 or a Vite dev server on 5173, using host= to satisfy framework host checks without editing configuration.
  • Anyone testing on a phone: the terminal view prints a QR code that opens the public URL directly on a mobile device.
  • Automation and AI agents reading the JSON output instead of a terminal, for scripted tunnel creation.
  • Operators running their own server, with Docker or systemd installs, a configuration section, deployment behind a proxy, a stats endpoint and troubleshooting all documented in the README.
  • People sharing a short-lived preview with a restricted audience, closing a tunnel with an allow= network list or an auth= password.

Getting started

The hosted service needs nothing beyond ssh -t -R 80:localhost:8080 proxy.tunnl.gg, substituting stable@ for the bare host when a persistent URL is wanted. Self-hosting follows the README's Docker or systemd install, then configuration and optional placement behind a proxy.

How it compares

The material provided names no comparable tunneling products, paid or otherwise, so on the evidence here tunnl.gg stands alone in this registry. The distinction it draws for itself is that it uses the SSH client already on the machine rather than a separate client install or an account-based signup.

When to use it — and when not to

Choose it when a public HTTPS URL for a local port is needed quickly and SSH access is acceptable; a self-hoster takes on operating the Go server itself, through Docker or systemd, including its configuration, any reverse proxy in front of it and the stats endpoint. It fits poorly if several simultaneous stable tunnels are needed from one machine, since each SSH key holds one live tunnel and two machines therefore need two keys, or if a large, heavily staffed project is expected: the registry shows 681 stars, 53 forks and one open issue. A stable URL is also tied to the server's host key and changes if that key changes, and the first connection requires confirming the ED25519 host key unless a DNSSEC-validating resolver is available.

project readme (upstream, from github) — read inline

tunnl.gg: your localhost, public in one command

CI MIT license

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.

Website · Docs · Guides

Contents

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 with ssh-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│
   └────────────────────────────────────────────────────────┘
  1. The client runs ssh -t -R 80:localhost:8080 proxy.yourdomain.com.
  2. The server picks a subdomain (random, or derived from the key for stable@) and prints the URL.
  3. A browser requests https://happy-tiger-a1b2c3d4.yourdomain.com.
  4. The server finds the tunnel and sends the request through the SSH connection.
  5. The SSH client passes it to localhost:8080 and 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_IP
    
  • A wildcard TLS certificate for yourdomain.com and *.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'
    

    --manual certificates don't renew on their own; a certbot DNS plugin for your DNS provider (such as python3-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.

License

MIT

Frequently asked questions

Is tunnl.gg free to use?

tunnl.gg is open source under the MIT licence. There is no licence fee and no seat count — you can self-host it or, where the project offers one, pay a vendor for a managed version instead.

What does tunnl.gg do?

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

What is tunnl.gg written in?

tunnl.gg is primarily written in Go. Its source is publicly available at https://github.com/klipitkas/tunnl.gg, and it has 681 GitHub stars.