Getting started
What is tunnels.io?
tunnels.io is a secure tunneling service that exposes your local development servers, APIs, and services to the public internet. Whether you are testing webhooks from Stripe or GitHub, demoing an application to a client, or debugging a mobile app that needs to reach your local backend, tunnels.io creates an encrypted tunnel from a public URL directly to a port on your machine.
When you start a tunnel, you get a public URL such as https://myapp.tunnels.host. Any HTTP or TCP traffic sent to that URL is forwarded through an encrypted TLS connection to your local service. No firewall config, no router port-forwarding, no remote deploy.
System requirements
The client is a single static binary with no external dependencies.
| Platform | Architecture | Minimum OS |
|---|---|---|
| Linux | amd64, arm64, 386, arm | Kernel 3.10+ |
| macOS | amd64 (Intel), arm64 (Apple Silicon) | macOS 11 (Big Sur) |
| Windows | amd64, 386, arm64 | Windows 10 |
| FreeBSD | amd64, arm64 | FreeBSD 12+ |
| NetBSD | amd64 | NetBSD 9+ |
Quick install (recommended)
One command. The installer detects your OS and architecture, downloads the correct binary, verifies the checksum, and installs it.
# Linux, macOS, FreeBSD, NetBSD curl -fsSL https://tunnels.io/install.sh | bash # Or with wget wget -qO- https://tunnels.io/install.sh | bash # Windows (PowerShell) irm https://tunnels.io/install.ps1 | iex
On Linux, macOS, and FreeBSD the installer places the binary in /usr/local/bin/tunnels (or ~/.local/bin/tunnels without sudo).
Manual installation
Prefer to install by hand? Download from the Downloads page or:
# Linux (amd64) curl -Lo tunnels https://download.tunnels.io/0.0.74/linux/amd64/tunnels # macOS (Apple Silicon) curl -Lo tunnels https://download.tunnels.io/0.0.74/darwin/arm64/tunnels chmod +x tunnels sudo mv tunnels /usr/local/bin/
Verify the installation:
tunnels version
# 0.0.74Creating your first tunnel
Have a local server on port 8000? Expose it:
tunnels http 8000
tunnels.io connects, allocates a public URL, and forwards traffic:
Tunnel Status: online Version: 0.0.74 Web Interface: http://127.0.0.1:4040 Forwarding: https://a1b2c3d4.tunnels.host -> http://localhost:8000
http://localhost:4040 shows every request through the tunnel with its method, path, status, duration, and full request and response headers. Invaluable for debugging webhooks.Signing up and getting an auth token
An account unlocks static subdomains, multiple tunnels, TCP, and higher limits:
- Visit tunnels.io/signup and create your account.
- Verify your email via the link sent to your inbox.
- Your initial CLI token (
tnl_…) is shown in your dashboard. Copy it. - Create additional tokens from Account > API Tokens.
Using your auth token
tunnels config add-authtoken tnl_xxxxxxxxxxxxxxxxxx tunnels http 8000
Installation
tunnels.io ships as a single static binary, no dependencies, runtimes, or package managers. Fastest path:
# Automatic install (detects OS + architecture)
curl -fsSL https://tunnels.io/install.sh | bashLinux
amd64, arm64, 386, and arm.
curl -fsSL https://tunnels.io/install.sh | bash
# Or manual (amd64)
curl -Lo tunnels https://download.tunnels.io/0.0.74/linux/amd64/tunnels
chmod +x tunnels && sudo mv tunnels /usr/local/bin/ && tunnels versionmacOS
Intel (amd64) and Apple Silicon (arm64).
# Apple Silicon
curl -Lo tunnels https://download.tunnels.io/0.0.74/darwin/arm64/tunnels
chmod +x tunnelsxattr -d com.apple.quarantine ./tunnels.Windows
# PowerShell
Invoke-WebRequest -Uri https://download.tunnels.io/0.0.74/windows/amd64/tunnels.exe -OutFile tunnels.exe
.\tunnels.exe versionFreeBSD
curl -Lo tunnels https://download.tunnels.io/0.0.74/freebsd/amd64/tunnels chmod +x tunnels && sudo mv tunnels /usr/local/bin/
Verifying with SHA-256 checksums
Every release includes checksums; the install script verifies automatically. Manually:
curl -fsSL https://download.tunnels.io/checksums.json sha256sum tunnels # Linux shasum -a 256 tunnels # macOS
Adding to PATH
| Platform | Location | Command |
|---|---|---|
| Linux | /usr/local/bin/ | sudo mv tunnels /usr/local/bin/ |
| macOS | /usr/local/bin/ | sudo mv tunnels /usr/local/bin/ |
| Windows | C:\Program Files\Tunnels\ | Add directory to PATH |
| FreeBSD | /usr/local/bin/ | sudo mv tunnels /usr/local/bin/ |
| NetBSD | /usr/local/bin/ | sudo mv tunnels /usr/local/bin/ |
Running as a service
The client runs in the foreground. There is no --detach flag and no built-in service installer, so keeping a tunnel up across a logout or a reboot is your operating system's job. Pick the supervisor for your platform below.
Three settings every service needs
| Setting | Why |
|---|---|
| --inspect-addr disabled | The local request inspector binds 127.0.0.1:4040 by default and has no authentication. Two services collide on that port. Use 127.0.0.1:0 instead if you want it on a port the OS picks. |
| TUNELS_NO_UPDATE_CHECK=1 | Stops the once-a-day update banner from filling the service log. A service does not self-update either way, so run tunnels update deliberately. |
| the right user | The authtoken is read from $HOME/.tunnels, and $HOME follows whichever account the service runs as. See the warning below. |
macOS (launchd)
launchd has two kinds of job and they are not interchangeable.
| LaunchAgent | LaunchDaemon | |
|---|---|---|
| Location | ~/Library/LaunchAgents/ | /Library/LaunchDaemons/ |
| Starts | at login | at boot, before any login |
| Survives logout | no | yes |
| Runs as | you | root, until you set UserName |
| Finds ~/.tunnels | yes | no, --config is required |
| Use for | a Mac you log into | a headless Mac or build box |
LaunchAgent. Save as ~/Library/LaunchAgents/io.tunnels.client.plist, replacing the user name in the log paths:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key> <string>io.tunnels.client</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/tunnels</string>
<string>--log</string> <string>stdout</string>
<string>--inspect-addr</string> <string>disabled</string>
<string>start-all</string>
</array>
<key>RunAtLoad</key> <true/>
<key>KeepAlive</key> <true/>
<key>ThrottleInterval</key> <integer>10</integer>
<key>StandardOutPath</key> <string>/Users/you/Library/Logs/tunnels.log</string>
<key>StandardErrorPath</key> <string>/Users/you/Library/Logs/tunnels.log</string>
<key>EnvironmentVariables</key>
<dict><key>TUNELS_NO_UPDATE_CHECK</key><string>1</string></dict>
</dict>
</plist># load it, then check it launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/io.tunnels.client.plist launchctl print gui/$(id -u)/io.tunnels.client # restart, and stop launchctl kickstart -k gui/$(id -u)/io.tunnels.client launchctl bootout gui/$(id -u)/io.tunnels.client
LaunchDaemon. Same plist with three changes: add <key>UserName</key> naming the account that owns the config, add --config /Users/you/.tunnels to the arguments, and write the log somewhere that account can reach.
sudo cp io.tunnels.client.plist /Library/LaunchDaemons/ sudo chown root:wheel /Library/LaunchDaemons/io.tunnels.client.plist sudo chmod 644 /Library/LaunchDaemons/io.tunnels.client.plist sudo launchctl bootstrap system /Library/LaunchDaemons/io.tunnels.client.plist
If the job keeps restarting, launchctl print shows the last exit status and the file named in StandardErrorPath holds the reason. launchd throttles respawn to ten seconds, so a misconfigured job loops slowly rather than spinning.
Linux (systemd)
# /etc/systemd/system/tunnels.service
[Unit]
Description=tunnels.io client
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=tunnels
ExecStart=/usr/local/bin/tunnels --log stdout --inspect-addr disabled start-all
Environment=TUNELS_NO_UPDATE_CHECK=1
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload sudo systemctl enable --now tunnels systemctl status tunnels journalctl -u tunnels -f
Type=simple is correct and is the default. The client never forks, so Type=forking would make systemd wait for a fork that never comes and then kill the unit. Use network-online.target rather than network.target: the latter is satisfied before any interface has an address.
If you add ProtectHome or ProtectSystem=strict, grant write access to ~/.local/share/tunnels/ as well. The client stores a persistent machine id there, and a blocked write is silent: it simply generates a new id on every start.
FreeBSD (rc.d)
The client does not fork and rc.subr does not supervise, so use daemon(8) from the base system. It backgrounds the client and, with -r, restarts it if it dies.
# /usr/local/etc/rc.d/tunnels chmod 755
#!/bin/sh
#
# PROVIDE: tunnels
# REQUIRE: NETWORKING DAEMON
# KEYWORD: shutdown
. /etc/rc.subr
name="tunnels"
rcvar="tunnels_enable"
load_rc_config $name
: ${tunnels_enable:="NO"}
: ${tunnels_user:="tunnels"}
: ${tunnels_config:="/home/tunnels/.tunnels"}
: ${tunnels_args:="start-all"}
pidfile="/var/run/${name}.pid"
command="/usr/sbin/daemon"
command_args="-r -P ${pidfile} -o /var/log/${name}.log -t ${name} -u ${tunnels_user} -- \
/usr/bin/env TUNELS_NO_UPDATE_CHECK=1 \
/usr/local/bin/tunnels --log stdout --inspect-addr disabled \
--config ${tunnels_config} ${tunnels_args}"
run_rc_command "$1"sudo sysrc tunnels_enable=YES sudo service tunnels start service tunnels status
-P writes the supervisor's pid, not the client's, so service tunnels stop stops both instead of killing a child that daemon(8) would immediately restart. For the same reason leave procname alone: it defaults to $command, which is what the pidfile actually holds. Point it at the tunnels binary instead and service tunnels status reports the service down while it is up.
NetBSD (rc.d)
# /etc/rc.d/tunnels chmod 755 #!/bin/sh # # PROVIDE: tunnels # REQUIRE: NETWORK DAEMON # KEYWORD: shutdown . /etc/rc.subr name="tunnels" rcvar=$name command="/usr/local/bin/tunnels" load_rc_config $name : ${tunnels:="NO"} : ${tunnels_user:="tunnels"} : ${tunnels_config:="/home/tunnels/.tunnels"} : ${tunnels_args:="start-all"} export TUNELS_NO_UPDATE_CHECK=1 # The trailing & backgrounds it. No pidfile is set on purpose: with one it # would hold the subshell's pid. Without one, rc.subr finds the real process # by $command, so stop and status both work. command_args="--log /var/log/tunnels.log --inspect-addr disabled \ --config ${tunnels_config} ${tunnels_args} &" run_rc_command "$1"
echo 'tunnels=YES' | sudo tee -a /etc/rc.conf sudo /etc/rc.d/tunnels start /etc/rc.d/tunnels status
Create the log file first and hand it to the service account: touch /var/log/tunnels.log && chown tunnels /var/log/tunnels.log.
Windows
There is no service installer. A scheduled task set to run at system startup, with highest privileges and independent of anyone being logged on, is the least fuss. NSSM or WinSW does the same with better restart handling.
# PowerShell, as Administrator
$action = New-ScheduledTaskAction -Execute "C:\Program Files\Tunnels\tunnels.exe" `
-Argument "--log stdout --inspect-addr disabled start-all"
$trigger = New-ScheduledTaskTrigger -AtStartup
$set = New-ScheduledTaskSettingsSet -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1)
Register-ScheduledTask -TaskName "tunnels" -Action $action -Trigger $trigger `
-Settings $set -User "SYSTEM" -RunLevel HighestReady-made files
All of the above ship in the client repository under contrib/, along with a README covering the usual failures: io.tunnels.client.plist, io.tunnels.client.daemon.plist, tunnels.service, tunnels.freebsd.rc and tunnels.netbsd.rc.
CLI reference
Basic syntax
tunnels [options] <local-address>
The <local-address> is the local service to expose: a port (8080), host:port (localhost:8080), or IP:port. A bare port assumes localhost.
Subcommands
| Command | What it does |
|---|---|
| start <name>... | Start one or more named tunnels from the config file. |
| start-all | Start every tunnel in the config file, in one process and one connection. |
| list | List the tunnels defined in the config file. Honours --format and --json. |
| stop <name|url> | Ask a running client to close one tunnel and leave its others alive. It finds the process through the local inspector, so a client started with --inspect-addr disabled cannot be reached this way. |
| config | add-authtoken [TOKEN] saves a token, and reads it from standard input with --stdin so it never reaches your shell history. path prints the config location. show prints the config with secrets redacted. |
| auth | login signs in through the browser. logout clears stored tokens. whoami prints the current identity and returns a distinct exit code per outcome, which is what scripts should branch on. |
| team | Workspace management: list, switch, current, create, show, invite, members, subdomains, seats, audit, billing. ws is an alias. |
| token | Team CLI tokens: list, create, revoke, rotate. Requires an active workspace. |
| inspect <tunnel> | Live-stream HTTP transactions on a tunnel over server-sent events. |
| completions <shell> | Print a shell completion script. |
| version | Print the version. --check also reports the latest published release. |
| update | Replace the installed binary in place, after verifying its SHA-256 against the release manifest. --check reports without installing. |
Run any of them with --help for the full set of options.
Command-line flags
These are global: they work before or after the subcommand.
| Flag | Default | Description |
|---|---|---|
| --authtoken TOKEN | none | Your auth token (begins tnl_). Required for static subdomains, multiple tunnels, and paid features. Also settable in the config file, or with tunnels config add-authtoken. |
| -c, --config FILE | ~/.tunnels | Path to the YAML config file. There is no search of the current directory: without this flag the client reads $HOME/.tunnels and nothing else, so a .tunnels sitting next to you is ignored unless you point at it. |
| --log DEST | none | stdout, none, or a file path. Anything else is treated as a file path, so --log stderr creates a file called stderr rather than writing to the stream. |
| --log-level LEVEL | INFO | DEBUG, INFO, WARNING, ERROR. Case does not matter. |
| --inspect-addr ADDR | 127.0.0.1:4040 | Where the local request inspector binds. disabled turns it off, which is what a background service wants. 127.0.0.1:0 lets the operating system pick a free port, which is best when several clients run at once. |
| --capture-bodies | off | Record request and response bodies for the inspector. They stay on your machine and are never sent to the tunnel server, capped at 64 KiB per body and 8 MiB in total, text content types only. The inspector has no authentication, so do not enable this on a shared host. |
| --events ndjson | off | Emit one JSON object per line on stdout for every lifecycle event: tunnels opening and closing with the server's reason, reconnects, and exit. Implies headless output; logs move to stderr. Built for scripts and CI. |
| -W, --workspace SLUG | active workspace | Use this workspace for one invocation, without the persistent switch that tunnels team switch makes. Intended for CI. |
| --format FORMAT | table | Output format for list commands: table, json, yaml, csv. |
| --json | off | Shorthand for --format json. Wins if both are given. |
| --no-color | off | Disable ANSI colour. The NO_COLOR environment variable does the same. |
| -y, --yes | off | Answer every confirmation prompt with yes. For scripts and CI. |
Quick-tunnel flags
These apply only to an ad-hoc tunnel such as tunnels http 8000, not to start or start-all, which take their settings from the config file. Put them before the port: everything after it is passed through untouched.
| Flag | Default | Description |
|---|---|---|
| --subdomain NAME | random | Request a specific subdomain (Pro and above). 3 to 63 characters, lowercase letters, digits and hyphens, no leading or trailing hyphen and no two hyphens in a row. Common names such as www, api, admin, app and mail are reserved and are refused. |
| --domain DOMAIN | none | A full static domain, for example myapp.tunnels.host. Cannot be combined with --subdomain or --hostname. |
| --hostname HOST | none | A custom domain you own and have pointed at us. |
| --httpauth USER:PASS | none | Put HTTP basic authentication in front of the tunnel. |
| -p, --proto PROTOCOL | https | https or tcp. http is accepted and treated as https: tunnels are always TLS on the public side. TCP requires Pro or above. |
Environment variables
| Variable | Effect |
|---|---|
| TUNELS_AUTHTOKEN | Supply the token without writing it anywhere. It takes priority over the config file, and the client will not save it to disk, which is what you want in CI. |
| TUNELS_NO_UPDATE_CHECK | Set to 1 to stop the once-a-day update banner. Set it in any service or CI job. |
| http_proxy | Outbound HTTP proxy for the connection to the gateway. Lowercase only; HTTPS_PROXY and HTTP_PROXY are not read. |
| NO_COLOR | Disable ANSI colour, same as --no-color. |
Usage examples
# Basic HTTPS tunnel. A bare port means localhost. tunnels 3000 tunnels http 3000 tunnels localhost:3000 # Authenticated, static subdomain (Pro and above) tunnels --authtoken tnl_abc123 --subdomain myapi http 8080 # -> https://myapi.tunnels.host # TCP tunnel for a database tunnels --authtoken tnl_abc123 tcp 5432 # Behind HTTP basic auth tunnels --httpauth alice:s3cret http 8080 # Debug logging to a file tunnels --log /tmp/tunnels-debug.log --log-level DEBUG http 8000 # Named tunnels from the config file tunnels list tunnels start webapp tunnels --config ./my-tunnels.yaml start-all # Scriptable: token from the environment, machine-readable output, # no inspector port, no update banner. TUNELS_AUTHTOKEN=tnl_abc123 TUNELS_NO_UPDATE_CHECK=1 \ tunnels --inspect-addr disabled --events ndjson http 3000 # Who am I, as JSON. Exit code says why, so scripts can branch on it. tunnels auth whoami --json
Configuration
Settings live in ~/.tunnels, a YAML file the client reads on every run. Point at a different file with --config path/to/file. The easiest way to create it is tunnels config add-authtoken TOKEN, which writes the file for you.
File format
Note the shape of proto: it is a map of protocol to local address, not a string. There is no separate addr field.
# ~/.tunnels server_addr: tunnels.host:443 auth_token: tnl_xxxxxxxxxxxxxxxxxxxxxxxx inspect_addr: 127.0.0.1:4040 tunnels: webapp: subdomain: myapp proto: http: localhost:3000 database: proto: tcp: localhost:5432
Start them with tunnels start webapp, or tunnels start-all.
Configuration fields
| Field | Type | Description |
|---|---|---|
| server_addr | string | Tunnel gateway address. Default tunnels.host:443. Clients before 0.0.73 used gw.tunnels.one:443. That name still works, and 0.0.73 and later read it as tunnels.host:443. Leave this unset unless you were told otherwise. |
| auth_token | string | Auth token (begins tnl_). Same as --authtoken. Set it with tunnels config add-authtoken. |
| inspect_addr | string | Address for the local request inspector. Default 127.0.0.1:4040. Set it to disabled to turn the inspector off. Left at the default, a second client automatically takes the next free port (4041, 4042, and so on) and prints the one it used. Set it explicitly and the client binds that address or reports that it could not. |
| http_proxy | string | Outbound proxy for the connection to the tunnel server. |
| trust_host_root_certs | bool | Use the operating system trust store for the server connection. |
| active_team_id | string | The workspace this client acts in. Written by tunnels team switch. Absent means personal. It is sent with the handshake so the server applies the team's plan rather than yours. |
| active_team_slug | string | The readable name of that workspace. Written at the same time. |
| workspace_tokens | map | Workspace identifier to team token (tnlt_). Lets you switch between workspaces without signing in again. The token for the active workspace is preferred over auth_token. |
| tunnels | map | Named tunnel definitions. Each entry takes proto (a map of protocol to local address), and optionally subdomain, domain, hostname, auth and remote_port. |
Custom domains
On Gold and Premium plans, use your own domain instead of a *.tunnels.host subdomain:
- Add a CNAME pointing your domain to
tunnels.host:myapp.example.com CNAME tunnels.host.
- Wait for DNS propagation (5-30 min typical).
- Start with the
-hostnameflag:tunnels --hostname myapp.example.com --authtoken tnl_xxx localhost:3000
TLS certificates for custom domains are provisioned and renewed automatically via Let's Encrypt.
Environment variables
The client reads the variables below. Note the prefix is TUNELS_ with a single N, which does not match the product name. There is no environment override for the auth token: set it in the config file with tunnels config add-authtoken, or pass --authtoken.
| Environment Variable | Effect |
|---|---|
| TUNELS_NO_UPDATE_CHECK | Set to 1 to skip the once-a-day update check and its banner. Recommended in CI. |
| TUNELS_NO_TELEMETRY | Set to 1 to disable telemetry. |
| TUNELS_API_BASE_URL | Override the platform API base the client talks to. Rarely needed. |
| http_proxy | Standard outbound proxy for the connection to the tunnel server. |
API reference
The REST API manages your account, tokens, subscriptions, and usage metrics. Responses are JSON, except the invoice document routes, which return a PDF or a ZIP.
The endpoints below are the ones most people need. They are not the whole API: billing, checkout, payment methods, invoices, notifications, purchased domains and the entire team surface all have their own routes, and the CLI exercises them. Run any tunnels subcommand with --help to see what it calls.
Base URL
https://tunnels.io/api/v1
Authentication
Authenticate with a JWT Bearer token (from login) or a CLI token, in the Authorization header. JWT access tokens expire after 30 minutes; renew one with POST /api/v1/auth/refresh. CLI tokens do not expire unless revoked.
Every authenticated route is also scope-gated. A CLI token carries a set of scopes, chosen when it is created, and a request outside them is refused with 403 rather than 401. The scopes used below are account:read, tokens:read, tokens:write, tokens:delete, usage:read, tunnels:read and billing:read. A team token (tnlt_) is also refused with 403 on a personal route, and a CLI token is refused on the routes that require a browser session.
Authorization: Bearer eyJhbGciOiJSUzI1NiIs... # JWT Authorization: Bearer tnl_xxxxxxxxxxxxxxxx # CLI token
Response format
The success body is not uniform across the API. Read the shape from the endpoint you call, and do not assume a wrapper. Three shapes are in use:
// 1. Bare object or array (most endpoints) { "email": "[email protected]", ... } // 2. Wrapped under "data" (/api/v1/usage/summary only) { "data": { ... } } // 3. Wrapped under a named key (/api/v1/me/active-tunnels) { "tunnels": [ ... ] }
Error bodies are also not uniform. Check the HTTP status code first; it is always correct.
// JSON error: most application-level failures { "error": "Description of what went wrong" } // Plain text: every 401 and 403 from the auth layer, every 429, // and the metrics routes. These are the failures you hit most. Unauthorized Invalid token Rate limit exceeded
Parse the status code, not the body. A client that assumes JSON on a 401 fails on the most common error in the API.
Endpoints
/api/v1/auth/registerCreate a new account. No auth required.
// request { "email": "[email protected]", "password": "MySecureP@ss1", "first_name": "Jane", "last_name": "Doe", "cf_turnstile_response": "..." } // 201 response. Note the token: you do not need to call /tokens. { "message": "...", "user": { "email": "[email protected]" }, "cli_token": "tnl_...", "cli_token_note": "..." }
Password: 8 to 128 characters, and at least three of four classes: uppercase, lowercase, digits, special. All four are not required.
Sign-up is bot-protected. When protection is on, a missing cf_turnstile_response is rejected with 400, so this endpoint is meant for the sign-up page rather than for scripts. Throttled per address, with Retry-After: 3600.
/api/v1/auth/loginAuthenticate. There are two possible success bodies, so check for otp_required before reading the token pair.
// normal account { "access_token": "eyJ…", "refresh_token": "eyJ…", "token_type": "Bearer", "expires_at": "..." } // account with one-time codes enabled: no tokens yet. // Post the code to /api/v1/auth/verify-otp with this login_token. { "otp_required": true, "login_token": "..." }
A client that decodes only the first shape reads a zero-valued token pair and fails silently.
/api/v1/auth/meAuth requiredThe current user's profile. Scope account:read.
{ "id": "...", "email": "[email protected]", "is_admin": false,
"current_plan": "pro", "first_name": "Jane", "last_name": "Doe" }The plan field is current_plan. There is no status or verified field here.
/api/v1/plansList all subscription plans (code, name, price, limits). No auth.
/api/v1/subscriptionAuth requiredActive subscription, plan and billing cycle. Scope billing:read. It also carries a bandwidth_used_gb field, but that field is a placeholder and is always 0. Real usage is on /api/v1/usage/summary.
/api/v1/tokensAuth requiredCreate a CLI token. Scope tokens:write.
{ "name": "ci-runner", "scopes": ["tunnels:write"], "expires_in": 2592000 }name is required; without it the call is rejected with 400. An unknown scope string is also rejected. The full tnl_… value is in this response; afterwards it can only be read again from a signed-in browser session, never with a CLI token.
/api/v1/tokensAuth requiredList CLI tokens. Scope tokens:read. Each entry carries a prefix, which is the first part of the token, tnl_ plus six characters and an ellipsis. The last characters are never returned.
/api/v1/tokens/{id}Auth requiredRevoke a token so it cannot open anything new. Scope tokens:delete.
tunnels stop./api/v1/metrics/currentAuth requiredCurrent period usage: bandwidth, active tunnels, total requests.
/api/v1/metrics/historyAuth requiredUsage over time. ?period=hour (last 24 hours), day (last 30 days, the default), or month. Any other value returns 400.
Note: the top-level JSON shape differs per period. Decode against the period you asked for.
/api/v1/usage/summaryAuth requiredPlan limits and what is used against them: bandwidth, tunnels, custom domains, requests, and the billing period. Wrapped under data.
/api/v1/me/active-tunnelsAuth requiredThe tunnels that are connected right now. Returns { "tunnels": [ … ] }.
HTTP status codes
| 200 | Request succeeded |
| 201 | Resource created |
| 400 | Bad request (validation error) |
| 401 | Unauthorized |
| 403 | Forbidden. Plan or permission, a scope the token does not hold, a team token on a personal route, or a CLI token where a browser session is required. |
| 404 | Not found |
| 409 | Conflict (e.g. email already registered) |
| 410 | Removed. The route existed once and will not come back. |
| 429 | Rate limited |
| 500 | Internal server error |
| 503 | A dependency is unavailable. Retry. |
429 Too Many Requests with a Retry-After header and a plain-text body.AI assistants (MCP)
tunnels-mcp is the tunnels.io MCP server. It lets an AI assistant such as Claude read your account and put a local port online. It runs on your computer and talks only to tunnels.io.
1. Create a token
Go to Account → Tokens → Connect an AI assistant. Tick only the permissions the assistant needs. The token can read; it cannot create, change, delete, or spend money.
2. Install the server
Download a binary from the releases page, or build it with Go:
go install github.com/tunnels-io/tunnels-mcp/cmd/tunnels-mcp@latest
Claude Desktop can install the .mcpb file from the releases page instead. It asks for the token.
3. Add it to your client
{
"mcpServers": {
"tunnels": {
"command": "tunnels-mcp",
"env": { "TUNNELS_TOKEN": "tnl_..." }
}
}
}Tools
The server reads the token's permissions when it starts and lists only the tools that token can use.
| Tool | Permission | What it returns |
|---|---|---|
| list_tunnels | tunnels:read | Tunnels running now, with public URLs |
| get_usage | usage:read | Bandwidth, requests and quota this period |
| get_metrics | usage:read | Live traffic |
| get_account | account:read | Email, name and plan |
| list_tokens | tokens:read | Token names and prefixes, never secrets |
| list_invoices | billing:read | Invoices and payment status |
| list_domains | registrar:read | Domains bought through tunnels.io |
| list_notifications | notifications:read | In-app notifications |
| list_cloud_apps | cloud:read | Serverless apps and their status |
| start_tunnel | — | Opens an HTTP or TCP tunnel and returns the public URL |
| stop_tunnel | — | Closes a tunnel it opened |
start_tunnel and stop_tunnel need the tunnels CLI on your PATH, and a TCP tunnel needs a plan that includes TCP. An older all-access token cannot read billing, domains or cloud apps; create a token on the page above for those.Protocols
Two transport protocols: HTTP/HTTPS and TCP. TCP requires a paid plan.
HTTP/HTTPS tunneling
The default. You get a public HTTPS URL (e.g. https://myapp.tunnels.host); the server terminates TLS and forwards plain HTTP to your local service.
- Automatic HTTPS — every tunnel gets a valid TLS cert, no config.
- Request inspection — the local UI at
localhost:4040. - Host header rewriting — for virtual-host local setups.
- WebSocket support —
wss://works automatically.
TCP tunneling
Forward raw TCP for databases, SSH, game servers (Pro+). You get a public host:port like 0.tcp.tunnels.host:12345.
# PostgreSQL tunnels --proto tcp --authtoken tnl_xxx localhost:5432 # psql -h 0.tcp.tunnels.host -p 12345 -U user db # SSH tunnels --proto tcp --authtoken tnl_xxx localhost:22
Protocol availability by plan
| Protocol | Basic | Student | Pro | Gold | Premium |
|---|---|---|---|---|---|
| HTTP/HTTPS | |||||
| TCP | |||||
| WebSocket |
Security
TLS encryption
All traffic between the internet and the server is TLS 1.2+. The control channel between client and server uses a dedicated TLS connection. HTTP tunnels automatically receive a valid HTTPS certificate; the data channel is encrypted for TCP tunnels too.
Authentication methods
- Basic Auth (Pro+) —
tunnels -auth="user:password" localhost:8000 - OAuth / Google (Pro+) — restrict access to Google-authenticated users.
- IP allowlisting (Gold/Premium) —
tunnels -cidr-allow="203.0.113.0/24" localhost:8000
Token security
CLI tokens (tnl_) are 64+ chars of cryptographically random data, the keys to your account.
Best practices
Do
- Store tokens in env vars or
chmod 600config files - Use separate tokens per environment
- Rotate tokens periodically
- Use IP restrictions for sensitive services
- Add config files containing tokens to
.gitignore
Do not
- Commit auth tokens to version control
- Share tokens in chat, email, or forums
- Expose production databases without IP restrictions
- Leave tunnels running unauthenticated
- Reuse one token across team members
DELETE /api/v1/tokens/{id}. Active tunnels are terminated instantly. Then create a new one.Plans & limits
Plan comparison
| Feature | Basic | Student | Pro | Gold | Premium |
|---|---|---|---|---|---|
| Protocols | HTTP/HTTPS | HTTP/HTTPS | + TCP | + TCP | + TCP |
| Subdomain | Random | Random | Static | Custom domain | Custom domain |
| Support | Community | Forum | Priority email | Priority + Slack |
Current prices, bandwidth allowances, request-rate limits and tunnel counts are on the pricing page, which reads them live from the plan catalog.
The Student plan requires verification with a valid university email address.
Rate limiting (RPS)
Each plan has a max requests-per-second. A token-bucket algorithm with a burst multiplier allows short spikes at 2-5x. Requests over the limit get a 429 from the server, your local service never sees them.
Bandwidth enforcement
- 80% usage — email notification.
- 100% usage — a 24-hour grace period; traffic keeps flowing.
- Grace expired — new connections denied until next period or upgrade.
Troubleshooting
curl http://localhost:8000; match the port; try 127.0.0.1:8000 if your service binds there specifically.tnl_ and isn't truncated; check it isn't revoked; verify your email; create a new token.xattr -d com.apple.quarantine ./tunnelstunnels.host:443, the gateway a default install dials. Also allow gw.tunnels.one:443, which clients before 0.0.73 dial. Behind an HTTP proxy, set the lowercase http_proxy environment variable, or the http_proxy key in ~/.tunnels; HTTPS_PROXY and HTTP_PROXY are not read. Test from a mobile hotspot to confirm it is the network.inspect_addr: disabled); a slow backend looks like a slow tunnel.4040 and prints the address it actually bound, so read the Web Interface line rather than assuming 4040. Any inspector page also lists the other clients running on your machine, so you can move between them. Fix for older clients: versions before this behaviour shipped kept printing 127.0.0.1:4040 even when the port was already taken, which sent you to the first client's traffic; upgrade with tunnels update. To put several tunnels in one inspector instead, run them from one process with tunnels start-all.Enabling debug logs
tunnels --log stdout --log-level debug localhost:8000
# Or to a file for support
tunnels --log /tmp/tunnels-debug.log --log-level debug localhost:8000Deployment
Integrate tunnels.io into CI/CD pipelines, Docker workflows, and team setups for staging and deploy previews.
GitHub Actions example
# .github/workflows/e2e-tests.yml name: E2E Tests on: [pull_request] jobs: test: runs-on: ubuntu-latest steps: - name: Install tunnels run: curl -fsSL https://tunnels.io/install.sh | bash - name: Start tunnel env: { TUNELS_NO_UPDATE_CHECK: "1" } run: | tunnels config add-authtoken ${{ secrets.TUNNELS_TOKEN }} tunnels --subdomain pr-${{ github.event.number }} http 3000 &
Deploy previews for pull requests
Give every PR its own public URL using the PR number as the subdomain: https://pr-42.tunnels.host. Post the URL as a bot comment so reviewers click once.
Docker integration
# docker-compose.yml services: app: build: . ports: ["3000:3000"] tunnel: image: alpine:latest depends_on: [app] environment: ["TUNNELS_TOKEN=${TUNNELS_TOKEN}"] command: sh -c "wget -qO- https://tunnels.io/install.sh | bash && tunnels config add-authtoken $TUNNELS_TOKEN && tunnels --subdomain myapp http app:3000"
Team collaboration
- Each developer has a personal account + token.
- CI/CD uses a dedicated service-account token stored as a repo secret.
- Subdomain convention:
{project}-{developer}(e.g.api-jane). - Shared staging: a team service account for always-on tunnels.
tunnels config add-authtoken TOKEN once. It saves the token to ~/.tunnels so every later command picks it up automatically.