Skip to content

Self-hosting

Domains and TLS

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

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

NameExampleServesCertificate
App hostcritlab.example.comweb, and realtime at /wsOrdinary, one name
Sandbox zone*.s.example.comEvery prototype version, each on its own subdomainWildcard for the zone
Realtime host (optional)rt.example.comrealtime, when you do not want it on the app hostOrdinary
Custom share domains (optional, licensed)review.agency.comReviewer links on a workspace's own hostnameOne 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

RecordTypePoints at
critlab.example.comA/AAAA, or CNAMEYour ingress or Caddy
*.s.example.comA/AAAA, or CNAMEYour 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>/.

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.