Documentation

Everything you need to know.

Install, configure, and use tunnels.io to expose your local services to the internet securely. From your first tunnel to advanced configurations.

On this page

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.

PlatformArchitectureMinimum OS
Linuxamd64, arm64, 386, armKernel 3.10+
macOSamd64 (Intel), arm64 (Apple Silicon)macOS 11 (Big Sur)
Windowsamd64, 386, arm64Windows 10
FreeBSDamd64, arm64FreeBSD 12+
NetBSDamd64NetBSD 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.74

Creating 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
Web Interface: a local inspection UI at 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:

  1. Visit tunnels.io/signup and create your account.
  2. Verify your email via the link sent to your inbox.
  3. Your initial CLI token (tnl_…) is shown in your dashboard. Copy it.
  4. 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 | bash

Linux

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 version

macOS

Intel (amd64) and Apple Silicon (arm64).

# Apple Silicon
curl -Lo tunnels https://download.tunnels.io/0.0.74/darwin/arm64/tunnels
chmod +x tunnels
macOS Gatekeeper: the macOS binary is signed with a Developer ID certificate and notarized by Apple, so it runs without a Gatekeeper prompt. If you moved or rewrote the file after downloading and macOS still blocks it, clear the quarantine flag once with xattr -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 version

FreeBSD

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

PlatformLocationCommand
Linux/usr/local/bin/sudo mv tunnels /usr/local/bin/
macOS/usr/local/bin/sudo mv tunnels /usr/local/bin/
WindowsC:\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.

A supervisor is not what fixes a flaky network. The client already reconnects on its own, with exponential backoff capped at 30 seconds plus jitter, and it honours a reconnect delay sent by the server. What it cannot survive is the process going away: a reboot, a crash, an out-of-memory kill. That is what a service manager adds.

Three settings every service needs

SettingWhy
--inspect-addr disabledThe 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=1Stops 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 userThe authtoken is read from $HOME/.tunnels, and $HOME follows whichever account the service runs as. See the warning below.
The most common failure: the token is in a different home. A unit that runs as a dedicated tunnels account does not see the config you created under your own login. The service starts, exits immediately with auth token required, and the restart policy loops it forever. Either run the service as the account that ran tunnels config add-authtoken, or pass an absolute --config /home/you/.tunnels.

macOS (launchd)

launchd has two kinds of job and they are not interchangeable.

LaunchAgentLaunchDaemon
Location~/Library/LaunchAgents//Library/LaunchDaemons/
Startsat loginat boot, before any login
Survives logoutnoyes
Runs asyouroot, until you set UserName
Finds ~/.tunnelsyesno, --config is required
Use fora Mac you log intoa 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
Two launchd traps. First, launchd does not expand ~. A plist containing ~/Library/Logs/tunnels.log produces no log and no error, so use absolute paths everywhere. Second, a LaunchDaemon plist must be root:wheel and mode 644; launchctl refuses anything group- or world-writable and reports only Bootstrap failed: 5: Input/output error, which names no cause.

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.target
sudo 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)

NetBSD has no daemon(8). Copying the FreeBSD script above onto NetBSD fails with daemon: not found. Nothing in the NetBSD base system supervises, so this script gets no automatic restart. The client's own reconnect still covers network loss; a crashed process stays down until you start it again.
# /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 Highest
Running the task as SYSTEM means the client looks for its config in the SYSTEM profile, not yours. Add --config C:\Users\you\.tunnels to the arguments, or register the task under your own account instead.

Ready-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

CommandWhat it does
start <name>...Start one or more named tunnels from the config file.
start-allStart every tunnel in the config file, in one process and one connection.
listList 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.
configadd-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.
authlogin 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.
teamWorkspace management: list, switch, current, create, show, invite, members, subdomains, seats, audit, billing. ws is an alias.
tokenTeam 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.
versionPrint the version. --check also reports the latest published release.
updateReplace 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.

