# Preview routing

> Traefik Docker labels, Host rules (s-{id}-{port}.preview.{domain}), router priority 100 vs wake catch-all priority 1, PREVIEW_DOMAIN/ENTRYPOINT/TLS, and sandboxed.managed constraint.

- 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/traefik.yml`
- `traefik/dynamic/wake.yml`
- `traefik/dynamic/auth.yml`
- `docker-compose.yml`
- `.env.example`

---

---
title: "Preview routing"
description: "Traefik Docker labels, Host rules (s-{id}-{port}.preview.{domain}), router priority 100 vs wake catch-all priority 1, PREVIEW_DOMAIN/ENTRYPOINT/TLS, and sandboxed.managed constraint."
---

Traefik is the edge router: each sandbox container carries Docker labels that register one Host-matched router and service per exposed port, while a file-provider catch-all at priority 1 forwards stopped-sandbox preview traffic to `sandboxd` on the internal network. `sandboxd` builds the label set in `control-plane/internal/traefik` at create time and attaches it with `docker run --label`; Traefik’s docker provider only considers containers labelled `sandboxed.managed=true`.

## Preview hostname and router naming

Every exposed port gets a dedicated Traefik router and service whose names are identical: `s-{id}-{port}` (for example `s-01ARZ3NDEKTSV4RRFFQ69G5FAV-3000`). The Host rule targets the browser-facing hostname:

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

| Piece | Source | Example (defaults) |
| --- | --- | --- |
| Sandbox id | ULID from create (uppercase in DB) | `01ARZ3NDEKTSV4RRFFQ69G5FAV` |
| Port | `ports` array on `POST /sandbox` | `3000` |
| Domain | `PREVIEW_DOMAIN` on `sandboxd` | `localhost` |
| Full host | `Host(\`s-{id}-{port}.preview.{domain}\`)` | `s-01ARZ3NDEKTSV4RRFFQ69G5FAV-3000.preview.localhost` |

When `HTTP_PORT` is not `80`, append `:{HTTP_PORT}` to the URL (browsers still resolve `*.localhost` to `127.0.0.1`). The wake handler and forward-auth parsers accept an optional `:port` suffix on the Host header.

<Note>
If `ports` is empty or omitted at create, `traefik.Labels` returns `nil` and the sandbox has no Traefik exposure. Combined with `exposedByDefault: false`, the container is invisible to the edge until you recreate it with ports.
</Note>

## Docker provider: `sandboxed.managed` constraint

Static Traefik config scopes the docker provider so only stack-owned sandboxes become routes:

| Setting | Value | Effect |
| --- | --- | --- |
| `providers.docker.exposedByDefault` | `false` | Containers need `traefik.enable=true` |
| `providers.docker.constraints` | ``Label(`sandboxed.managed`,`true`)`` | Ignore unrelated labelled containers on the shared daemon |
| `providers.docker.network` | `sandboxed_net` (from `SANDBOXED_NETWORK`) | Backend targets use the compose network IP |

Every sandbox `docker run` emits:

```text
traefik.enable=true
sandboxed.managed=true
```

Sandboxes join `${SANDBOXED_NETWORK:-sandboxed_net}` so Traefik and the sandbox share L3 reachability on that network.

## Per-port label contract

`traefik.Labels(id, ports, domain, visibility, entrypoint, tls)` emits two base labels plus four lines per port (five with TLS, six for `visibility=private`):

| Label key pattern | Value | Role |
| --- | --- | --- |
| `traefik.http.routers.{router}.rule` | `Host(\`s-{id}-{port}.preview.{domain}\`)` | Match preview hostname |
| `traefik.http.routers.{router}.entrypoints` | `PREVIEW_ENTRYPOINT` (default `web`) | `:80` or `:443` entry |
| `traefik.http.routers.{router}.priority` | `100` | Beats wake catch-all |
| `traefik.http.services.{router}.loadbalancer.server.port` | container port | Upstream port inside sandbox |
| `traefik.http.routers.{router}.tls` | `true` when `PREVIEW_TLS=true` | TLS on router; no per-router `certresolver` |
| `traefik.http.routers.{router}.middlewares` | `sandbox-preview-auth@file` | Only when `visibility=private` |

`entrypoint` defaults to `web` when empty. TLS routers rely on a single wildcard `*.preview.{domain}` certificate in Traefik’s default TLS store (operator-supplied in production), not per-host ACME on each sandbox.

<RequestExample>
```bash
# Create with port 3000 — labels attached at docker run
curl -s -X POST http://127.0.0.1:9090/sandbox \
  -H 'content-type: application/json' \
  -d '{"ports":[3000]}'
```
</RequestExample>

<ParamField body="PREVIEW_DOMAIN" type="string">
Hostname suffix after `.preview.`. Default `localhost` (no DNS). Production: real wildcard domain (for example `yourdomain.com`).
</ParamField>

<ParamField body="PREVIEW_ENTRYPOINT" type="string">
Traefik entrypoint name on preview routers. Default `web` (`:80`). Production TLS: `websecure` after enabling `:443` in `traefik/traefik.yml`.
</ParamField>

<ParamField body="PREVIEW_TLS" type="boolean">
When `true`, adds `traefik.http.routers.{router}.tls=true` on every preview router. Default `false`.
</ParamField>

## Priority 100 vs wake catch-all priority 1

Two router layers compete on the same Host shape:

| Router | Provider | Priority | When it matches |
| --- | --- | --- | --- |
| `s-{id}-{port}` | Docker (labels on running container) | `100` | Container running and Traefik has observed labels |
| `sandbox-wake` | File (`traefik/dynamic/wake.yml`) | `1` | No higher-priority router for that Host |

The catch-all rule is domain-agnostic:

```text
HostRegexp(`^s-[0-9A-Za-z]+-[0-9]+\.preview\..+$`)
```

It forwards to `http://sandboxd:9000` with `passHostHeader: true`. `sandboxd`’s `hostDispatch` middleware inspects the Host header: if it matches `^s-([0-9A-Za-z]+)-([0-9]+)\.preview\.{PREVIEW_DOMAIN}(?::\d+)?$`, the request goes to the wake handler (HTML warming page); otherwise it hits the API mux.

```mermaid
sequenceDiagram
  participant Browser
  participant Traefik
  participant Sandbox as s-id container
  participant Sandboxd as sandboxd:9000

  Note over Browser,Sandboxd: Sandbox stopped — no Docker router
  Browser->>Traefik: GET Host s-id-3000.preview.domain
  Traefik->>Sandboxd: sandbox-wake priority 1
  Sandboxd->>Sandbox: docker start + TCP ready
  Sandboxd-->>Browser: 200 warming HTML meta-refresh

  Note over Browser,Sandbox: Container running — labels published
  Browser->>Traefik: refresh same Host
  Traefik->>Sandbox: s-id-3000 priority 100
  Sandbox-->>Browser: dev server on :3000
```

After `docker start`, Traefik’s docker provider typically registers the priority-100 route before the browser’s meta-refresh fires, so the second request proxies directly to the app.

<Warning>
`traefik/dynamic/wake.yml` hardcodes `entryPoints: [web]`. For HTTPS-only production, align the wake router’s entrypoints with `PREVIEW_ENTRYPOINT` (for example `websecure`) and publish `:443` in compose — otherwise stopped-sandbox wakes never reach `sandboxd`.
</Warning>

## Static Traefik stack layout

```text
traefik/
  traefik.yml          # entryPoints, docker+file providers, access log path
  dynamic/
    wake.yml           # sandbox-wake catch-all → sandboxd:9000
    auth.yml           # sandbox-preview-auth forwardAuth (private sandboxes)
    api.yml            # optional api.preview.* → sandboxd (priority 100)
```

| File | Router | Priority | Backend |
| --- | --- | --- | --- |
| `wake.yml` | `sandbox-wake` | `1` | `http://sandboxd:9000` |
| `api.yml` | `sandbox-api` | `100` | `http://sandboxd:9000` (Host `api.preview.*`) |
| Docker labels | `s-{id}-{port}` | `100` | sandbox container port |

Access logs land at `${SANDBOXED_LOG_DIR}/traefik-access.log` (bind-mounted to both Traefik and `sandboxd`) so the idle reaper can bump `last_active_at` from `RequestHost`.

## Configuration wiring

Compose passes preview env into `sandboxd`; Traefik reads static YAML and watches `traefik/dynamic/`:

| Variable | Default | Consumed by |
| --- | --- | --- |
| `PREVIEW_DOMAIN` | `localhost` | Label Host rules; wake/forward-auth regex in `sandboxd` |
| `PREVIEW_ENTRYPOINT` | `web` | Label `entrypoints=` |
| `PREVIEW_TLS` | `false` | Label `tls=true` |
| `HTTP_PORT` | `80` | Host publish `:${HTTP_PORT}:80` on Traefik |
| `SANDBOXED_NETWORK` | `sandboxed_net` | `docker run --network` and Traefik `providers.docker.network` |

Production TLS (from README): enable `websecure` in `traefik/traefik.yml`, add a cert resolver or load a wildcard into the default TLS store, set `PREVIEW_DOMAIN`, `PREVIEW_ENTRYPOINT=websecure`, `PREVIEW_TLS=true`, and enable API auth.

## Private previews and forward-auth

Public sandboxes use only the label set above. `visibility=private` adds `traefik.http.routers.{router}.middlewares=sandbox-preview-auth@file`, defined in `traefik/dynamic/auth.yml` as forwardAuth to `http://sandboxd:9000/forward-auth`. Traefik calls that endpoint before proxying to the sandbox; a 2xx allows the request through.

Stopped private sandboxes still hit the wake catch-all; the wake handler runs the same preview-token check as forward-auth before `docker start`.

## Verify routing locally

<Steps>
<Step title="Confirm the edge is up">
```bash
curl -s http://127.0.0.1:9090/healthz
curl -s http://127.0.0.1:9090/readyz
```
</Step>
<Step title="Create a sandbox on port 3000">
```bash
API=http://127.0.0.1:9090
ID=$(curl -s -X POST "$API/sandbox" -H 'content-type: application/json' \
  -d '{"ports":[3000]}' | sed -E 's/.*"id":"([^"]+)".*/\1/')
```
</Step>
<Step title="Hit the preview with Host header">
```bash
curl -s -H "Host: s-${ID}-3000.preview.localhost" \
  "http://127.0.0.1:${HTTP_PORT:-80}/"
```
Expect either the app (running) or the warming page (stopped, wake path).
</Step>
<Step title="Inspect labels on the container">
```bash
docker inspect "s-${ID}" --format '{{json .Config.Labels}}' | jq .
```
Look for `traefik.http.routers.s-${ID}-3000.rule` and `priority=100`.
</Step>
</Steps>

## Failure modes

| Symptom | Likely cause |
| --- | --- |
| Connection refused on preview URL | Nothing listening on the exposed port inside the sandbox yet |
| Endless warming page | Dev server not bound to the declared port, or Traefik has not registered the Docker router after wake |
| Preview works via API wake but not browser | Host id case mismatch — browser sends lowercase; wake normalizes to uppercase ULID |
| Traefik routes wrong container | Missing `sandboxed.managed=true` or constraint disabled |
| Wake never fires on HTTPS | `wake.yml` still on `web` while previews use `websecure` only |

## Related pages

<CardGroup>
<Card title="Preview URL reference" href="/preview-url-reference">
Hostname pattern, `HTTP_PORT` suffix rules, and localhost vs production HTTPS.
</Card>
<Card title="Wake, idle, and pressure" href="/wake-idle-reapers">
Stop-on-idle, wake admission, warming page behavior, and keepalive.
</Card>
<Card title="Private previews" href="/private-previews">
Forward-auth middleware, preview tokens, and deny modes.
</Card>
<Card title="Production deployment" href="/production-deployment">
Wildcard DNS, `websecure`, cert resolver, and `PREVIEW_TLS=true`.
</Card>
<Card title="Configuration reference" href="/configuration-reference">
Full env key list for preview domain, ports, and network.
</Card>
</CardGroup>
