Raising static

Before the move, a push to GitHub went to a US host, which built the documentation sites and the blog with an image of its own choosing and served the result, and only the main site was frozen at home and pushed built. For five of the seven, the build ran nowhere else. Static hosting sounds wonderfully uncomplicated, and the finished thing is HTML, CSS, a little JavaScript and some assets on a server, but getting there is a longer chain: source, build environment, dependencies, runner, artefact, deployment, TLS, visitor. The scraps for this layer say a static output does not mean a static dependency graph. Two questions got asked of every link in that chain. If the host vanished tomorrow, could the site be built, a certificate obtained and the files put somewhere else? And how many foreign services have to stay available before a page can be produced and published at all?

Two routes

Purple Lantern runs seven sites of three kinds: five documentation sites built with Sphinx, a blog built with Zola, and a main site built with Frozen-Flask. Drawing the chain showed that they reach statichost by two different routes. The documentation sites are built by statichost from source. The blog and the main site are built on the workstation, and the built directory is committed and served as it is.

   Sphinx sites                                  blog, main site

   workstation ── push ──► CodeFloe               workstation ── zola build, build.py ──► public/
                              │                                                             │
                           webhook                                                      committed
                              ▼                                                             │
                      statichost builder                                                 push
        Docker Hub ── python:3.12-slim ─┤                                                   │
        PyPI ── Sphinx, MyST, theme ────┤                                                   ▼
        committed cache ── Roboto ──────┤                                                CodeFloe
                              ▼                                                             │
                         build/html                                                      webhook
                              │                                                             │
                              └─────────────────► statichost edge ◄─────────────────────────┘
                                                        ▲
                                                  Let's Encrypt
                                                        │
                                                     visitor

On the left, everything feeding the builder except the committed cache is fetched on every build. On the right, statichost fetches nothing: the build already happened, on a machine at home, and what arrives is a directory of files. Same host, two graphs.

Three lines

The whole of the build configuration for one documentation site is three lines in its statichost.yml:

image: python:3.12-slim
command: pip install --no-cache-dir -r requirements.txt && python -m sphinx -M html source build
public: build/html

Every line is a dependency. The image is pulled from Docker Hub on each build, by tag, so it is whatever Debian and Python the 3.12-slim tag points at that day. The command pulls Sphinx, MyST and the immaterial theme from PyPI at the versions pinned in requirements.txt, and runs Sphinx without make, because a slim image has no make. The public directory is where Sphinx writes. The other four documentation sites have the same three lines.

The same three lines run on the workstation and produce the same output. That, and nothing else, is what makes statichost a convenience for these five and not a dependency. When the local build matches, the host holds no secret knowledge.

Built at home

The blog’s statichost.yml is one line, public: public, and a comment saying to run zola build before committing. make build runs Zola on the workstation, refuses to run while the dev server is up, and fails if the output contains a localhost URL. That last guard was added after a lesson: a bare zola serve drops a search index with a 127.0.0.1 base into public/, and if that goes out in a commit, every search on a Swedish server points visitors at a laptop under a desk in Ankh-Morpork for an afternoon. That is the Ankh-Morpork school of giving directions: sincere, precise, and of use only to the person giving them. The result is committed and statichost serves it. The Zola binary is not in the repository and not at the host; it is installed on the workstation, through Homebrew, and that is the only place in the stack it is. There is no theme: the templates under templates/ and the CSS, JavaScript and images under static/ are the site’s own, and the search index is elasticlunr, built by Zola at build time and queried in the browser.

The main site’s statichost.yml is public: project/build. Two scripts run on the workstation first. build_search_index.py reads the built _sources of the five documentation sites from their sibling checkouts and writes one lunr index over all of them, with a Node script and a copy of lunr.js kept in the repository, so the main site’s search covers the family and depends on the siblings having been built locally first. Then build.py runs Frozen-Flask, which walks every route of the Flask application and writes it to project/build, and copies _headers and _redirects in afterwards, because Frozen-Flask removes files it did not generate. Flask 3.0.3 and Frozen-Flask 1.0.2 are pinned in requirements.txt. Flask never runs anywhere but the workstation, and statichost never runs anything for this site.

For these two, the dependencies on PyPI, GitHub releases, Homebrew and Node are the workstation’s, not the host’s. A build with the network unplugged is a question about what is installed at home, and the answer is yes for both as long as the Zola binary, the Python packages and Node are already there.

The question of stripping the two remotes down to the built directory came up and was turned down. Since the host serves only the output, the remote could hold only that, with the source kept at home. It would make the remote lighter and the stack weaker. Output is derivable from source and source is not derivable from output: a frozen directory does not give back the Flask application, and a rendered public/ does not give back the posts. With the source in the remote it has two copies, the workstation and the forge; without it, one. And it would lessen no dependency. Zola through Homebrew, Flask through PyPI and Node through the distribution would all remain, on a single machine with no second copy of what they build from. The one reason to split source from output is a source that has to be private, and that reason is not present.

Fetched

There are three moments at which something is fetched, and each got its own answer.

At statichost, on every build of a documentation site, two things: the image from Docker Hub, run by Docker, Inc. of Palo Alto, and the packages from PyPI, maintained by the Python Software Foundation.