FlagDefaultDescription
--authtoken TOKENnoneYour 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~/.tunnelsPath 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 DESTnonestdout, 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 LEVELINFODEBUG, INFO, WARNING, ERROR. Case does not matter.
--inspect-addr ADDR127.0.0.1:4040Where 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-bodiesoffRecord 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 ndjsonoffEmit 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 SLUGactive workspaceUse this workspace for one invocation, without the persistent switch that tunnels team switch makes. Intended for CI.
--format FORMATtableOutput format for list commands: table, json, yaml, csv.
--jsonoffShorthand for --format json. Wins if both are given.
--no-coloroffDisable ANSI colour. The NO_COLOR environment variable does the same.
-y, --yesoffAnswer 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.

FlagDefaultDescription
--subdomain NAMErandomRequest 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 DOMAINnoneA full static domain, for example myapp.tunnels.host. Cannot be combined with --subdomain or --hostname.
--hostname HOSTnoneA custom domain you own and have pointed at us.
--httpauth USER:PASSnonePut HTTP basic authentication in front of the tunnel.
-p, --proto PROTOCOLhttpshttps or tcp. http is accepted and treated as https: tunnels are always TLS on the public side. TCP requires Pro or above.

Environment variables

VariableEffect
TUNELS_AUTHTOKENSupply 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_CHECKSet to 1 to stop the once-a-day update banner. Set it in any service or CI job.
http_proxyOutbound HTTP proxy for the connection to the gateway. Lowercase only; HTTPS_PROXY and HTTP_PROXY are not read.
NO_COLORDisable 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

FieldTypeDescription
server_addrstringTunnel 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_tokenstringAuth token (begins tnl_). Same as --authtoken. Set it with tunnels config add-authtoken.
inspect_addrstringAddress 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_proxystringOutbound proxy for the connection to the tunnel server.
trust_host_root_certsboolUse the operating system trust store for the server connection.
active_team_idstringThe 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_slugstringThe readable name of that workspace. Written at the same time.
workspace_tokensmapWorkspace 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.
tunnelsmapNamed 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:

  1. Add a CNAME pointing your domain to tunnels.host:
    myapp.example.com    CNAME    tunnels.host.
  2. Wait for DNS propagation (5-30 min typical).
  3. Start with the -hostname flag:
    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 VariableEffect
TUNELS_NO_UPDATE_CHECKSet to 1 to skip the once-a-day update check and its banner. Recommended in CI.
TUNELS_NO_TELEMETRYSet to 1 to disable telemetry.
TUNELS_API_BASE_URLOverride the platform API base the client talks to. Rarely needed.
http_proxyStandard outbound proxy for the connection to the tunnel server.
Priority (highest to lowest): command-line flags, then the config file, then built-in defaults.

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

POST/api/v1/auth/register

Create 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.

POST/api/v1/auth/login

Authenticate. 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.

GET/api/v1/auth/meAuth required

The 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.

GET/api/v1/plans

List all subscription plans (code, name, price, limits). No auth.

GET/api/v1/subscriptionAuth required

Active 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.

POST/api/v1/tokensAuth required

Create 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.

GET/api/v1/tokensAuth required

List 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.

DELETE/api/v1/tokens/{id}Auth required

Revoke a token so it cannot open anything new. Scope tokens:delete.

Revoking does not close tunnels that are already open. It marks the token revoked, and the gateway caches token validation for a few minutes, so a tunnel started with that token can keep serving for a short time. If you need it down now, stop the client, or use tunnels stop.
GET/api/v1/metrics/currentAuth required

Current period usage: bandwidth, active tunnels, total requests.

GET/api/v1/metrics/historyAuth required

Usage 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.

GET/api/v1/usage/summaryAuth required

Plan limits and what is used against them: bandwidth, tunnels, custom domains, requests, and the billing period. Wrapped under data.

GET/api/v1/me/active-tunnelsAuth required

