# Domains and TLS

> The app hostname, the sandbox zone and its wildcard certificate, custom share domains and the Caddy configuration.

Source: https://critlab.app/docs/self-hosting/domains-tls

critlab needs at least two names: one for the app and one DNS zone for prototypes. Getting the second one right — a wildcard certificate issued over DNS-01 — is the most important step of an install.

## The names you need

| Name | Example | Serves | Certificate |
| --- | --- | --- | --- |
| App host | `critlab.example.com` | web, and realtime at `/ws` | Ordinary, one name |
| Sandbox zone | `*.s.example.com` | Every prototype version, each on its own subdomain | **Wildcard** for the zone |
| Realtime host (optional) | `rt.example.com` | realtime, when you do not want it on the app host | Ordinary |
| Custom share domains (optional, licensed) | `review.agency.com` | Reviewer links on a workspace's own hostname | One per domain |

The app host and the sandbox zone must be different names; the Helm chart refuses to render otherwise. The sandbox zone must not be a parent of the app host either — pick a dedicated subdomain such as `s.example.com`.

### DNS records

| Record | Type | Points at |
| --- | --- | --- |
| `critlab.example.com` | A/AAAA, or CNAME | Your ingress or Caddy |
| `*.s.example.com` | A/AAAA, or CNAME | Your ingress or Caddy |

The bare zone (`s.example.com`) needs no record: no URL is ever issued at the zone apex.

## Why the sandbox zone needs a wildcard certificate

Every time a share link is opened, web issues a fresh sandbox URL of the form `https://<label>.s.example.com/`, where the 55-character label is a signed, expiring capability for one version. Serving each version from its own origin is what makes packages with root-absolute asset paths (`/assets/index.js` — the default output of Vite, Astro, Create React App and Next.js static export) work, and it means two prototypes can never share an origin.

A certificate per label is not an option:

- Let's Encrypt issues at most 50 certificates per registered domain per week; per-label issuance would run into that almost immediately.
- Every issued certificate is published in Certificate Transparency logs. Per-label certificates would announce every live prototype to anyone watching them.

One wildcard certificate for `*.s.example.com` avoids both: it is issued once, renewed every 60 to 90 days, and its log entry shows only the zone. A wildcard can only be proved over **DNS-01** (a TXT record in the zone), not HTTP-01.

### Option 1: cert-manager with a DNS-01 solver (Kubernetes)

```yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-dns01
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: ops@example.com
    privateKeySecretRef:
      name: letsencrypt-dns01-account-key
    solvers:
      - dns01:
          cloudflare:
            apiTokenSecretRef:
              name: cloudflare-api-token
              key: api-token
        selector:
          dnsZones:
            - "s.example.com"
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: critlab-sandbox-wildcard
  namespace: critlab
spec:
  secretName: critlab-sandbox-tls
  issuerRef:
    name: letsencrypt-dns01
    kind: ClusterIssuer
  dnsNames:
    - "*.s.example.com"
```

Swap the solver for your DNS provider (cert-manager ships solvers for Route 53, Google Cloud DNS, Azure DNS and others). Then set `ingress.tls.sandboxSecretName: critlab-sandbox-tls`. Alternatively set `ingress.tls.mode: cert-manager` with `ingress.tls.issuerRef.name` and let the chart annotate its Ingress objects.

### Option 2: a certificate from your own CA

```bash
kubectl create secret tls critlab-sandbox-tls -n critlab \
  --cert=wildcard-s-example-com.crt \
  --key=wildcard-s-example-com.key
```

Point `ingress.tls.sandboxSecretName` at it. The chart never rotates a Secret it did not create; renewal is your CA's process.

### Option 3: Caddy with a DNS plugin (outside Kubernetes)

Caddy's on-demand TLS issues one certificate per hostname, which is exactly what the sandbox must not do. Build Caddy with your DNS provider's plugin (`xcaddy build --with github.com/caddy-dns/cloudflare`, or the `caddy:builder` image) and name the wildcard site explicitly:

