One YAML file, three sites
nfltr connects machines through a relay they dial out to, and nfltr orch runs Claude Code agents on those machines, driven by a hub that is your own Claude Code session. nfltr.xyz used to be one landing page about running AI coding agents, with every guide underneath it, so the tunnels and the relay were hard to find and every product change meant editing page templates by hand. A site that presents several products needs their names, the header, the footer and which page belongs where to live in one place, or they drift apart page by page.
What we did and found: nfltr.xyz now presents three products, each on its own host with its own landing page and docs. The homepage, the header and footer every page shares, and the list of which page lives on which host all come from one YAML file. Someone who does not write code can change the homepage text, reorder the products or change a header link by editing that file; the build tool refuses a mistake with the line and the field. 17 pages moved hosts and every old address still answers. "One YAML entry per product" turned out to be true for the pages and false for serving a new host, which needs a relay release and operator settings. This post covers the file, its validation, the redirects, how nfltr serves and publishes the sites, why this is not a Hugo site, and what is still not driven by the file.
Three products, three hosts
| Product | Host | What it is |
|---|---|---|
| Tunnels and sharing | nfltr.xyz | the nfltr client: http and tcp tunnels, end-to-end encrypted shares, shell, WireGuard, peer to peer, nfltr up |
| Orchestration | orch.nfltr.xyz | the distributed Claude Code hub and the nodes its agents run on |
| Relay | relay.nfltr.xyz | the transport under both: hosted at nfltr.xyz or run on your own network (beta), and the Go SDK |
The landing pages describe what each product is and how it works, in short paragraphs. Headings name what a section shows rather than telling the reader what to do; each product has one quick-start snippet, lower down. A test fails on a landing heading that starts with an imperative verb.
What the file holds
web/site/products.yaml has two parts. site is the homepage (title, description, heading, intro paragraphs) and what every header and footer show: the docs link label, extra header links with one marked as the button, the footer links and the copyright line. products is the list of products in the order they appear in the header and on the homepage. This is the relay's entry, trimmed to one paragraph and one proof item, without its quick-start snippet:
- id: relay
name: Relay
nav: Relay
host: relay
summary: "The transport under tunnels and orchestration, hosted at nfltr.xyz or run on your own network."
description:
- "Every connection in NFLTR goes through the relay: a share link to a dev server, an SSH session, a hub talking to agents on other machines. Machines connect out to it; it accepts, routes and copies connections. It runs no model and makes no decisions."
docs: /docs/guides/own-relay
content: relay
pages:
- docs/guides/own-relay.html
- docs/sdk.html
- docs/features/go-sdk.html
proof:
- title: "Measured against frp, rathole, chisel and bore"
text: "Same Docker harness, one CPU each: ..."
link: /blog/relay-vs-frp-rathole-chisel-bore
host is either apex (served at nfltr.xyz itself; exactly one product has it) or a subdomain label. A subdomain product names a content folder holding its landing page, and the docs pages it takes from nfltr.xyz; their old nfltr.xyz addresses turn into redirects. Optional assets list files such as recordings its pages play. Text fields allow only `code` and [label](href); everything else is escaped, so the file cannot inject markup into a page. The comment at the top of the file documents every field, and a short guide in the repository walks through a text change and a new product.
Mistakes stop the build, with the line and the field
site-build, the Go tool that builds the sites, reads the file strictly and fails closed. This is its real output for a copy of the file with a fourth product appended whose subdomain label the relay does not know and whose content folder does not exist:
site-build: products.yaml:164: products[3] (notes).host: "notes" is not reserved by the relay: a new product subdomain needs a relay-server release that reserves it first
products.yaml:164: products[3] (notes).content: web/notes/index.html does not exist
Every mistake in the file is reported at once, not only the first. The checks cover:
- unknown keys (a misspelt field is an error, not ignored) and missing required fields;
- duplicate ids or hosts, and not exactly one apex product;
- a subdomain label the relay does not reserve, or one the relay serves itself;
- a missing landing page, docs page, asset or proof image;
- a page claimed by two products, or a page outside the docs and the blog;
- a link that is neither a path nor an
https://address, and a docs start page that the product's site does not ship.
The validation test has 23 error cases, each checked for the line and the field it names. The real file is validated and built by every site-build test.
Evidence: every file mistake is refused with its line and field
- Setup
- site-build's tests in the nfltr repository, October 9, 2026; the error output above is from a copy of the real file with a fourth product appended.
- Numbers
- 23 error cases, each asserting the line and the field it names; the real
products.yamlvalidated and built by every site-build test. - Reproduce
- (a) Not with the released binaries: site-build is a tool in the closed nfltr repository, not a shipped command. (b) Our check, in the closed nfltr repository:
make site-check($0, no network) builds every site from the real file and fails on any mistake; the 23 cases are site-build's Go tests. - Limits
- The cases cover the mistakes we thought of; a mistake outside them (a valid but wrong link text, say) builds fine. The quick-start snippets in the file are not run by the documented-commands check (see the last section).
Every page has one home
The page sources did not move. The docs are still files under web/docs/, so the docs tests, the check that runs every documented command and the CLI's tutorial links keep reading the same files; products.yaml only says which site each page is served on. site-build then gives every page one home:
- the canonical link,
og:urland structured-data URL of a page are its address on its own host; - each site has its own
sitemap.xmllisting its indexable pages, and arobots.txtthat names it. Built from the current tree, before this post, the sitemaps list 32 addresses on nfltr.xyz, 14 on orch.nfltr.xyz and 4 on relay.nfltr.xyz; - a root-relative link resolves to the page at that path on the same site, else to the shared page wherever it lives, else to nfltr.xyz. It is written relative on its own site and absolute to another;
- the shared header and footer are plain HTML filled in at build time from the file, with the current product marked; no script draws them.
A link checker then runs over all three sites together: every internal and cross-site link and every #fragment has to resolve to a page, a redirect page, an asset or a path the relay itself serves on nfltr.xyz. The same check runs before anything is published.
Evidence: one home per page, every link resolving
- Setup
- All three sites built from the tree as it was just before this post, October 9, 2026; one build.
- Numbers
- Sitemaps with 32 addresses on nfltr.xyz, 14 on orch.nfltr.xyz and 4 on relay.nfltr.xyz; the cross-site link check passing over all of them, with a header on every page and each canonical on its own host.
- Reproduce
- (a) Read the live sitemaps:
curl -s https://orch.nfltr.xyz/sitemap.xml(and the same on nfltr.xyz and relay.nfltr.xyz); the counts have grown since. (b) Our check, in the closed nfltr repository:make site-check($0, every site built into a temp directory with the cross-site link check). - Limits
- The counts describe that one tree; every post and guide added since changes them. The link check proves links resolve inside our sites, not that external links still work.
Redirects are static pages, not HTTP 301s
17 pages moved: the orchestration guides and references to orch.nfltr.xyz, the guide to running your own relay and the SDK pages to relay.nfltr.xyz. A test lists every file the three sites served before the split and requires each one to still be served, as itself or as a redirect. At the old address of each moved page, site-build writes a small page like this one, from nfltr.xyz:
<meta name="robots" content="noindex">
<link rel="canonical" href="https://orch.nfltr.xyz/docs/guides/nodes">
<meta http-equiv="refresh" content="0; url=https://orch.nfltr.xyz/docs/guides/nodes">
<script>location.replace("https://orch.nfltr.xyz/docs/guides/nodes" + location.hash)</script>
An immediate meta refresh together with a canonical link to the new address is treated by search engines as a permanent move. The script keeps a #fragment, which a meta refresh drops, and the body has a plain link for anything that runs neither. Old fragments on pages that stayed, such as the orchestration sections of the old homepage, are forwarded by a small inline script on that page.
These are not HTTP 301s, on purpose. A real 301 would have to come from the relay, which would mean a redirect table in the relay and a relay release for every page that moves, or from the agent's file server, which would mean a new user-visible behaviour in a client command for one site's need. Static pages need neither, and they are checked by the same link checker as everything else. If search traffic shows that the meta refresh is not enough, the file server can learn real redirects later.
Evidence: 17 pages moved and every old address still answers
- Setup
- A test holding the list of every file the three sites served before the split, checked against a fresh build; October 9, 2026.
- Numbers
- 17 pages moved; every listed file still served, as itself or as a redirect page, and every redirect target resolving.
- Reproduce
- (a) Open an old address, such as
https://nfltr.xyz/docs/guides/nodes, and see it land on orch.nfltr.xyz with its#fragment. (b) Our check, in the closed nfltr repository:make site-check; the old-address list is a site-build Go test. - Limits
- It proves the redirect pages exist and point at real pages, not how search engines treat them; we have no search-traffic data yet.
How nfltr serves the three sites
The three sites are static builds. As described in nfltr.xyz now runs on nfltr, they are served by an ordinary nfltr up agent, connected to the relay like any other client, with one http service per site; the relay keeps only sign-in, the dashboard, the API and shares. The shape of the agent's config, with the directory names shortened:
agent: site
mode: inspectable
services:
www: { http: ./www/current, no_share: true }
orch: { http: ./orch/current, no_share: true }
relay: { http: ./relay/current, no_share: true }
Each current is a symlink to a release directory, and the agent's file server resolves it on every request. One deploy command builds every site, link-checks them together, stages each release, checks that each has its index.html, 404.html, robots.txt and sitemap.xml, and only then swaps each site's link by rename, so a site is never half old and half new. The newest three releases are kept, and rollback is the same command with one variable set: each site's link goes back to its previous release, links and pages together, without restarting anything. A content-only deploy does not restart the agent or touch the relay.
A test keeps the agent's service list equal to the sites in products.yaml, and the deploy script reads its list of sites from the agent's config, so the two cannot drift apart unnoticed.
What a fourth product takes
On the page side, a product with a new subdomain is one entry in the file, plus a folder with its landing page. A test builds a fourth product from such an entry: it appears on the homepage and in every header, its site gets its own sitemap, and nfltr.xyz redirects the pages it takes.
Serving it takes three more steps that are not in the file, because they belong to the relay and the operator rather than to the content:
- a service line for it in the site agent's config. The test above fails until the services and the products match;
- its label reserved by the relay, which is a relay release. site-build refuses a label that is not reserved, as in the error above;
- the relay's mapping from the new host to the agent's service, which is operator configuration on the relay, followed by deploying the sites and the relay.
So "one YAML entry" is true for what the product looks like and where its pages live, and not for whether its host answers.
The first real fourth product came the same day: status.nfltr.xyz, the canary's status page, is a status entry in the same file and a fourth service of the same site agent. It is described in nfltr on nfltr, part 2, the next post in this series.
Why not Hugo or another static site generator
Hugo, Eleventy and Jekyll do data-driven pages (a data file plus templates), Markdown content, RSS, live reload and themes. Any of them would have worked for this.
We kept the in-repo Go tool because it already built the docs pages into each site, and the CLI reference pages are generated from each binary's own command registry and checked by Go tests: one fails when the reference is stale, another checks every command and flag the docs mention. The per-host split, the redirect pages and the link checker were small additions to that same tool, and they add no toolchain to the build or the release.
That has costs. Blog posts and docs pages were hand-written HTML at first; since October 26 they are Markdown that the same tool renders (see below). There is no RSS feed yet, and no theme ecosystem. Switching to a generator later stays possible, because the page content and products.yaml are separate from the code that renders them.
What the file drives now, and what stays code
When this post was first published (October 9), six things were outside the file. As of October 26, five of them are driven by data and checked by tests:
- The docs sidebar.
products.yamllists each sidebar's sections and pages. Each link's text is the linked page's own heading, so renaming a page renames its link. A test fails when a guide that shows the sidebar is not listed in it. - Posts and guides. They are Markdown files with a short header (title, description, date, series). site-build renders them into the same page templates as before. Evidence blocks, the series box and the "Is it for you?" close are part of the format, and the series box is built from one list of posts. The converted pages have the same visible text as the hand-written ones had.
- Serving a new host. The site agent's service, the relay's host-mapping example and the hosts the release checks after a deploy are generated from the products. site-build prints the mapping line for the relay's settings. The reserved-label check reads the relay's own list instead of a copy.
- Links printed by the CLI and the dashboard. One URL map is generated from the file into the CLI and the dashboard, so the Quick Start footer, the reference's "Tutorials:" lines and the dashboard's guide links print the page's current address, such as
https://orch.nfltr.xyz/docs/guides/00-getting-started, instead of relying on the redirect page. A test fails when code spells out the old address of a moved page. - Quick-start snippets. The docs check reads the
nfltrcommands inproducts.yamland fails on an unknown command or flag, with the line. The nightly run of documented commands runs the snippets that can run against a private relay and says why it skips the others.
What deliberately stays code or operator settings:
- Layouts and styles. The homepage template, the landing pages and the page templates are HTML and CSS. The file fills in text and links; it does not change a layout, and no product needs its sections reordered by data.
- The reserved label itself. A new product host must be a label the relay reserves, and that list ships with the relay. A new label still needs a relay release.
- The host mapping's value. It names the account and the agent that serve the sites, which is operator configuration kept with the relay's other secrets. The release's checks fail on a new host until the operator sets it.
The same pattern for your own sites
Nothing in the serving side is special to nfltr.xyz. A multi-product static site served by nfltr has the same parts: one data file for the shared parts (product names, the header and footer, which page belongs to which site), any generator or script that builds one directory per site from it, a link check across all the built sites, and one nfltr up agent with one http service per site:
agent: mysites
services:
www: { http: ./sites/www/current }
docs: { http: ./sites/docs/current }
app: { http: ./sites/app/current }
$ nfltr up -c sites.yaml
An http target that is a path is a directory the agent serves itself: index.html for a directory, page.html for an extensionless /page, a root 404.html as the not-found page, and no directory listings. Each service gets its own share URL. Publishing is a build into a new release directory, a check, and a symlink swap by rename; rolling back is pointing the symlink at the previous release. Nothing restarts for either. Our host names are permanent because they are reserved labels the operator maps on the relay; an account cannot set that mapping, so share URLs are what a site served this way gets. Their options are in Share a local app.
How it was checked
Besides the checks in the evidence blocks above, the fourth-product test builds a product with a new subdomain from one entry, and a test keeps the site agent's services equal to the products in the file. The layout was checked in a browser:
Evidence: no horizontal scroll at 390 px and 1440 px
- Setup
- A local preview of all three sites, driven with Playwright at 390 px and 1440 px wide, October 9, 2026; one pass.
- Numbers
- No horizontal scroll on the three landing pages, the docs home, both CLI references, a moved guide, install, the blog and the 404 page.
- Reproduce
- (a) Resize a browser window to 390 px on the live pages. (b) Our check, in the closed nfltr repository:
make site-previewserves every site locally (http://localhost:8080/, http://orch.localhost:8080/, http://relay.localhost:8080/); the Playwright pass was run by hand against it, and no checked-in spec reproduces it. - Limits
- Two widths, one browser engine, the pages named above only; pages added later were not part of this pass.
Is it for you?
Fits if you publish a few static sites from one machine, want the shared parts (product names, header, footer, which page lives where) in one data file with strict validation, and are happy with any generator or script that builds one directory per site, served by one nfltr up agent.
Not ready for using our tooling directly: site-build lives in the nfltr repository, which is closed source, so you get the pattern, not the tool. Permanent custom host names on nfltr.xyz are operator-only; your sites get share URLs. There are no HTTP 301s and no RSS feed yet. The hosted relay is one process on one VM.
Try next nfltr up -c sites.yaml with one http service per built directory, as in the previous section, and Share a local app for share options. How the agent serves a directory and how we publish and roll back is in nfltr.xyz now runs on nfltr; the status site and releases run by a hub are in nfltr on nfltr, part 2. How the build and its checks fit our wider process is in How we build reliable Go software.