TEHAS

Previews and the Protolab on the internet

A host can publish two things under names of their own: a running preview of every open pull request, and the Protolab's page for every ticket at the prototype stage. This page is how that was set up for the tehas-tehas instance, written so that another instance can follow it.

Name What answers
uat-<pr>.tehas.app The preview of that pull request
lab-<issue>.tehas.app That ticket's page in the Protolab
lab.tehas.app The Protolab's list

How far this is proven:

See Features.

What it needs

Nothing here needs a DNS token or a paid certificate.

Why the names are one level deep

Cloudflare's free certificate covers the domain and one level under it: *.tehas.app. A name two levels down, such as 12.uat.tehas.app, resolves and then fails at the TLS handshake. You can see both halves:

dig +short 1.uat.tehas.app          # answers: the wildcard record matches
curl -sI https://1.uat.tehas.app    # fails: no certificate for two levels
curl -sI https://uat-1.tehas.app    # gets an answer

Two levels need Cloudflare's paid certificates, or a DNS token on the host and the host's address made public. uat-12.tehas.app needs neither.

The proxy needs a certificate too

Cloudflare speaks HTTPS to the proxy in front of the host. A Caddy that gets a certificate per site has none for a name it has no site for, and Cloudflare then answers 525. That is what lab.tehas.app said before this was set up.

The answer is one certificate for *.tehas.app on the proxy. A Cloudflare origin certificate is free, lasts for years and needs no renewal job. It is trusted by Cloudflare only, which is enough: these names are only ever reached through Cloudflare.

1. Make the certificate

In Cloudflare, in the domain: SSL/TLS, Origin Server, Create Certificate.

The next page shows the certificate and the private key. The key is shown once. Keep the page open for the next step. On SSL/TLS, Overview, the encryption mode has to be Full or Full (strict).

2. Put the two files on the proxy's machine

As root there:

mkdir -p /etc/caddy/certs
chmod 700 /etc/caddy/certs
nano /etc/caddy/certs/origin.pem     # the Origin Certificate, with its BEGIN and END lines
nano /etc/caddy/certs/origin.key     # the Private Key, the same
chown -R caddy:caddy /etc/caddy/certs
chmod 644 /etc/caddy/certs/origin.pem
chmod 600 /etc/caddy/certs/origin.key

3. Check the files before touching the Caddyfile

openssl x509 -in /etc/caddy/certs/origin.pem -noout -ext subjectAltName -enddate
sudo -u caddy test -r /etc/caddy/certs/origin.key && echo "caddy can read the key"

The first lists both host names and an end date years away. The second prints its line.

The files come first. A site that names certificate files that are not there stops Caddy from starting at all. If Caddy is then restarted, every site in that Caddyfile is down, not only the new one. This happened while these steps were first taken. To get back: take the new site out, caddy validate --config /etc/caddy/Caddyfile, systemctl start caddy.

4. Add the site

Under the dashboard's site in /etc/caddy/Caddyfile, with the host's private address in place of <address>:

*.tehas.app {
    tls /etc/caddy/certs/origin.pem /etc/caddy/certs/origin.key
    @edge header_regexp Host ^(lab|lab-[0-9]+|uat-[0-9]+)\.tehas\.app$
    handle @edge {
        reverse_proxy <address>:8081
    }
    handle {
        respond 404
    }
}

Caddy takes a wildcard as a whole label only, so lab-* cannot be a site address; the matcher picks the names. The sites tehas.app and www.tehas.app are more specific and are not touched. The Host header is passed on as it is, which is what the router picks by.

5. Validate, then reload

As root on the proxy's machine, as in step 2:

caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy

Reload, never restart. A reload with a configuration Caddy refuses keeps the old one serving. Warnings about formatting or deprecated directives elsewhere in the file do not matter; the last line has to be Valid configuration.

6. What the names answer now

Name Answer It means
tehas.app 200 The dashboard is as before
www.tehas.app 301 So is its redirect
lab.tehas.app, lab-1.tehas.app, uat-1.tehas.app 502 The certificate and the site work. Nothing listens on port 8081 of the host yet
nothing.tehas.app, uat-x.tehas.app 404 Other names are refused
any of them 521 Caddy on the proxy is not running. See the box above
any of them 525 The certificate is not in place, or the encryption mode is not Full
for h in tehas.app lab.tehas.app uat-1.tehas.app nothing.tehas.app; do
  printf '%-20s ' "$h"; curl -s -o /dev/null -w '%{http_code}\n' "https://$h"
done

On the host

7. The settings

In the instance's instance.env:

TEHAS_DOMAIN=tehas.app
TEHAS_PREVIEWS=1

