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:
- The steps for the domain, the certificate and the proxy, 1 to 6 below, were carried out and checked from outside on 10 October 2026, on Ubuntu 26.04 LTS with Caddy 2.11 as a system service.
- The router's configuration was run in Caddy 2.8.4, the version its container uses, with a container on an internal Docker network, and every route answered as this page says. That was on a development machine.
- The preview image of this repository was built and run with the options a
preview gets, and served the front page, the dashboard and
/health. tehasbot previewis covered by the shell tests. It has never run on a host, against GitHub or against Docker. Steps 7 to 10 are what the code does, not a record of a run.
See Features.
What it needs
- A domain whose DNS is at Cloudflare, proxied, with a wildcard record. The dashboard's own name is already there in the same way.
- A proxy in front of the host that can reach the host's private address. For
tehas-tehas that is a Caddy on another machine, the one that already sends
tehas.appto port 8080. - Docker on the host, which a tehas host has.
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.
- Leave "Generate private key and CSR with Cloudflare" chosen.
- Host names:
*.tehas.appandtehas.app, which is the default. - Validity: the default of 15 years.
- Format: PEM.
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:
- Inside the platform checkout, as for tehas-tehas, whose settings are
instances/tehas-tehas/in the repository: commit the two lines to the repository. They arrive on the host withtehas updatein step 8. Do not edit the file on the host.tehas updatechanges nothing while the checkout has local changes: it stops with exit 1 andhas local changes; nothing was changed. - Anywhere else: edit the file in place on the host.
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:
- A host that ran the Protolab's server under its old name, the compose
project
tehas-protolab, has that container on port 8081.tehas updatetakes it down and starts the router in its place. If the router did not start, runtehas updateagain once the cause is gone: it remembers the takeover and starts the router. See Troubleshooting. - A host that never ran it gets nothing started. The
docker composeline above starts the router. Where the router already runs, the line changes nothing.
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:
- the top of the pull request's own description says
https://uat-<pr>.tehas.app/, - the dashboard lists it under Previews,
tail ~/tehas/logs/preview.loghas a line#<pr>: serving.
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
- A preview is public. A pull request that an owner, member or collaborator opens from a branch of the repository is on the internet at the next run, with nobody looking at it first.
- The build runs the pull request's
preview/Dockerfileon the host's Docker, with network access. That is why forks are left out, and pull requests opened by anyone outside the repository. - Who opened a pull request is what GitHub says in
author_association. A member of the organisation whose membership is private may be shown as less than a member to the bot's token. That was not tried. If it is so, that person's pull requests get no preview and are counted as opened by someone outside the repository. Making the membership public, or adding the person to the repository as a collaborator, is the way round it. - A preview's output is capped at two files of 10 MB by options of Docker's
json-fileandlocallog drivers, so the host's Docker must log through one of those.docker info --format '{{.LoggingDriver}}'says which. Another driver refuses the options: Docker 29.1.3 withjournaldansweredunknown log opt 'max-size'. No preview would start there, and each would bebuild-failed. - A recipe's volumes are read from the image after the build, and one outside
/tmp,/configand/datais refused. That was seen with Docker 29.1.3: a volume declared at one of the three made nothing on the disk, and one declared elsewhere did. - Previews are not kept from each other: one can call another on port 8080 by its container's name. A preview holds nothing but its own image.
- The host needs Docker 27.1 or later. An older Docker does not know the option that keeps the host out. Whether it would make a network that looks right and is not was not tried: it is not asked to make one, and no preview is built on it.
- A recipe can start
FROMany image in the host's local store, or from a registry the tehas user is signed in to. What it builds is public, so what is in such an image can be served to anyone. - A build has no CPU, memory or disk limit for its 15 minutes.
- The checks are of how the network was made, not of what a container on it can reach. Nothing tries to reach the host from inside.
uat-<pr>.tehas.appandtehas.appare one site to a browser. A preview's script can set a cookie for the domain. Nothing on the dashboard logs in, so this is accepted today.- The origin certificate is trusted by Cloudflare only. A browser sent straight to the proxy's machine for these names is warned.
- Not checked: the origin certificate covers
tehas.apptoo, so Caddy on the proxy may serve it for the dashboard's own names in place of the certificate it got for them. Behind Cloudflare nothing changes.journalctl -u caddy | grep 'skipping automatic certificate management'on the proxy shows whether it does. - Under
TEHAS_PREVIEW_MIN_FREEof free disk no preview is built. Docker's build cache is not pruned by tehas. - A full disk does not stop a cycle. Only previews hold back.
- The commits are fetched into a bare clone,
~/tehas/preview/repo.git. It takes the address of its origin from the product checkout, as written there. A checkout whose address carries a credential would have that credential written to a second file. Keep credentials in a credential helper, not in the address. - The newest 100 open pull requests are looked at.