# Preview URL reference

> Hostname pattern s-{ulid}-{port}.preview.{PREVIEW_DOMAIN}, HTTP_PORT suffix rules, localhost vs production HTTPS, and Traefik router/service naming.

- Repository: tastyeffectco/sandboxes
- GitHub: https://github.com/tastyeffectco/sandboxes
- Human docs: https://grok-wiki.com/public/docs/tastyeffectco-sandboxes-f551c1a2e9a0
- Complete Markdown: https://grok-wiki.com/public/docs/tastyeffectco-sandboxes-f551c1a2e9a0/llms-full.txt

## Source Files

- `control-plane/internal/traefik/traefik.go`
- `traefik/dynamic/wake.yml`
- `.env.example`
- `control-plane/internal/api/v1.go`
- `README.md`

---

---
title: "Preview URL reference"
description: "Hostname pattern s-{ulid}-{port}.preview.{PREVIEW_DOMAIN}, HTTP_PORT suffix rules, localhost vs production HTTPS, and Traefik router/service naming."
---

Each sandbox with exposed ports gets a deterministic preview hostname `s-{id}-{port}.preview.{PREVIEW_DOMAIN}`; Traefik registers one router and one service per port using the same name `s-{id}-{port}`, and traffic reaches the process listening on that port inside the container `s-{id}`.

## Hostname pattern

| Segment | Value | Notes |
|---|---|---|
| Prefix | `s-` | Fixed; matches container name `s-{id}` |
| Sandbox id | ULID | Auto-generated when `id` is omitted on create; custom `id` must pass ULID validation |
| Port | `1`–`65535` | Must be listed in `ports` at create; one hostname per exposed port |
| Preview segment | `.preview.` | Fixed literal between id-port and domain |
| Domain | `PREVIEW_DOMAIN` | Default `localhost`; set to your apex domain in production |

**Canonical hostname (no scheme, no Traefik host port):**

```text
s-{id}-{port}.preview.{PREVIEW_DOMAIN}
```

Examples with defaults (`PREVIEW_DOMAIN=localhost`, port `3000`):

```text
s-01ARZ3NDEKTSV4RRFFQ69G5FAV-3000.preview.localhost
```

<Note>
The port in the hostname is the **application port inside the sandbox** (from `POST /sandbox` `ports`), not `HTTP_PORT`. `HTTP_PORT` is only how clients reach Traefik on the host.
</Note>

## Full URL shape

| Mode | Scheme | Host | Host port suffix |
|---|---|---|---|
| Local OSS default | `http` | `s-{id}-{port}.preview.localhost` | Append `:HTTP_PORT` when `HTTP_PORT` ≠ `80` |
| Production TLS | `https` | `s-{id}-{port}.preview.{yourdomain.com}` | Usually omitted (`443`) after enabling `websecure` |

**Local (default `.env`):**

```text
http://s-{id}-{port}.preview.localhost
http://s-{id}-{port}.preview.localhost:8088    # when HTTP_PORT=8088
```

**Production (README “Production / TLS”):**

```text
https://s-{id}-{port}.preview.yourdomain.com
```

Browsers resolve `*.localhost` to `127.0.0.1`, so local previews need no DNS or certificates. A public deployment requires wildcard DNS for `*.preview.{PREVIEW_DOMAIN}`, `PREVIEW_ENTRYPOINT=websecure`, `PREVIEW_TLS=true`, and a wildcard cert in Traefik’s default TLS store (no per-host ACME on each sandbox router).

## Configuration keys

<ParamField body="PREVIEW_DOMAIN" type="string" default="localhost">
Apex domain preview hostnames hang off. Wake/auth regexes validate this exact domain; the file-provider wake catch-all uses a domain-agnostic `HostRegexp` so you do not edit `wake.yml` when changing domain.
</ParamField>

