# Private tunnels
URL: /docs/networking/private-tunnels
LLM index: /llms.txt
Description: Give CI jobs and coding agents access to staging APIs and other services behind your VPN, without starting a tunnel per instance.

# Connect instances to your network with a private tunnel

A private tunnel connects instances to services in your private network. One connector on a machine in your network serves every iOS simulator and Android emulator that names the tunnel. CI jobs and coding agents create instances with `--tunnel <name>`, and [PR preview](/docs/ci/pr-previews) links name it with a `tunnel` parameter. The app inside reaches the destinations you declared, such as `localhost:3000` or `*.internal.example.com`, at the addresses it already uses. To route a single instance through the machine you are working on instead, start a destination tunnel; see [Connect an iOS simulator to local services](/docs/ios/local-services).

An organization admin sets up the tunnel once: create it, then keep its connector running. Organization members and admins can then name it when they create instances.

## Create a tunnel

Organization admins create tunnels in the console:

1. Open **Network** and select **New tunnel**.
2. Enter a **Name**. Instances refer to the tunnel by this name, and it cannot be changed later. Use lowercase letters, digits, and dashes, at most 63 characters, starting and ending with a letter or digit.
3. Under **Selectors**, enter the destinations instances reach through the connector, one per line; see [Choose selectors](#choose-selectors).
4. Choose when the token expires under **Token expires in**, then select **Create tunnel**.

The dialog then shows the connector command, `lim tunnel run --token <token>`. Copy it before you close the dialog: Limrun does not store the token, so this is the only time it is shown. Keep the dialog open while you start the connector; its status changes from **Waiting for the connector...** to **Connected**.

<AccordionGroup>

<Accordion title="Alternatively, create a tunnel from the CLI" id="create-from-the-cli">

From a terminal or a pipeline, create the tunnel with `lim tunnel create`:

```bash
lim tunnel create staging --selector localhost:3000 --selector "*.internal.example.com"
```

The command prints the connector command, `lim tunnel run --token <token>`, the only time the token is shown. `--expiration-months` sets the token's lifetime, from 1 to 60 months (default 12), and `--quiet` prints only the token, for example to store it as a CI secret. `lim tunnel list` shows each tunnel with its connector, selectors, and token expiry; add `--json` for tunnel IDs.

</Accordion>

</AccordionGroup>

## Choose selectors

Selectors are the destinations a tunnel carries. They take the same forms on private tunnels and on [destination tunnels](/docs/ios/local-services), and they resolve on the machine that runs the tunnel: the connector's machine for a private tunnel, or the machine running `lim ios tunnel` or `lim android tunnel` for a destination tunnel. `localhost:3000` reaches port 3000 on that machine.

A selector is either an exact TCP destination or a domain:

- **Exact `host:port`.** `localhost:8000` also captures the loopback aliases `127.0.0.1:8000`, `[::1]:8000`, and `[::ffff:127.0.0.1]:8000`. Literal IP selectors work the same way for non-loopback addresses. Write IPv6 in brackets, as in `[::1]:8000`.
- **Domain.** An exact name (`api.corp.example`) or a `*.` wildcard (`*.staging.example`). Domain selectors are intercepted on the instance and dialed from the machine that runs the tunnel whether or not the name resolves on public DNS, so that machine's DNS, `/etc/hosts`, and VPN apply. Wildcards are label-bound: `*.staging.example` covers `a.staging.example` but not `staging.example`. `localhost` and IP literals are not domains; declare them as `host:port`.

Apps that resolve DNS themselves over HTTPS (DoH) bypass domain interception. For those, declare the resolved IP instead.

Android emulators add two rules for exact selectors; see [Choose selectors on Android](/docs/android/local-services#choose-selectors).

## Run the connector

Run the connector on a machine that reaches every selector, such as a VM inside your network, a CI runner, or a laptop on your VPN. The `lim tunnel` commands need `lim` 0.43.0 or later, and `runTunnel` needs `@limrun/api` 0.62.0 or later. Keep the connector running for as long as instances need the tunnel:

<CodeGroup labels={["CLI","TypeScript"]}>
```bash
lim tunnel run --token <token>

# LIM_TUNNEL_TOKEN works in place of --token, for example from a CI secret
lim tunnel run
```

```ts
import { runTunnel } from '@limrun/api';

// The CLI reads both IDs from the token. The organization ID is under
// Settings > Organization in the console; `lim tunnel list --json` shows tunnel IDs.
const connector = runTunnel({
  token: process.env['LIM_TUNNEL_TOKEN']!,
  baseURL: 'https://api.limrun.com',
  organizationId: '<organization-id>',
  tunnelId: '<tunnel-id>',
  onEvent: (event) => console.log(event),
});

process.once('SIGINT', () => void connector.close());
await connector.closed;
```
</CodeGroup>

The CLI prints `Tunnel staging is active. Instances created with --tunnel staging attach here; press Ctrl+C to stop.`, then one line for each instance it attaches.

The connector works like this:

- **The token is its only credential.** The connector needs no API key, and an API key cannot run a connector. The token runs this one tunnel and nothing else.
- **One connector holds the tunnel at a time.** A second connector started with the same token, for example on another machine, waits on standby. It takes over within about a second after the active connector stops, or within about a minute if the active connector crashes or loses its network. `--replace` takes the tunnel over at once.
- **Reconnects keep instances attached.** If the connection to Limrun drops, the connector reconnects, and attached instances keep their tunnels meanwhile.
- **Traffic is inspected by default.** `--[no-]inspect`, `--persist`, `--ttl`, and `--har-body-limit` work as on a destination tunnel; see [Inspect tunnel traffic](/docs/ios/local-services#inspect-tunnel-traffic). The connector does not print requests: open an instance in the console to see them in its network panel. `--verbose` logs every forwarded connection and dial failure.

## Create instances that use the tunnel

Name the tunnel when you create an iOS simulator or an Android emulator:

<CodeGroup labels={["CLI","TypeScript","cURL"]}>
```bash
lim ios create --tunnel staging
lim android create --tunnel staging
```

```ts
import Limrun from '@limrun/api';

const lim = new Limrun({ apiKey: process.env['LIM_API_KEY'] });

const ios = await lim.iosInstances.create({ wait: true, spec: { tunnel: 'staging' } });
const android = await lim.androidInstances.create({ wait: true, spec: { tunnel: 'staging' } });
```

```bash
curl -X POST "https://api.limrun.com/v1/ios_instances?wait=true" \
  -H "Authorization: Bearer $LIM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "spec": { "tunnel": "staging" } }'

curl -X POST "https://api.limrun.com/v1/android_instances?wait=true" \
  -H "Authorization: Bearer $LIM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "spec": { "tunnel": "staging" } }'
```
</CodeGroup>

The Python and Go SDKs do not expose `tunnel`; send the REST request from those languages. Agents on the [remote MCP server](/docs/agents/remote-mcp) pass `tunnel` to `create-ios-simulator` and `create-android-emulator`.

Creation checks the tunnel first and fails when it does not exist or no connector holds it. The instance becomes ready, and its initial apps install, only after the tunnel attaches. If the tunnel does not attach within 30 seconds, creation fails and Limrun deletes the instance. Once the instance is ready, the app reaches each selector at the address you declared, as on a destination tunnel.

Android instances attach only when every exact selector uses port 1024 or higher. A lower port keeps every Android instance from attaching, while iOS instances are unaffected. The console warns about such ports, and the connector prints a `Notice` that Android instances refuse the tunnel.

## Change the selectors

In the console, hover over the tunnel's row, select the pencil button next to its selectors, edit them, and select **Save selectors**. From a terminal, pass the complete new list:

```bash
lim tunnel update staging --selector localhost:3000 --selector localhost:4000
```

Instances attached after the change get the new selectors; attached instances keep theirs. To apply the change to attached instances, restart the connector.

## Rotate the token

If the token may have leaked, rotate it now. Otherwise, rotate it before it expires. In the console, select **Rotate** in the tunnel's **Token** column, choose **Token expires in**, select **Rotate token**, and copy the new connector command. From a terminal:

```bash
lim tunnel rotate staging
```

The command prints the new connector command. The old token stops working at once, and connectors running with it exit within about 10 seconds. A tunnel has one token, so restart every connector, standbys included, with the new one.

From 14 days before the token expires, the connector logs a warning once a day and the console flags the token.

## Delete a tunnel

Delete a tunnel with the delete button on its row in the console, then confirm with **Delete tunnel**. From a terminal:

```bash
lim tunnel delete staging
```

Its connector exits, its token stops working, and instances that name it lose access to your network.

## Run a quick tunnel

To share a service for one session without creating a tunnel first, run a quick tunnel:

```bash
lim tunnel run --name scratch --selector localhost:3000
```

The command creates a tunnel named `scratch` with its own token, runs its connector, and deletes the tunnel when you stop the command. If the connector crashes, Limrun deletes the tunnel about 10 minutes after the connector stops reaching it. Instances name it with `--tunnel scratch` like any other tunnel, and the console marks it **Quick**.

From the API, create the tunnel with `"ephemeral": true` and a `token`, then run it with `runTunnel`; closing the connector deletes the tunnel.

## Troubleshooting

| Symptom or message | Cause | Fix |
|---|---|---|
| `tunnel <name> does not exist; create it in the console (Network) first` | No tunnel in your organization has that name. | Create the tunnel, or correct the name. |
| `tunnel <name> is offline` | No connector holds the tunnel. | Start the connector. A connector that started seconds ago can still show as offline; retry. |
| `tunnel <name> did not attach within 30s` | The connector did not attach the instance in time. | Check the connector's output for `Retrying` and `Could not attach` lines. On Android, move services on ports below 1024 to higher ports. |
| The connector exits with `its tunnel token was rotated, revoked or has expired`, or `the tunnel token was revoked, has expired or is not valid` | The token was rotated or revoked, or it expired. | Restart the connector with the current token. If you no longer have it, rotate the token. |
| `--token takes a tunnel token from the console (Network) or the API; this is not one.` | `--token` or `LIM_TUNNEL_TOKEN` holds another credential, such as an API key. | Pass the tunnel's token. |
| `Tunnel <name> runs with its own token (--token).` | `--name` names a tunnel created in the console or the API. | Run it with its token. |
| `Standby: <host> holds the tunnel` | Another connector holds the tunnel. | Wait for it to stop, or pass `--replace` to take over. |
| The connector exits with a message containing `was deleted` | The tunnel was deleted. | Create a new tunnel. |

## Next steps

<Columns cols={2}>
  <Card title="iOS local services" icon="network" href="/docs/ios/local-services">
    Selector rules, traffic inspection, and per-instance destination tunnels.
  </Card>
  <Card title="Android local services" icon="network" href="/docs/android/local-services">
    The Android port and address rules for tunnels.
  </Card>
</Columns>