```text
{
	email ops@example.com
}

*.s.example.com {
	tls {
		dns cloudflare {env.CLOUDFLARE_API_TOKEN}
	}
	reverse_proxy sandbox:4001
	header {
		-Set-Cookie
		-Server
	}
}
```

Do not add `header_up Host {http.request.host}`: that placeholder drops the port, the sandbox then fails to match the zone, and every prototype silently falls back to path-mode routing. `infra/caddy/Caddyfile` has this block commented out with the full explanation.

Set `SANDBOX_URL_MODE=host`, `SANDBOX_ZONE=s.example.com` and (optionally) `SANDBOX_SCHEME=https` on **both** web and sandbox.

## Fallback: path mode

If you cannot get a wildcard certificate at all, set `SANDBOX_URL_MODE=path` (chart: `sandbox.urlMode: path`) and give the sandbox one ordinary hostname (`ingress.sandboxHost`, or `SANDBOX_ORIGIN`). Every version is then served at `https://proto.example.com/s/<token>/v/<version>/`.

> **Path mode breaks many modern builds**
>
> In path mode, a package whose HTML references assets by root-absolute URL (`/assets/…`, `/_astro/…`) loads those files from the sandbox host's root instead of the package, and renders unstyled or blank. That is the default output of Vite, Astro, Create React App and Next.js static export. Only packages with relative asset URLs, or built with a matching `base`/`publicPath`, render correctly. Tell whoever approves the install; the only fix is a wildcard certificate and host mode.

## Custom share domains

A licensed workspace can serve reviewer links on its own hostname, e.g. `https://review.agency.com/p/<slug>`. The prototype itself still loads from your sandbox zone.

1. An owner or admin adds the hostname in **Settings → Domains** (or `crit domain add`). Subdomains only; apex domains are not supported.
2. They publish two records: `TXT _critlab-verify.<host>` with the value critlab shows, and a CNAME to `DOMAIN_CNAME_TARGET` — or, on a self-hosted install where that is unset, any CNAME/A/AAAA pointing at your ingress.
3. **Verify** checks both. The domain becomes active once its certificate is issued, and only then is it used in share links.

On a custom host, web serves reviewer surfaces only (`/p/*` and the thread and comment API); everything else, sign-in included, redirects to the app host, so no session cookie is ever set on a domain the customer controls. Unknown hosts get a 404.

### Certificates with Caddy

Set `DOMAIN_TLS_PROVIDER=caddy`, `TRUST_PROXY_HOST=1` and `INTERNAL_ORIGIN=http://web:3000` on web, and enable on-demand TLS with critlab as the gatekeeper. Caddy asks `/api/domains/tls-ask` before every handshake for an unknown name; critlab answers 200 only for verified domains.

```text
{
	email ops@example.com
	on_demand_tls {
		ask http://web:3000/api/domains/tls-ask
	}
}

https://critlab.example.com {
	reverse_proxy web:3000
}

https:// {
	tls {
		on_demand
	}
	reverse_proxy web:3000 {
		header_up X-Forwarded-Host {http.request.host}
		header_up X-Forwarded-Proto {http.request.scheme}
	}
	header {
		X-Robots-Tag "noindex, nofollow, noarchive"
	}
}
```

### Certificates on Kubernetes

`ingress.customDomainsCatchAll.enabled: true` routes unrecognised hosts to web, which enforces its own allowlist. Nothing in the chart issues their certificates on demand yet: create a certificate (and Ingress TLS entry) per domain yourself. The chart sets `DOMAIN_TLS_PROVIDER=none` by default, which verifies ownership but never activates a domain; switch it once certificates are in place.

## More than one name for the app

If the app is reachable under several names (an internal and an external one, say), list the extra ones in `EXTRA_PRIMARY_HOSTS`. Otherwise requests on an unlisted name are treated as an unknown custom domain and get a 404.

> **Open questions**
>
> - **Activating custom domains on Kubernetes.** With `DOMAIN_TLS_PROVIDER=none` a domain stays verified and is never used in links; with `caddy` there is no Caddy in the chart. How an operator marks a domain's certificate as issued after creating it by hand is not documented in the code we read.
> - **Customer-delegated sandbox zones** (`*.review.acme.com` for the iframe too) are planned, not built.