Not the fonts, and the fonts are the one fetch the move took out of a build. The immaterial theme wants Roboto and Roboto Mono from Google Fonts. Its cache, a few hundred request and response pairs written the last time the theme was upgraded, is committed in each of the five documentation repositories, so the builder reads the cache and never asks Google, and would still build on a day Google Fonts is unreachable from Europe. Two of the five repositories had a leftover .gitignore line that would have kept future cache updates out of the commit; it went. All five got an exclude_patterns entry in conf.py so the cache is read by the builder and not copied into the published site. Two tests settled it. With the cache present and every outbound request sent to a dead proxy, the build succeeds and writes every font file. With the cache empty and the same proxy, it fails at the first request. The cache is keyed by request, and the requests are fixed by the theme version and the font settings, so a theme upgrade asks for new URLs; those are fetched once at home and committed together with the new pin.

   repository (input)                        builder                        output (published)

   source/_static/immaterial_cache/          reads the cache,               build/html/_static/fonts/
     .request + .response pairs      ─────►  never asks Google,     ────►   the .woff2 and .ttf files
     committed                               writes the font files          served to the visitor

   source/_static/  (css, js, images) ────►  copied as they are     ────►   build/html/_static/
                                             except immaterial_cache,
                                             which stays behind

At home, when the blog or the main site is built: the Zola binary, installed through Homebrew from the project’s GitHub releases; Flask and Frozen-Flask from PyPI; Node from the distribution. statichost fetches nothing for these two, because what it receives is the built directory.

At page view, in the visitor’s browser: the site itself, and on one page of the main site one other place. The contact form posts to Formward, a form backend built in Stockholm since 2025 on Swedish datacentres, and the site’s _headers names it as the only permitted form action. The fonts are served from the site. No page loads a script, a style or an image from anywhere else, and the CSP in _headers says so: default-src 'self'.

The trigger

A push at CodeFloe calls a webhook. For the documentation sites the statichost builder clones the repository, runs the three lines, and publishes the directory. For the other two it clones the repository and publishes the committed directory. The forge and the host are separate control planes: the deployment service can change without moving the source, and the repository can move without changing the public site. If the webhook stops, the build can be started from the statichost dashboard or the directory uploaded, so a broken webhook is a delay, not an outage.

The credential to protect is the statichost account itself. It can change the build command, the published directory and the attached domains, which is everything. Like any account, it recovers through a mailbox, so the mailbox’s own recovery, on the email page, is the hosting account’s as well.

TLS

statichost obtains a certificate for each attached name on first request, by ACME, from Let’s Encrypt; the docs say only “automatically”, and the issuer is what the live certificate on any statichost-served name says: Let’s Encrypt, valid for ninety days. The chain runs domain, DNS, ACME challenge, CA, certificate, host, and only the last step is the host’s. Because the DNS is at Infomaniak and not at the host, the same names get a certificate from another host in minutes if this one goes. Whether statichost can use a CA other than Let’s Encrypt has not been looked into yet.

Artefacts

The build cache at statichost is a convenience for the documentation sites, and a build that works only because an old dependency sits in the cache has made the cache part of the environment. The test is that a build from a clean clone produces the same site.

The artefact is build/html for Sphinx, public/ for the blog, project/build for the main site. For the last two the artefact is in Git, which is the best property of static sites taken one step further: the directory can be served by statichost, another host, a web server, a small VPS or a machine at home, with no build to reproduce and no state to reconstruct. For the documentation sites a copy of the last good build lives on the workstation with its date.

Previews are not used. A local build does the job, and a preview URL at the host would be one more thing at the host.

Raised

                  local repository
                        │
                 reproducible build ── on statichost for Sphinx, at home for the rest
                        │
              ┌─────────┴─────────┐
        local artefact       published directory
                                  │
                             statichost
                                  │
                           Let's Encrypt
                                  │
                               visitor

Source at the forge and in the clones on the workstation. Build instructions in each repository. Dependencies pinned, and cached or vendored. Artefact reproducible, and committed where the build is local. Deployment replaceable, webhook a trigger only, TLS through standard ACME, external assets local. The runner, the cache and the deployment service are disposable. The source, the domain and the keys are not.

What could go wrong?

  • If Docker Hub tightens anonymous pulls, the documentation sites’ builds fail at the first line and the last good site keeps serving until the next push; the blog and the main site do not notice, because nothing is pulled for them.

  • If PyPI became unreachable, the same, and the main site’s next local build would wait for a wheels directory.

  • If GitHub closed the Zola releases page, the blog could not be rebuilt on a fresh machine until the binary was found elsewhere, and the published one would keep serving.

  • If statichost closed the account, every site’s directory exists, two kinds of them in Git, and is uploaded to another host with the DNS changed at Infomaniak.

  • If Formward closed, one form on the main site would stop delivering and the page would still load.

In none of these does a visitor see anything until a change is needed, and the size of each is measured by what was kept outside the provider beforehand. statichost has vanished, what now? The repositories are here, two of them already contain the site, the three lines run locally for the others, the DNS is at Infomaniak, and another host takes a directory. An afternoon.


Nanny Ogg’s recipes fit on the back of an envelope and come out the same in any kitchen in Lancre, which is the whole point of a recipe.