<ParamField body="HTTP_PORT" type="integer" default="80">
Host port published for Traefik’s `web` entrypoint (`${HTTP_PORT}:80` in compose). Omit `:port` in browser URLs when `80`; otherwise append `:${HTTP_PORT}` to the authority (install summary uses the same rule).
</ParamField>

<ParamField body="PREVIEW_ENTRYPOINT" type="string" default="web">
Traefik entrypoint name on per-sandbox Docker labels (`traefik.http.routers.{name}.entrypoints`). Use `websecure` with TLS deployments.
</ParamField>

<ParamField body="PREVIEW_TLS" type="boolean" default="false">
When `true`, sandboxd emits `traefik.http.routers.{name}.tls=true` on each preview router. Routers do not set a per-router cert resolver; Traefik serves `*.preview.{domain}` from the shared default TLS store.
</ParamField>

## Traefik router and service naming

For each entry in `ports` at create, `traefik.Labels` in `control-plane/internal/traefik/traefik.go` emits matching **router** and **service** names:

```text
Router name:   s-{id}-{port}
Service name:  s-{id}-{port}   (same string)
Host rule:     Host(`s-{id}-{port}.preview.{PREVIEW_DOMAIN}`)
Priority:      100
Backend port:  traefik.http.services.s-{id}-{port}.loadbalancer.server.port={port}
```

Shared labels on every routed sandbox:

| Label | Purpose |
|---|---|
| `traefik.enable=true` | Expose container to Traefik (with `exposedByDefault: false`) |
| `sandboxed.managed=true` | Docker provider constraint — only routes stack-owned sandboxes |

**Per-port label set (HTTP, public):**

```text
traefik.http.routers.s-{id}-{port}.rule=Host(`s-{id}-{port}.preview.{domain}`)
traefik.http.routers.s-{id}-{port}.entrypoints={PREVIEW_ENTRYPOINT}
traefik.http.routers.s-{id}-{port}.priority=100
traefik.http.services.s-{id}-{port}.loadbalancer.server.port={port}
```

With `PREVIEW_TLS=true`, add `traefik.http.routers.s-{id}-{port}.tls=true`. With `visibility=private`, add `traefik.http.routers.s-{id}-{port}.middlewares=sandbox-preview-auth@file`.

<Warning>
If `ports` is empty or omitted, `Labels` returns `nil` — the sandbox has **no** preview routes until recreated with ports.
</Warning>

## Routing priority and wake catch-all

```mermaid
flowchart LR
  subgraph client [Client]
    B[Browser / curl]
  end
  subgraph edge [Traefik]
    R100["Docker router s-id-port priority 100"]
    R1["File router sandbox-wake priority 1"]
  end
  subgraph backends [Backends]
    SB["Sandbox container :port"]
    SD["sandboxd :9000 wake"]
  end
  B --> edge
  R100 -->|running + labels visible| SB
  R1 -->|stopped or no router| SD
  SD -->|docker start + warming page| B
  B -->|retry| R100
```

| Router | Source | Rule | Priority | Backend |
|---|---|---|---|---|
| `s-{id}-{port}` | Docker labels on `s-{id}` | `Host(\`s-{id}-{port}.preview.{domain}\`)` | `100` | Sandbox container port |
| `sandbox-wake` | `traefik/dynamic/wake.yml` | `HostRegexp(\`^s-[0-9A-Za-z]+-[0-9]+\\.preview\\..+$\`)` | `1` | `http://sandboxd:9000` |

A **running** sandbox’s label-backed router always wins. The catch-all fires when the container is **stopped** (or Traefik has not yet observed new labels right after wake); sandboxd starts the container and returns the warming HTML until the app listens.

The wake `HostRegexp` intentionally matches **any** preview domain suffix; sandboxd’s handler compiles a **domain-specific** regex anchored to `PREVIEW_DOMAIN` and normalizes the captured id to uppercase ULID for DB lookup (browsers lowercase `Host`).

## Related hostnames

