# Connect to local services
URL: /docs/ios/local-services
LLM index: /llms.txt
Description: Route an iOS app's connections to a mock backend, dev server, or VPN-only API on your machine.

# Connect an iOS simulator to local services

A destination tunnel sends the connections you declare from the simulator through the machine that runs the tunnel, so the app keeps using the addresses it already has, such as `localhost:8000` or `api.corp.example`. This page covers iOS simulators; for Android emulators, see [Connect an Android emulator to local services](/docs/android/local-services), which reuses the selector and inspection rules described here.

<Note>
  This page covers traffic from the device to your service. Reaching a server that runs *inside* the device from outside, such as WebDriverAgent, is the opposite direction and uses `targetHttpPortUrlPrefix`. See [Appium](/docs/testing/appium) for how that works.
</Note>

## Start a tunnel from an iOS simulator

Declare each destination the app needs as a `--selector`, and run the tunnel in the background with `--detach`:

```bash
lim ios tunnel \
  --selector localhost:8000 \
  --selector 10.20.30.40:443 \
  --selector "*.staging.example" \
  --detach \
  --id <instance-id>
```

The app keeps using the literal `localhost:8000`, and the tunnel connects it to `localhost:8000` on the machine running the command. Without `--detach`, the tunnel runs until you stop the command. `--verbose` logs every forwarded connection and dial failure; with `--detach` those lines go to the tunnel log file that `tunnel status` points to.

Query or stop the tunnel from another process:

```bash
lim ios tunnel status --id <instance-id> --json
lim ios tunnel stop --id <instance-id>
```

From the TypeScript SDK, start the tunnel on a device client. The caller owns the returned tunnel; disconnecting the device client does not close it.

```ts
const tunnel = await client.startTunnel({
  selectors: ['localhost:8000', '10.20.30.40:443', 'api.corp.example', '*.staging.example'],
});

const status = await client.getTunnelStatus();
await client.stopTunnel(tunnel.tunnelId);
```

The Python and Go SDKs do not start destination tunnels. Raw HTTP cannot start one either: it takes the CLI, the TypeScript SDK, or an implementation of the tunnel's multiplexed WebSocket protocol.

## Choose selectors

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 your machine whether or not the name resolves on public DNS, so your 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.

## Limits and behavior

