Expose local HTTP services to the internet over native OpenSSH (ssh -p 2222 -R 80:localhost:3000 tunl.online). No client installation, background daemon, or account required for ephemeral tunnels.
tunl is an open-source reverse tunnel platform consisting of an OpenSSH-compatible tunnel daemon (Go), a REST control plane (Node.js/Express/PostgreSQL), and a web dashboard (Next.js 16).
ssh -p 2222 -R 80:localhost:3000 tunl.online >_ tunl █ • ● Online
──────────────────────────────────────────────────────────
Account Anonymous Device
Forwarding http://localhost:3000
Public URL https://frosty-pine.tunl.online
Inspector https://tunl.online/inspect/frosty-pine
──────────────────────────────────────────────────────────
Press Ctrl+C or Ctrl+D to stop the tunnel
Port 2222:
tunllistens on port2222to run without root privileges and avoid conflicts with the host system's primary SSH daemon (port 22). Always pass-p 2222when connecting.
- Zero Client Dependencies: Uses standard OpenSSH (
ssh -R). Works out of the box on macOS, Linux, and Windows. - Real-Time Request Inspector: Live HTTP telemetry streamed over Server-Sent Events (SSE) at
/inspect/[subdomain]with one-click cURL export. - Persistent & Custom Subdomains: Reserve fixed subdomains linked to your SSH public key (
app@tunl.online). - Custom Domains: Bring your own domain (
app.example.com) — CNAME it at tunl, prove ownership with a TXT record, and route it at a live tunnel. - Interactive Subdomain Selection: TTY menu (
ssh -p 2222 -t ...) to pick from assigned subdomains or generate a random one. - Tunnel Password Protection: Lock public endpoints with password protection via dashboard settings.
- CIDR IP Allowlisting: Restrict tunnel ingress by IPv4/IPv6 CIDR ranges (
192.168.1.0/24). - WebSocket & SSE Pass-Through: Full HTTP/1.1 Upgrade handling for WebSockets, SSE streams, and Vite/Next.js HMR.
- Bandwidth Analytics & Auditing: Live session tracking, transfer volume metrics, and key-level session revocation.
- Self-Hostable: Monorepo architecture with clean separation between data plane (Go) and control plane (TypeScript).
Forward any local port to a public HTTPS URL:
ssh -p 2222 -R 80:localhost:3000 tunl.onlineForward a custom local port (e.g. 5173, 8080):
ssh -p 2222 -R 80:localhost:5173 tunl.onlineIf you have claimed a subdomain on tunl.online, specify it as the SSH user:
ssh -p 2222 -R 80:localhost:3000 myapp@tunl.onlineIf your account has multiple reserved subdomains, allocate a pseudo-terminal with -t to choose dynamically:
ssh -p 2222 -t -R 80:localhost:3000 tunl.onlineTo avoid typing -p 2222 and host details every time, add this to ~/.ssh/config:
Host tunl
HostName tunl.online
Port 2222
ServerAliveInterval 15
ServerAliveCountMax 3Then run:
# Ephemeral
ssh -R 80:localhost:3000 tunl
# Reserved subdomain
ssh -R 80:localhost:3000 myapp@tunlServe a reserved tunnel on your own domain (app.example.com):
-
Reserve a subdomain (e.g.
myapp) so the tunnel has a stable identity. -
In the dashboard, open Custom domains, enter your domain, and pick the target tunnel.
-
Create two DNS records at your registrar (in your domain's zone, e.g.
example.com):Type Name Value CNAMEappcname.tunl.onlineTXT_tunl-challenge.appyour one-time token -
Click Verify DNS. A matching
CNAMEorTXTrecord proves ownership; only then does the edge route the domain. -
For TLS, proxy the
CNAMEthrough Cloudflare (orange cloud) or Cloudflare for SaaS — Cloudflare terminates HTTPS and forwards to the tunl origin, which resolves theHostheader to your tunnel.
Custom domains reuse the tunnel's password protection, CIDR allowlist, request inspector, and analytics. Plan limits are controlled by plans.max_custom_domains (set 0 to disable).
+----------------------+
| HTTP / SSH Clients |
+----------------------+
|
v
+-------------------------------------------------------------------+
| tunnel-server (Go) |
| - SSH Server (:2222) - HTTP Proxy (:8080 / :443) |
| - Subdomain Router - CIDR & Password Enforcement |
| - WebSocket & SSE Upgrades - Request Telemetry Capture |
+-------------------------------------------------------------------+
| |
Key Validation & Config Session Activity
| |
v v
+-------------------------------------------------------------------+
| control-plane (Express) |
| - REST API & Auth - AES-256-GCM TOTP Secrets |
| - PostgreSQL (Drizzle) - Redis Session & Event Bus |
+-------------------------------------------------------------------+
^
API / Session State
|
+-------------------------------------------------------------------+
| dashboard (Next.js 16) |
| - Subdomain Reservations - Live Traffic Inspector (SSE) |
| - Profile & Security - Bandwidth Analytics |
+-------------------------------------------------------------------+
| Service | Port | Description |
|---|---|---|
tunnel-server (SSH) |
2222 |
Inbound SSH reverse tunnels |
tunnel-server (HTTP) |
8080 |
Inbound HTTP traffic routed to active tunnels |
tunnel-server (Health) |
9090 |
Daemon health check endpoint (/healthz) |
control-plane |
3001 |
Core REST API, authentication, and session state |
dashboard |
3000 |
Web UI, key management, and traffic inspector |
- Node.js
20+and pnpm9+ - Go
1.22+ - PostgreSQL
14+ - Redis
7+ - OpenSSH client (
ssh -V)
git clone https://github.com/thakurdotdev/tunl.git
cd tunl
pnpm installCopy the example configuration files:
cp control-plane/.env.example control-plane/.env
cp tunnel-server/.env.example tunnel-server/.envEnsure the databases and shared secrets are set:
control-plane/.env:
DATABASE_URL=postgres://postgres:postgres@localhost:5432/tunnel_saas
REDIS_URL=redis://localhost:6379/0
PORT=3001
JWT_SECRET=replace-with-a-random-secret-at-least-32-characters
INTERNAL_SHARED_SECRET=changeme-shared-secret-at-least-16-chars
DASHBOARD_URL=http://localhost:3000
BETTER_AUTH_SECRET=replace-with-a-random-secret-at-least-32-characters
BETTER_AUTH_URL=http://localhost:3001tunnel-server/.env:
BASE_DOMAIN=localhost
TUNNEL_URL_SCHEME=http
SSH_LISTEN_ADDR=:2222
HTTP_LISTEN_ADDR=:8080
HEALTH_LISTEN_ADDR=:9090
CONTROL_PLANE_URL=http://localhost:3001
INTERNAL_SHARED_SECRET=changeme-shared-secret-at-least-16-chars
REDIS_URL=redis://localhost:6379/0
REQUEST_LOG_ENABLED=true
LOG_LEVEL=infodashboard/.env:
NEXT_PUBLIC_CONTROL_PLANE_URL=http://localhost:3001Initialize the PostgreSQL schema:
pnpm db:migrate
# Optional: populate seed data
pnpm db:seedStart control-plane, dashboard, and tunnel-server in parallel:
pnpm dev-
Start a dummy HTTP server:
python3 -m http.server 5000
-
Establish an SSH tunnel against the local daemon using
-p 2222:ssh -p 2222 -R 80:localhost:5000 localhost
-
The terminal outputs your assigned local subdomain (e.g.
sunny-lake):>_ tunl █ • ● Online ────────────────────────────────────────────────────────── Account Anonymous Device Forwarding http://localhost:5000 Public URL http://sunny-lake.localhost:8080 Inspector http://localhost:3000/inspect/sunny-lake ────────────────────────────────────────────────────────── -
Send a request to the tunnel proxy:
curl -H "Host: sunny-lake.localhost" http://localhost:8080 -
Open the traffic inspector at
http://localhost:3000/inspect/sunny-laketo inspect headers, payloads, and round-trip timings in real time.
Run the workspace validation suite:
# Full check: oxfmt check + oxlint + Vitest + Go race tests
pnpm run check
# Go unit tests & race detector
cd tunnel-server && go test -race ./...
# Control plane unit tests
pnpm --filter control-plane test
# TypeScript type checks
pnpm --filter control-plane exec tsc --noEmit
pnpm --filter dashboard exec tsc --noEmitProduction process orchestration is configured via ecosystem.config.js:
# Build production assets
pnpm build
# Start services under PM2
pm2 start ecosystem.config.js
# Monitor
pm2 status
pm2 logsIn production, place Nginx in front of the HTTP proxy and dashboard. See shared/nginx.conf for reference:
tunl.online&tunl.online/api-> Dashboard (:3090) and API (:3091)*.tunl.online-> Tunnel HTTP proxy (:8088) with WebSocket upgrade headers- Open port
2222on your firewall for incoming SSH tunnels:sudo ufw allow 2222/tcp comment "tunl SSH reverse tunnels"
| Feature | tunl | ngrok | Cloudflare Tunnel | localtunnel |
|---|---|---|---|---|
| Client Requirement | Native OpenSSH (ssh -p 2222) |
Proprietary CLI | cloudflared |
Node CLI |
| Anonymous / Ephemeral | Supported | Account required | Account required | Supported |
| License | MIT (100% Open Source) | Proprietary core | Proprietary edge | MIT |
| Self-Hostable | Yes | No | Enterprise only | Abandoned |
| Request Inspector | Built-in SSE + cURL | Web interface | Dashboard only | No |
| Subdomain Reservation | SSH Key fingerprint | Paid plans | Yes | Unreliable |
| CIDR IP Allowlist | Built-in | Paid plans | Cloudflare Access | No |
| Tunnel Passwords | Built-in | Paid plans | Access policies | No |
| WebSocket / HMR | Native | Supported | Supported | Partial |
MIT License. See LICENSE for details.
Copyright (c) 2026 Pankaj Thakur.