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

# Serve HTTP from a box and get a public URL

> Start an HTTP server inside a Cloud box, open a tunnel to its port, and use the public URL the tunnel gives you.

A box running a web app, a dev server, or an API is only useful once something outside can reach it. Ask the box for a tunnel to the port your server listens on, and the tunnel hands you a public URL.

## Prerequisites

* An API key from the console, exported as `BOXLITE_API_KEY`. See [API keys](/cloud/api-keys).
* `pip install boxlite`, and the REST URL exported as `BOXLITE_REST_URL`. See [Quickstart](/cloud/quickstart).
* A box you can start, and a service inside it that listens on a TCP port.

Every example reads both values from the environment, so nothing hard-codes a credential.

This script creates a box, starts an HTTP server inside it, prints the tunnel's public URL, makes a request over the tunnel, and removes the box in `finally`.

```python theme={"theme":{"light":"github-light","dark":"github-dark"}}
# cloud_tunnel_http.py — serve HTTP from a Cloud box and reach it through a tunnel
# Run: python cloud_tunnel_http.py
import asyncio
import os
import time

from boxlite import ApiKeyCredential, Boxlite, BoxliteRestOptions, BoxOptions

GUEST_PORT = 18080
IMAGE = "ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0"


async def http_get(tunnel, path: str = "/") -> bytes:
    """Send one HTTP request over one tunnel. connect() consumes the tunnel."""
    connection = await tunnel.connect()
    try:
        request = (
            f"GET {path} HTTP/1.1\r\n"
            "Host: localhost\r\n"
            "Connection: close\r\n\r\n"
        ).encode()
        await connection.write(request)

        response = bytearray()
        while True:
            chunk = await connection.read(64 * 1024)
            if not chunk:  # an empty read means the far side closed the stream
                break
            response.extend(chunk)
        return bytes(response)
    finally:
        await connection.close()


async def main() -> None:
    # export BOXLITE_API_KEY=<YOUR_API_KEY> before running
    rt = Boxlite.rest(BoxliteRestOptions(
        url=os.environ.get("BOXLITE_REST_URL", "https://api.boxlite.ai"),
        credential=ApiKeyCredential(os.environ["BOXLITE_API_KEY"]),
    ))

    box_id = None
    try:
        box = await rt.create(
            BoxOptions(image=IMAGE),
            name=f"tunnel-http-{int(time.time())}",
        )
        box_id = box.id
        await box.start()

        # Bind 0.0.0.0: the tunnel arrives on the box's network interface,
        # so a server on 127.0.0.1 inside the box cannot be reached.
        launch = await box.exec("sh", args=[
            "-c",
            f"nohup python3 -m http.server {GUEST_PORT} --bind 0.0.0.0 "
            "> /tmp/server.log 2>&1 & echo launched",
        ])
        await launch.wait()

        # uri() is synchronous and does not consume the tunnel.
        tunnel = await box.network.tunnel(GUEST_PORT)
        print(f"tunnel URL: {tunnel.uri()}")

        # The server needs a moment. Each attempt needs its own tunnel.
        response = b""
        for attempt in range(20):
            try:
                response = await http_get(tunnel)
                if response.startswith(b"HTTP/1."):
                    break
            except Exception as exc:
                print(f"attempt {attempt + 1}: {type(exc).__name__}: {exc}")
            await asyncio.sleep(1)
            tunnel = await box.network.tunnel(GUEST_PORT)  # the previous one is spent

        if response.startswith(b"HTTP/1."):
            print(response.split(b"\r\n", 1)[0].decode())
        else:
            log = await box.exec("cat", args=["/tmp/server.log"])
            text = ""
            async for line in log.stdout():
                text += line
            await log.wait()
            print(f"no HTTP response. server log:\n{text}")
    except Exception as exc:
        print(f"tunnel demo failed: {type(exc).__name__}: {exc}")
    finally:
        # Teardown on the runtime, in finally, so a failure above cannot leave a box running.
        if box_id:
            try:
                await rt.remove(box_id, force=True)
            except Exception as exc:
                print(f"remove failed: {exc}")


if __name__ == "__main__":
    asyncio.run(main())
```

Expected output:

```text theme={"theme":{"light":"github-light","dark":"github-dark"}}
tunnel URL: https://...
HTTP/1.0 200 OK
```

`uri()` returns the public URL of a tunnel served remotely, which is what a Cloud tunnel is. On a local runtime the same call returns `None`, because a local tunnel is already a live connection with no address to publish. Reading `uri()` leaves the tunnel usable — only `connect()` and `forward()` spend it.

The snippet starts the server with `python3`. If the server log says `python3: not found`, install an interpreter into the box first, or start a service the image already ships.

## Next steps

<CardGroup cols={2}>
  <Card title="Network" icon="network-wired" href="/cloud/network">
    How tunnels work, the full parameter reference, and the outbound boundary.
  </Card>

  <Card title="Network policy" icon="shield-halved" href="/cloud/network-policy">
    Control what the box itself is allowed to reach.
  </Card>
</CardGroup>