The tunnels that are connected right now. Returns { "tunnels": [ … ] }.

HTTP status codes

200Request succeeded
201Resource created
400Bad request (validation error)
401Unauthorized
403Forbidden. 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.
404Not found
409Conflict (e.g. email already registered)
410Removed. The route existed once and will not come back.
429Rate limited
500Internal server error
503A dependency is unavailable. Retry.
Rate limiting: limits are counted per account when the request is authenticated, and per address only when it is not. So a second machine using the same token shares one budget. Limits are applied to the sign-in and token routes rather than across the whole API. Exceeding one returns 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.

ToolPermissionWhat it returns
list_tunnelstunnels:readTunnels running now, with public URLs
get_usageusage:readBandwidth, requests and quota this period
get_metricsusage:readLive traffic
get_accountaccount:readEmail, name and plan
list_tokenstokens:readToken names and prefixes, never secrets
list_invoicesbilling:readInvoices and payment status
list_domainsregistrar:readDomains bought through tunnels.io
list_notificationsnotifications:readIn-app notifications
list_cloud_appscloud:readServerless apps and their status
start_tunnel—Opens an HTTP or TCP tunnel and returns the public URL
stop_tunnel—Closes a tunnel it opened
Two notes. 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
Security warning: TCP tunnels expose raw access. Ensure your service has authentication (DB passwords, SSH keys) and use IP restrictions on Gold/Premium.

Protocol availability by plan

ProtocolBasicStudentProGoldPremium
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 600 config 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
If a token is compromised: revoke it immediately from Account > API Tokens or DELETE /api/v1/tokens/{id}. Active tunnels are terminated instantly. Then create a new one.

Plans & limits

Plan comparison

FeatureBasicStudentProGoldPremium
ProtocolsHTTP/HTTPSHTTP/HTTPS+ TCP+ TCP+ TCP
SubdomainRandomRandomStaticCustom domainCustom domain
SupportCommunityForumEmailPriority emailPriority + 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.
Need more? Upgrade anytime from the Pricing page. Changes take effect immediately and are prorated.

Troubleshooting

"Connection refused" or "502 Bad Gateway"
Cause: the tunnel is up but your local service isn't listening on that address/port. Fix: verify with curl http://localhost:8000; match the port; try 127.0.0.1:8000 if your service binds there specifically.
"Authentication failed" / "Invalid auth token"
Cause: token invalid, revoked, or account unverified. Fix: confirm it starts with tnl_ and isn't truncated; check it isn't revoked; verify your email; create a new token.
"Tunnel limit exceeded"
Cause: at your plan's concurrent-tunnel cap. Fix: close unused tunnels (Ctrl+C); orphaned ones auto-clean within minutes; upgrade for more.
macOS: "developer cannot be verified"
The macOS binary is Developer ID signed and notarized, so this should not happen on a fresh download. It can occur if the file was moved, rewritten or unpacked by a tool that dropped the signature. Fix: re-download it, or clear the quarantine flag with xattr -d com.apple.quarantine ./tunnels
Firewall / corporate proxy blocking connection
Fix: allow outbound TCP to tunnels.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.
Slow tunnel performance
Fix: throughput can't exceed your upload bandwidth; check for 429s in the inspector; disable inspection for high throughput (inspect_addr: disabled); a slow backend looks like a slow tunnel.
Inspector is on a different port, or shows another tunnel's traffic
Each running client needs its own inspector port. When you start a second client, it takes the next free port after 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:8000

Deployment

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

  1. Each developer has a personal account + token.
  2. CI/CD uses a dedicated service-account token stored as a repo secret.
  3. Subdomain convention: {project}-{developer} (e.g. api-jane).
  4. Shared staging: a team service account for always-on tunnels.
Pro tip: run tunnels config add-authtoken TOKEN once. It saves the token to ~/.tunnels so every later command picks it up automatically.