Where the file is depends on the instance directory:

TEHAS_PREVIEW_MAX and TEHAS_PREVIEW_MIN_FREE have defaults; see Commands. TEHAS_PREVIEWS=1 without a domain stops every command that reads the settings with exit 78.

TEHAS_DOMAIN by itself is enough for the Protolab's names: each ticket is then at lab-<issue>.tehas.app, and the Protolab's address becomes https://lab.tehas.app unless TEHAS_PROTOLAB_URL says otherwise.

8. Install and start the router

tehas update
. ~/tehas/env.sh
docker compose -f ~/tehas/edge/router.compose.yaml up -d
curl -i -H 'Host: uat-1.tehas.app' "http://$TEHAS_HOST_ADDR:8081/"

tehas update pulls the platform checkout and runs install.sh, which installs the router's files and the units and makes the Docker network tehas-previews. What it does about the router depends on the host:

The last line answers 404 with "No preview is running at this address". From outside, uat-1.tehas.app now answers 404 where it answered 502.

9. Start the timers

systemctl --user enable --now tehas-protolab.timer tehas-preview.timer tehas-preview-sweep.timer
Timer Every What it does
tehas-protolab.timer 5 minutes Builds the Protolab's pages
tehas-preview.timer 5 minutes Builds a preview for a new commit, removes the previews of merged and closed pull requests
tehas-preview-sweep.timer 3 hours Checks the host for anything a closed pull request left, and removes it

Each interval is counted from the end of the run before. The timers are enabled by the operator, here. tehas update restarts a unit only where it is already active, so on a host that was installed before previews existed the two new timers are installed and do not run until this line is given.

10. A preview, end to end

The product needs a preview/Dockerfile: see A new project. Push a branch to the product repository and open a pull request. At the next run, which is at most five minutes away, the preview is built. Once the build is done:

Merge or close it, and at the next run the log has #<pr>: pull request no longer open, removed, and the name answers 404. The description keeps the notice it last said, which is an address that answers 404 from then on: nothing of previews edits the description of a pull request that is no longer open.

When a preview is not there, the dashboard says why. The pull request's description says so for a failed build and for a full disk. A pull request that had a preview and falls over the limit, or whose new commit has no recipe, has its notice changed to say there is none. See Troubleshooting.

What a preview is given, and what it is not

A preview runs the pull request's own files on the factory's host. It is given nothing of the host: no directory, no setting, no token, no port, a read-only root, 256 MB and one CPU. It writes to /tmp, /config and /data, which are in memory, and nowhere on the host's disk: a recipe that declares a volume anywhere else is not run, and what a container prints is kept in at most two files of 10 MB. It is on a Docker network with the router and the other previews and nothing else. It cannot reach the internet, the model host, the dashboard or the host itself. Previews can reach each other, by the names of their containers.

That network, tehas-previews, has to be an internal one on which the host has no address. An internal network alone keeps a container from the internet and from the host's other addresses, but the container still reaches every service the host listens on at all addresses, its SSH server for one, through the gateway address of the network's bridge. So the network is made with the option com.docker.network.bridge.gateway_mode_ipv4=isolated, and without IPv6, because the option is about IPv4 only: on a network with IPv6 the host has an address of that kind on the bridge, and a container reaches the host through it.

tehasbot preview does not take the network's name for it. At the start of every run it checks what keeps the host out, and if one thing is missing the run stops before it builds or removes anything. The log then says the network tehas-previews does not keep the host out (<reason>); nothing was built, with one of these reasons:

Reason What it means
Docker <version> is older than 27.1 Docker knows the option from 27.1 on. The bridge driver of Docker 29.1.3 was seen to accept an option it does not know and to show it when asked. So an older Docker may take this one, show it, and give the host an address all the same. That was not tried on an older Docker. The version is checked, not the option alone, so that it does not matter
Docker's version could not be read docker version --format '{{.Server.Version}}' gave nothing that reads as a version
it could not be read docker network inspect tehas-previews failed
it is not internal The network was made without --internal: a preview would reach the internet and the model host
its driver is not bridge The option means nothing to another driver
it has IPv6 The host has an IPv6 address on the bridge
it is not isolated from the host The network was made without the option: the host has an IPv4 address on the bridge

install.sh checks the same things by the same code. It does not ask a Docker older than 27.1 to make the network, and it leaves a network that is there and fails a check, and says so.

Only a pull request whose branch is in the product repository gets one, and only when an owner, member or collaborator of the repository opened it. Pushing a branch there needs write access, and a pull request from a fork never gets a preview. Opening a pull request needs no access: on a public repository anyone can open one between two branches that are already there. Such a pull request is not built and takes no place from the others.

Limits