> ## Documentation Index
> Fetch the complete documentation index at: https://docs.runloop.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Open a Tunnel to a Service on a devbox

> Create a tunnel to access ports on your devbox

<Tip>
  Start with the [Runloop Quickstart](/docs/tutorials/quickstart) to use the
  examples below.
</Tip>

When developing software on your devbox, you will often want to expose local services running on your devbox to the outside world.
For example, you may want to have your agent start a local web server to serve a frontend application and then expose the live frontend to your users.
Other examples include:

* remotely collaborating on a frontend project
* testing a web service
* accessing a Jupyter notebook running on your devbox
* accessing a local database running on your devbox

Let's use devbox tunnels to securely access ports on your devbox over a simple url.

## Supported Protocols

devbox tunnels support multiple protocols, making them suitable for a wide variety of applications:

* **HTTP/HTTPS**: Standard web traffic for REST APIs, web applications, and static content
* **WebSockets**: Real-time bidirectional communication for chat applications, live updates, and interactive features
* **Server-Sent Events (SSE)**: One-way real-time communication from server to client for live data streams and notifications

<Warning>
  You will need to explicitly specify the hostname `0.0.0.0` within your service
  to expose ports to the outside world. Using other IP addresses or localhost is
  incompatible with tunnels.
</Warning>

## Setting up a tunnel

There are two ways to set up a tunnel: at devbox creation time, or after the devbox is running.

### Option 1: Enable tunnel at devbox creation

The simplest approach is to enable the tunnel when creating the devbox. The tunnel will be automatically provisioned and available when the devbox is ready.

<CodeGroup>
  ```python Python theme={null}
  devbox = await runloop.devbox.create(
      tunnel={"auth_mode": "open"},  # or "authenticated"
      entrypoint="python3 -m http.server 8080 --bind 0.0.0.0"
  )

  # Access the tunnel URL
  tunnel_url = await devbox.get_tunnel_url(8080)
  print(f"Tunnel URL for port 8080: {tunnel_url}")
  ```

  ```typescript TypeScript theme={null}
  const devbox = await runloop.devbox.create({
    tunnel: { auth_mode: "open" }, // or "authenticated"
    entrypoint: "python3 -m http.server 8080 --bind 0.0.0.0",
  });

  // Access the tunnel URL
  const tunnelUrl = await devbox.getTunnelUrl(8080);
  console.log(`Tunnel URL for port 8080: ${tunnelUrl}`);
  ```
</CodeGroup>

### Option 2: Enable tunnel on a running devbox

You can also enable a tunnel on an existing running devbox using the `enable_tunnel` method.

<Steps>
  <Step title="Create a devbox">
    Create a devbox and start a service on it.

    <CodeGroup>
      ```python Python theme={null}
      devbox = await runloop.devbox.create(
          entrypoint="python3 -m http.server 8080 --bind 0.0.0.0"
      )
      ```

      ```typescript TypeScript theme={null}
      const devbox = await runloop.devbox.create({
        entrypoint: "python3 -m http.server 8080 --bind 0.0.0.0",
      });
      ```
    </CodeGroup>
  </Step>

  <Step title="Enable the tunnel">
    Enable a tunnel on the running devbox.

    <CodeGroup>
      ```python Python theme={null}
      tunnel = await devbox.net.enable_tunnel(auth_mode="open")  # or "authenticated"

      url = await devbox.get_tunnel_url(8080)
      print(f"Access your service at: {url}")
      ```

      ```typescript TypeScript theme={null}
      const tunnel = await devbox.net.enableTunnel({ auth_mode: "open" }); // or "authenticated"

      const url = await devbox.getTunnelUrl(8080);
      console.log(`Access your service at: ${url}`);
      ```
    </CodeGroup>
  </Step>
</Steps>

## Tunnel URL Format

Tunnel URLs follow this format:

```
https://{port}-{tunnel_key}.tunnel.runloop.ai
```

Where:

* `{port}` is the port number your service is running on (e.g., 8080, 3000)
* `{tunnel_key}` is the encrypted key returned when you enable the tunnel

For example, if your tunnel key is `abc123xyz` and your service runs on port 3000:

```
https://3000-abc123xyz.tunnel.runloop.ai
```

You can access any port on your devbox by changing the port number in the URL, as long as your service is bound to `0.0.0.0`.

## Authentication Modes

Tunnels support two authentication modes:

### Open Mode (Public Access)

With `auth_mode: "open"`, anyone with the URL can access your tunnel. This is useful for:

