tunnl.gg: exposing a localhost to the internet over plain SSH
A minimal SSH tunneling service. Expose your local apps to the internet with a single command.
At a glance
- What is it?
- tunnl.gg is a Go SSH tunneling server that gives a local web server a public HTTPS URL from a single ssh -R command, with no client to install and no account, and the repository is the open source server behind the hosted service. It suits developers who need to hand a colleague or a webhook service a reachable URL for a few minutes, and it is not a production ingress for traffic you care about keeping up.
- Who is it for?
- tunnl.gg is a good fit for the short, ad hoc case: a demo to show a colleague, a webhook you need to receive, or a local server whose host header breaks behind a proxy. It is the wrong fit for anything that needs a stable hostname, a written security contract, or an SLA, and the shared hosted domain in particular should not be treated as a durable address.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 2 days ago.
- What is it written in?
- Mainly Go, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.
Editorial analysis
What tunnl.gg does: SSH remote forwarding with nothing to install
The idea is one sentence long: expose a local web server to the internet with one SSH command, and get a public HTTPS URL. The README's headline command is the whole mechanism:
ssh -t -R 80:localhost:8080 proxy.tunnl.ggThat is a standard SSH remote forward, where port 80 on the remote host is mapped back to port 8080 on your machine. What the service adds around it is a web server in front of those forwarded ports: it hands out a subdomain, terminates TLS for it, routes requests into your tunnel, and prints what is happening.
The feature list is where the design decisions show. There is no account and no auth token, because the only client is the SSH binary you already have on macOS, Linux and Windows 10 or later. The result is a URL on a subdomain, with HTTPS and WebSockets supported, in the shape https://happy-tiger-a1b2c3d4.tunnl.gg. The session shows a live view in the terminal: the URL, a QR code for phones, and a coloured log of every request with path, status, size, timing and visitor, plus plain-words explanations when a request fails.
The protection story is described as safe by default, with rate limits, abuse protection, a phishing warning page for browsers, and tunnels kept out of search engines. That last point matters more than it sounds: a tunnel that is indexed is a tunnel that gets scraped, and the phishing warning page exists precisely because these URLs are reachable by anyone who guesses or scrapes them.
The repository is the MIT-licensed server behind the hosted service, and the README says you can use the hosted service for free or run your own. That makes the project worth reading as software as well as using as a tool.
The quick start session and what the terminal gives you
The README's quick start block is annotated with a comment and a single command:
# Expose the app on port 8080
ssh -t -R 80:localhost:8080 proxy.tunnl.ggTwo details are explained and both matter. The first is that tunnl prints the public URL and a QR code and then logs requests as they arrive, so the session itself is the monitoring tool rather than something you have to attach to. The second is the role of the -t flag, which gives the session a terminal. The server needs that terminal to draw the URL, and the README points at the JSON output mode for scripts that cannot have one.
Stopping the tunnel is Ctrl+C. There is no daemon to install, no background process to supervise, and no configuration file involved on the client side. For a developer who wants a colleague to look at a branch deployment, that is the whole workflow: start the tunnel, send the URL, keep the terminal open.
The single port mapping is also worth reading literally. -R 80:localhost:8080 asks the remote side to listen on port 80 and forward to your local 8080. Because the server is the one terminating TLS, the public side is HTTPS even though the tunnel itself carries plain HTTP to your app, which is what makes it work with a development server that only speaks HTTP.
Stable subdomains: connecting as stable@ with your own SSH key
Each connection gets a new random URL, which is fine for a one-off but useless for anything you want to keep. The README's answer is to connect as stable instead, and the URL is then tied to your SSH key and stays the same every time you reconnect:
ssh -t -R 80:localhost:8080 [email protected]The rules around that feature are specific enough to be worth reading line by line. Any SSH key works and there is nothing to register, but without one the stable@ form is refused, so you need a key created with ssh-keygen -t ed25519. Each key has exactly one live tunnel, which means reconnecting with the same key, after your laptop slept for instance, replaces the old connection, and two machines need two keys. The URL cannot be worked out from your public key, but it does change if the server's host key changes.
That last pair of sentences is the security model in miniature. The mapping is server-side and opaque, so somebody who has your public key still cannot guess the address, and the address is not a secret you chose. It is also not a credential you can hand to a third party as a permission, because possession of the URL grants access to whatever you expose, and nothing in the design separates read access from write access.
If you need a stable address that survives a key change, or a hostname under your own domain, that is a different product, and self-hosting the server is the route the README points to.
Options: host rewrite, basic auth, IP allowlists and CORS
Options are passed after the host as name=value pairs, which keeps the command line readable and avoids a client config file:
ssh -t -R 80:localhost:5173 proxy.tunnl.gg host=localhost auth=me:secret allow=203.0.113.7The host option sends a different Host header to your app instead of the public one, and the README lists what that fixes: a Blocked request error from Vite, Django's ALLOWED_HOSTS, Rails and webpack-dev-server, none of which need their config changed. X-Forwarded-Host still carries the public host, so applications that want to know the public address can still read it.
The auth option puts HTTP basic authentication in front of the tunnel, and the password is not passed on to your app. The README describes the brute-force handling: each visitor gets 10 wrong passwords, then one attempt every 10 seconds. The allow option restricts access to listed IPs or networks, and everyone else gets a 403. The cors option lets pages on the given sites call the tunnel from a browser with cookies, and tunnl answers CORS preflights even on a password-protected tunnel, adding the headers unless your app sets its own; cors=* allows any site, without cookies.
Two caveats come with the options themselves. Options are visible to the server, and auth= ends up in your shell history like any command, so a shared machine or a recorded terminal session is a leak path. A server can restrict which options anonymous clients may use through Server.SetFreeLimits, with all options allowed by default, and the output option only changes what the client sees, so every client may use it. An invalid option ends the session with an error rather than being ignored, which is the safer default.
JSON output: reading the URL from a script instead of a terminal
For automation, the session can print one JSON object per line instead of the banner, the QR code and the request table. The README says this lets scripts, CI jobs and AI coding agents, naming Claude Code, Cursor and Codex, read the URL and watch requests without parsing terminal output, and that it works on every tunnel.
The background pattern runs the session with -n -T for no input and no terminal, and reads the URL from the first line:
ssh -n -T -o ServerAliveInterval=30 -R 80:localhost:3000 [email protected] output=json > tunnel.jsonl &
until grep -q '"event":"tunnel"' tunnel.jsonl 2>/dev/null; do sleep 1; done
head -1 tunnel.jsonl | jq -r .urlThe ServerAliveInterval keeps the SSH connection from dropping during long jobs, and the polling loop handles the fact that the URL is not known until the server has answered. The event stream itself has three shapes:
{"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."}The first line describes the tunnel, then comes one line per request, and notices cover things such as the warning before a time limit or the summary when the tunnel closes. The first line carries idle_timeout_seconds and max_lifetime_seconds, which is where the server tells you when it will end the session, and the request line carries method, path, status, bytes, duration_ms and visitor. The README's field table also notes that a warning field is set when the server ignored part of the request, for instance a subdomain name submitted without an account.
Running your own instance: Docker, compose and a hardened container
The repository is the server, and it ships a Dockerfile and a docker-compose.yml. The compose file defines a production instance and a development one, the latter behind the dev profile so it does not start by default. The production service builds from the repository, maps 22 for SSH tunnel connections, 80 for HTTP that redirects to HTTPS, and 443 for HTTPS tunnel traffic, and mounts a host key directory generated on first start plus a certificates directory mounted read-only.
The environment block is where you point the server at your own domain and certificates:
environment:
- DOMAIN=tunnl.gg # your domain: tunnels are served on its subdomains
- SSH_ADDR=:22
- HTTP_ADDR=:80
- HTTPS_ADDR=:443
- HOST_KEY_PATH=/hostkey/host_key
- TLS_CERT=/certs/fullchain.pem
- TLS_KEY=/certs/privkey.pemThe hardening in that file is worth noting because it is not the default in most hobby compose files. The container drops all capabilities and adds back only NET_BIND_SERVICE, which is what lets it bind ports 22, 80 and 443 as a non-privileged process, sets no-new-privileges, and marks the filesystem read-only. A commented TRUSTED_PROXIES variable is there for deployments behind something like Cloudflare.
The Dockerfile takes the same idea further. The build stage is a golang image pinned by digest, with the comment that the tag and digest are updated together, and the build uses CGO_ENABLED=0, -trimpath and linker flags that strip symbols. The runtime stage is scratch, containing only the CA certificate bundle and the binary, with the service running as UID 65534, the nobody user, and the entrypoint set to the tunnl binary.
The Makefile covers the development loop with targets for a normal build, a size-optimised build, a tiny build that runs upx when it is installed, a cross-platform build covering Linux amd64 and arm64 plus Darwin targets, and the usual test, lint, vuln, tidy, size-check and install targets. The README's table of contents also lists a systemd installation path, a configuration section, a section on running behind a proxy, a stats endpoint and a troubleshooting section, so the self-hosting route is documented beyond the Docker path shown here.
Where tunnl.gg is the wrong tool
The first wrong case is anything that needs a guaranteed, durable address. Every anonymous connection gets a fresh random subdomain, stable@ ties the URL to an SSH key and to the server's host key, and the server closes tunnels on idle and lifetime timers, with the example stream showing an idle timeout of 7200 seconds and a maximum lifetime of 86400 seconds. For a demo or a webhook callback that is fine. For a service other people bookmark, it is not.
The second wrong case is exposing something sensitive on a shared domain. There is no account, so the server has no notion of who you are, and access control is either a password, an IP allowlist, or nothing. The URL is a bearer token in practice, and the README is candid that a phishing warning page exists because browsers can be pointed at these addresses. Anything with real data behind it should not sit on a public subdomain of somebody else's service.
The third wrong case is a heavy or long-running workload. The tool is a debugging and demo aid: the tunnel lives as long as your SSH session, the request log is a terminal view, and the protections listed are rate limits and abuse handling rather than a production ingress policy. If you need load balancing, request logging to a real sink, or an uptime guarantee, a conventional reverse proxy is the right tool.
The fourth is a network that blocks SSH or outbound 443 to a third party, or an environment where you cannot reach the public internet at all. A tunnel is by definition a way out, and the self-hosted answer is the same constraint with your own server in the path.
The alternative approach worth naming is plain SSH remote forwarding to a host you already own, which is the same -R mechanism without the service in front of it. You would then terminate TLS yourself, from a certificate you manage, on an address you control, with no random subdomain and no shared infrastructure, at the cost of maintaining a server, certificates and the reverse proxy configuration by hand. That is exactly the trade the repository's Docker and systemd paths exist to soften, and for many teams running their own instance is the better answer than depending on the hosted one.
Editorial conclusion
tunnl.gg is a good fit for the short, ad hoc case: a demo to show a colleague, a webhook you need to receive, or a local server whose host header breaks behind a proxy. It is the wrong fit for anything that needs a stable hostname, a written security contract, or an SLA, and the shared hosted domain in particular should not be treated as a durable address. Verify first that the service you are exposing speaks HTTPS and WebSockets rather than plain HTTP, that nothing sensitive sits behind the URL, and, if you self-host, that the DOMAIN, TLS_CERT and TLS_KEY settings in docker-compose.yml point at infrastructure you control.
Frequently asked questions
Do I need an account or a client to use tunnl.gg?
No. The README says it uses the SSH client already present on macOS, Linux and Windows 10 or later, with no account and no auth token. The tunnel is an ordinary ssh -R command to proxy.tunnl.gg, and the repository contains the MIT-licensed server if you would rather run it yourself.
How do I keep the same public URL across reconnects?
Connect as stable@ instead of the plain host, so the URL is tied to your SSH key. Any SSH key works and there is nothing to register, but one is required, and stable@ is refused without one. Each key has one live tunnel, so reconnecting with the same key replaces the old connection and two machines need two keys.
Can I add a password or restrict who can reach my tunnel?
Yes. The auth option puts HTTP basic authentication in front of the tunnel, with the password not passed on to your app, and each visitor gets 10 wrong passwords before one attempt every 10 seconds. The allow option restricts access to given IPs or networks and returns 403 to everyone else.
How do I use tunnl.gg from a script or CI job?
Pass output=json so the session prints one JSON object per line instead of the banner and request table, and run it in the background with -n -T. The README's example polls the output file for the tunnel event and reads the URL from the first line with jq.
What does it take to run my own tunnl.gg server?
The repository ships a Dockerfile and a docker-compose.yml. You set DOMAIN to the domain tunnels are served on, point HOST_KEY_PATH, TLS_CERT and TLS_KEY at your key and certificate files, and map ports 22, 80 and 443. The container drops all capabilities except NET_BIND_SERVICE, runs with no-new-privileges and a read-only filesystem, and the image is built FROM scratch as a non-root user.
Which Go version does tunnl.gg require?
The go.mod file declares go 1.26.0 with toolchain go1.26.9. The direct dependencies are github.com/mikesmitty/edkey, golang.org/x/crypto and rsc.io/qr, and the Dockerfile builds from a golang 1.26.9 alpine image pinned by digest.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/klipitkas-tunnl-gg)Was this useful?