- A tunnel carries TCP only. CIDRs and UDP are not supported.
- One tunnel accepts up to ten exact `host:port` selectors and up to 64 domain selectors.
- Ports 1 to 65535 are accepted except port 53.
- One instance has one active destination tunnel, and its selector list cannot change. To add or remove a destination, stop the tunnel and start it again with the complete list.
- A WebSocket loss closes the tunnel's connections. Start a new tunnel to recover.
- An open idle tunnel does not count as instance activity and does not prevent the [inactivity timeout](/docs/concepts#lifecycle-and-timeouts).
- A tunnel captures connections the app opens after the tunnel is ready. An app that reached a destination earlier keeps its open connections and can reuse them outside the tunnel, so start the tunnel before you launch the app or open the page, or relaunch the app once the tunnel is ready.
- If the app connects to a selector while its local service is down, only that connection fails; the tunnel and its other selectors stay up. JSON status records the failure as `lastDialFailure`. Restart the service at the same destination and the existing tunnel carries later connections.

Clients on `@limrun/api` 0.48 or older (`lim` before 0.31) speak an earlier tunnel protocol that current instances reject with `invalid_message`. Upgrade to use tunnels.

## Inspect tunnel traffic

HTTP and HTTPS through a tunnel are inspected by default. Each completed request is printed as one summary line (in the tunnel log file when detached), and requests also stream live to the console's network panel. These flags control inspection on `lim ios tunnel`, and `lim android tunnel` takes the same flags:

| Flag | What it does |
|---|---|
| `--[no-]inspect` | Print one HTTP summary per completed request. On by default; `--no-inspect` turns inspection off. |
| `--har <path>` | Write inspected traffic, with bodies, as HAR 1.2 at this path. |
| `--har-body-limit <bytes>` | Maximum captured bytes per request or response body. Defaults to `10485760`. |
| `--persist` | Upload a network log, including bodies, as a session artifact when the tunnel stops or the instance terminates. |
| `--ttl <seconds>` | Lifetime of the persisted network log. Defaults to `259200` (3 days), maximum `2592000` (30 days). Requires `--persist`. |

This captures a HAR file and keeps a network log for seven days:

```bash
lim ios tunnel --selector "*.api.example" --har ./traffic.har --persist --ttl 604800 --id <instance-id>
```

HTTPS is decoded with a certificate authority the device trusts, so apps that pin certificates fail through inspected selectors. Leave the pinned host out of the selectors, or pass `--no-inspect` to keep TLS end to end. From the TypeScript SDK, pass `inspection: { enabled: false }` to `startTunnel` for the same effect.

## Connect Expo and Metro

Metro can listen on its usual local port while Expo advertises `localhost`. Start the tunnel for Metro's port, then start Metro with `EXPO_PACKAGER_PROXY_URL` pointing at the same address:

```bash
METRO_PORT=8081
lim ios tunnel \
  --selector "localhost:${METRO_PORT}" \
  --detach \
  --id <instance-id>
TUNNEL_URL="http://localhost:${METRO_PORT}"
echo "TUNNEL_URL=$TUNNEL_URL"

EXPO_PACKAGER_PROXY_URL="$TUNNEL_URL" \
  npx expo start --dev-client --port "$METRO_PORT"
```

`EXPO_PACKAGER_PROXY_URL` makes Expo use `localhost` and the declared port in the manifest, bundle URLs, and deep links. Set it inline so it takes precedence over project dotenv values. Keep this shell open for Metro, and copy the printed `TUNNEL_URL` into a second terminal for the launch command. If port 8081 is taken, pick another port and use the same value in the selector, `TUNNEL_URL`, and Expo's `--port`.

Open your development build with that URL:

```bash
SCHEME="<scheme from app.json, or exp+<slug>>"
ENCODED_URL="$(node -e 'console.log(encodeURIComponent(process.argv[1]))' "$TUNNEL_URL")"
lim ios open-url \
  --id <instance-id> \
  "${SCHEME}://expo-development-client/?url=${ENCODED_URL}"
```

For Expo Go, replace `--dev-client` with `--go` in the Metro command and open:

```bash
lim ios open-url \
  --id <instance-id> \
  "exp://${TUNNEL_URL#http://}"
```

## Use the legacy fixed-port reverse tunnel

`lim ios reverse` remains for existing workflows that already pick a simulator-facing port in the reserved range 57090 to 57099. New workflows should use the destination tunnel above.

```bash
lim ios reverse 57090:57090            # expose local :57090 as simulator :57090
lim ios reverse 57091 --local-host 0.0.0.0
```

The mapping is `<remotePort>` or `<remotePort>:<localPort>`. `--local-host` defaults to `127.0.0.1`; non-loopback hosts are meant for debugging. On start, the CLI prints the address the app must connect to. It is an internal listener IP near the simulator, not `127.0.0.1`.

From the TypeScript SDK:

```ts
const tunnel = await client.startReverseTunnel({
  remotePort: 57090,           // must be in 57090..57099
  localPort: 57090,            // defaults to remotePort
  localHost: '127.0.0.1',      // defaults to 127.0.0.1
});
// The app reaches your service at tunnel.remoteAddress, e.g.
// `${tunnel.remoteAddress.address}:${tunnel.remoteAddress.port}`.
// tunnel.close() to tear it down.
```

The reverse tunnel has these constraints:

- **Client-first services only.** The app in the simulator opens the connection. Server-first protocols, where the server speaks first, are not supported.
- **No automatic reconnect.** The tunnel fails closed: if the WebSocket drops, the listener and its connections close. Restart the tunnel to recover.
- **Idle tunnels don't keep the instance alive.** Opening a tunnel counts as activity once; after that, only traffic through it does. A quiet tunnel lets the inactivity timeout fire as usual, so agents that leave tunnels behind stop accruing instance time.
- **Vanished clients lose their port.** If the tunnel client stops responding without closing (a suspended VM, a frozen process), the server closes the session and frees the remote port within about two minutes. A port held by a live tunnel stays reserved, and a second tunnel on it fails with `already in use`.
- **TypeScript SDK and CLI only.** The Go and Python SDKs do not have it.
- **iOS only.** On Android, use a [destination tunnel](/docs/android/local-services) or `adb reverse` over the [ADB tunnel](/docs/android/run-emulator#open-an-adb-tunnel).

## Troubleshooting

| Symptom or message | Cause | Fix |
|---|---|---|
| Tunnel start fails with `invalid_message` | The client is `@limrun/api` 0.48 or older, or `lim` before 0.31. | Upgrade the CLI or SDK. |
| One connection fails while others work | The local service for that selector is down. JSON status shows it as `lastDialFailure`. | Restart the service at the same destination; the tunnel carries later connections. |
| A domain selector has no effect | The app resolves DNS over HTTPS, or the wildcard does not cover the bare domain. | Declare the resolved IP as `host:port`, or add the exact domain next to the wildcard. |
| You need another destination | The selector list of a running tunnel cannot change. | Stop the tunnel and start it again with the complete list. |
| All tunnel connections dropped | The tunnel's WebSocket closed. | Start a new tunnel. |
| `lim ios reverse` fails with `already in use` | A live reverse tunnel holds that port. | Pick another port from 57090 to 57099, or stop the other tunnel. |
| The app ignores the tunnel | The app opened its connections before the tunnel was ready. | Start the tunnel first, then relaunch the app. |
| An app with certificate pinning fails through the tunnel | Inspection decodes HTTPS with a certificate the pinning app rejects. | Leave that host out of the selectors, or pass `--no-inspect`. |

## Next steps

<Columns cols={2}>
  <Card title="Run a simulator" icon="smartphone" href="/docs/ios/run-simulator">
    Drive the app once it reaches your service: taps, typing, logs, recordings.
  </Card>
  <Card title="Android local services" icon="network" href="/docs/android/local-services">
    The same destination tunnel for Android emulators.
  </Card>
  <Card title="Appium" icon="flask-conical" href="/docs/testing/appium">
    The opposite direction: reach WebDriverAgent inside the simulator through `targetHttpPortUrlPrefix`.
  </Card>
</Columns>