* Sharing live previews with collaborators
* Testing webhooks from external services
* Public demos

<CodeGroup>
  ```python Python theme={null}
  tunnel = await devbox.net.enable_tunnel(auth_mode="open")
  ```

  ```typescript TypeScript theme={null}
  const tunnel = await devbox.net.enableTunnel({ auth_mode: "open" });
  ```
</CodeGroup>

### Authenticated Mode (Restricted Access)

With `auth_mode: "authenticated"`, requests must include a bearer token. This is useful for:

* Sensitive development environments
* APIs that should not be publicly accessible
* Secure internal tools

<CodeGroup>
  ```python Python theme={null}
  tunnel = await devbox.net.enable_tunnel(auth_mode="authenticated")

  print(f"Auth token: {tunnel.auth_token}")
  url = await devbox.get_tunnel_url(8080)
  print(f"URL: {url}")
  ```

  ```typescript TypeScript theme={null}
  const tunnel = await devbox.net.enableTunnel({ auth_mode: "authenticated" });

  console.log(`Auth token: ${tunnel.auth_token}`);
  const url = await devbox.getTunnelUrl(8080);
  console.log(`URL: ${url}`);
  ```
</CodeGroup>

To access an authenticated tunnel, include the token in your requests:

```bash theme={null}
curl -H "Authorization: Bearer <auth_token>" \
  https://8080-<tunnel_key>.tunnel.runloop.ai
```

<Warning>
  While the devbox is active and the tunnel is enabled, the URL has remote
  access to all of your devbox ports. Treat tunnel URLs with the same care you
  would treat any exposed endpoint.
</Warning>

## Tunnel Lifecycle