| Hostname | Role |
|---|---|
| `api.preview.{PREVIEW_DOMAIN}` | Optional Traefik route to sandboxd API (`traefik/dynamic/api.yml`); loopback API remains `SANDBOXED_API_BIND` |
| `.preview.{PREVIEW_DOMAIN}` | Cookie `Domain` for private preview sessions (`sandbox_preview`) |

Private sandboxes use forward-auth middleware `sandbox-preview-auth@file` → `http://sandboxd:9000/forward-auth` (`traefik/dynamic/auth.yml`).

## Constructing URLs

**From create response:** use sandbox `id` and each port in `ports`.

**Formula:**

```text
{scheme}://s-{id}-{port}.preview.{PREVIEW_DOMAIN}{hostPortSuffix}
```

Where:

- `scheme` = `http` when `PREVIEW_TLS=false`; `https` when `PREVIEW_TLS=true`
- `hostPortSuffix` = empty if `HTTP_PORT` is `80`, else `:${HTTP_PORT}`

**v1 API `preview.url`:** `GET /v1/sandboxes/{id}` returns `preview.url` built as `https://s-{id}-3000.preview.{PREVIEW_DOMAIN}` — fixed port `3000` and **always `https`**, regardless of local HTTP settings. For local HTTP stacks, build the URL from env yourself; use the v1 field as a production-oriented hint when TLS and port 3000 match your template.

**Host parsing in sandboxd:** `parseSandboxIDFromHost` accepts an optional `:digits` suffix on the host (`s-{id}-{port}.preview.{domain}:8088`) for proxies that include the Traefik listen port in `Host`.

## Local verification without DNS

When Traefik listens on loopback, send the preview `Host` header explicitly:

<CodeGroup>
```bash title="Default HTTP_PORT 80"
curl -s -H "Host: s-{id}-3000.preview.localhost" \
  http://127.0.0.1:80/
```

```bash title="HTTP_PORT 8088"
curl -s -H "Host: s-{id}-3000.preview.localhost" \
  http://127.0.0.1:8088/
```
</CodeGroup>

Replace `{id}` with the sandbox ULID from create. First hit to a **stopped** sandbox returns the warming page; repeat after the container is running to reach the app.

## Multi-port sandboxes

`ports: [3000, 3001]` yields two independent URLs and two Traefik router/service pairs:

```text
s-{id}-3000.preview.{domain}
s-{id}-3001.preview.{domain}
```

Prometheus/activity code also recognizes Traefik internal names like `s-{id}-{port}@docker`.

## Quick reference

| Question | Answer |
|---|---|
| Container name? | `s-{id}` |
| Traefik router/service name? | `s-{id}-{port}` |
| Minimum create field for previews? | `"ports": [<port>]` |
| Local domain default? | `localhost` → `*.localhost` → 127.0.0.1 |
| When to add `:port` to URL? | When `HTTP_PORT` ≠ 80 |
| TLS on router? | `PREVIEW_TLS=true` + `PREVIEW_ENTRYPOINT=websecure` + wildcard cert |
| Stopped sandbox behavior? | Catch-all → sandboxd wake → warming page → retry hits priority-100 router |

## Related pages

<CardGroup>
<Card title="Preview routing" href="/preview-routing">
Traefik labels, entrypoints, TLS store, and the managed-container constraint in depth.
</Card>
<Card title="Wake, idle, and pressure" href="/wake-idle-reapers">
Catch-all wake path, idle stop, and warming-page behavior.
</Card>
<Card title="Configuration reference" href="/configuration-reference">
All compose-backed env keys including preview and HTTP_PORT.
</Card>
<Card title="Production deployment" href="/production-deployment">
Wildcard DNS, websecure, cert resolver, and auth hardening.
</Card>
<Card title="Private previews" href="/private-previews">
forwardAuth, preview tokens, and cookie domain `.preview.{domain}`.
</Card>
<Card title="Quickstart" href="/quickstart">
End-to-end create → task → open preview URL.
</Card>
</CardGroup>