* **One tunnel per devbox**: Each devbox can have one tunnel enabled at a time.
* **Persistent until shutdown**: Once enabled, tunnels remain active until the devbox is shut down.
* **Survives suspend/resume**: If you suspend and resume a devbox, the tunnel information is preserved (you'll need to re-enable the tunnel after resume if needed).
* **Multiple ports**: A single tunnel allows access to any port on your devbox - just change the port number in the URL.

### Wake on HTTP

With `wake_on_http` enabled, HTTP traffic to the tunnel URL automatically resumes a suspended devbox. This lets you suspend devboxes when idle and only pay for compute when requests arrive.

When a request hits a suspended devbox:

1. The tunnel returns `503 Service Unavailable` with a `Retry-After: 5` header
2. The devbox resumes (typically under a second of infrastructure overhead)
3. The caller retries and the request is proxied to your running service

Webhook providers like GitHub, Stripe, and Slack automatically retry on `503` responses, so wake-on-HTTP works out of the box for webhook endpoints. For browsers, the tunnel returns an HTML page that auto-refreshes.

<CodeGroup>
  ```python Python theme={null}
  devbox = await runloop.devbox.create(
      entrypoint="python3 server.py",  # Restarts automatically on resume
      tunnel={"auth_mode": "open", "wake_on_http": True},
  )

  tunnel = (await devbox.get_info()).tunnel
  print(f"Tunnel URL: https://8080-{tunnel.tunnel_key}.tunnel.runloop.ai")
  ```

  ```typescript TypeScript theme={null}
  const devbox = await runloop.devbox.create({
    entrypoint: "python3 server.py", // Restarts automatically on resume
    tunnel: { auth_mode: "open", wake_on_http: true },
  });

  const tunnel = (await devbox.getInfo()).tunnel;
  console.log(`Tunnel URL: https://8080-${tunnel?.tunnelKey}.tunnel.runloop.ai`);
  ```
</CodeGroup>

<Tip>
  When running agents behind wake-on-HTTP webhooks, use [Agent Gateways](/docs/devboxes/agent-gateways) to protect your API credentials. Gateways prevent credential exfiltration by keeping secrets on Runloop's servers — agents only see temporary gateway tokens.
</Tip>

For a fully automatic sleep/wake cycle, combine `wake_on_http` with an idle timeout:

<CodeGroup>
  ```python Python theme={null}
  devbox = await runloop.devbox.create(
      entrypoint="python3 server.py",
      tunnel={"auth_mode": "open", "wake_on_http": True},
      launch_parameters={
          "after_idle": {"idle_time_seconds": 300, "on_idle": "suspend"}
      },
  )
  ```

  ```typescript TypeScript theme={null}
  const devbox = await runloop.devbox.create({
    entrypoint: "python3 server.py",
    tunnel: { auth_mode: "open", wake_on_http: true },
    launch_parameters: {
      after_idle: { idle_time_seconds: 300, on_idle: "suspend" },
    },
  });
  ```
</CodeGroup>

With this configuration, the devbox suspends after 5 minutes of inactivity and wakes automatically when HTTP traffic arrives. HTTP traffic through the tunnel counts as activity (via `http_keep_alive`, enabled by default), so the devbox stays awake while requests are flowing.

## Example: Complete Tunnel Workflow

Here's a complete example showing how to create a devbox, start a web server, and access it via a tunnel:

<CodeGroup>
  ```python Python theme={null}
  import asyncio
  from runloop_api_client import AsyncRunloopSDK

  runloop = AsyncRunloopSDK()

  async def main():
      # Create devbox with tunnel enabled at launch
      devbox = await runloop.devbox.create(
          tunnel={"auth_mode": "open"},
          entrypoint="python3 -m http.server 8080 --bind 0.0.0.0"
      )

      # Get the tunnel URL
      tunnel_url = await devbox.get_tunnel_url(8080)
      print(f"Your web server is accessible at: {tunnel_url}")

      # Keep running until user interrupts
      try:
          print("Press Ctrl+C to shutdown...")
          await asyncio.sleep(float('inf'))
      except KeyboardInterrupt:
          pass
      finally:
          await devbox.shutdown()
          print("devbox shut down")

  asyncio.run(main())
  ```

  ```typescript TypeScript theme={null}
  import { RunloopSDK } from "@runloop/api-client";

  const runloop = new RunloopSDK();

  async function main() {
    // Create devbox with tunnel enabled at launch
    const devbox = await runloop.devbox.create({
      tunnel: { auth_mode: "open" },
      entrypoint: "python3 -m http.server 8080 --bind 0.0.0.0",
    });

    // Get the tunnel URL
    const tunnelUrl = await devbox.getTunnelUrl(8080);
    console.log(`Your web server is accessible at: ${tunnelUrl}`);

    // Wait for user to press Enter
    console.log("Press Enter to shutdown...");
    await new Promise((resolve) => process.stdin.once("data", resolve));

    await devbox.shutdown();
    console.log("devbox shut down");
  }

  main();
  ```
</CodeGroup>

## Advanced: Header Handling for Proxies

If you are placing another proxy in front of a Runloop tunnel, it is useful to understand how the tunnel resolves the target host and the exact behavior for headers forwarded upstream.

For routing, the tunnel backend resolves the host in this order:

1. `X-Runloop-Host` (requires an [authenticated tunnel](#authenticated-mode-restricted-access))
2. `Host`

The proxy consumes the first value it finds and uses that value for tunnel routing. Standard proxy headers like `Forwarded` and `X-Forwarded-Host` are not used for routing — they are forwarded to the backend as-is and remain available for your application.

### Forwarded Headers

For plain HTTP proxying, Runloop forwards request headers upstream except for hop-by-hop headers such as:

* `Connection`
* `Transfer-Encoding`
* `TE`
* `Trailer`
* `Proxy-Authorization`
* `Proxy-Authenticate`
* `Keep-Alive`

For WebSocket connections, Runloop forwards the request headers as-is.

Before proxying, Runloop also adds or updates a small set of headers:

* `x-runloop-request-id` is added or replaced
* `x-forwarded-for` is added
* `x-real-ip` is added
* `x-forwarded-proto` is added

### Transparent Proxy Routing

The `X-Runloop-Host` header lets a fronting proxy override which tunnel hostname is used for routing, without rewriting the connection or `Host` header. This is useful when you want to serve tunnels behind a branded domain — your proxy sets `X-Runloop-Host` for routing while preserving the customer-facing `Host` for your application.

For example, if your proxy at `preview.customer.com` needs to route to a Runloop tunnel:

```bash theme={null}
curl https://preview.customer.com \
  -H 'X-Runloop-Host: 8080-<tunnel_key>.tunnel.runloop.ai' \
  -H 'Authorization: Bearer <auth_token>'
```

Runloop uses `X-Runloop-Host` to resolve the tunnel, strips it from the request, and proxies to the devbox. Your application sees `Host: preview.customer.com` with no trace of the Runloop tunnel hostname, so cookies, redirects, and framework host validation behave normally. This works for both HTTP and WebSocket traffic.

<Warning>
  `X-Runloop-Host` only works with authenticated tunnels and requires a valid
  `Authorization: Bearer <token>` header. Using it against an open tunnel or
  without a valid token returns 401.
</Warning>
