One YAML file, three sites

nfltr.xyz used to be one landing page about running AI coding agents, with every guide underneath it. It now presents three products, each on its own host, each described on its own landing page and with its own docs. The homepage, the header and footer that every page of the three sites 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 checks it and refuses a mistake with the line and the field.

This post covers what the file holds, how it is validated, how pages moved between hosts without breaking any old address, how nfltr itself serves and publishes the three sites, what adding a fourth product takes, why this is not a Hugo site, and what is still not driven by the file.

Three products, three hosts

ProductHostWhat it is
Tunnels and sharingnfltr.xyzthe nfltr client: http and tcp tunnels, end-to-end encrypted shares, shell, WireGuard, peer to peer, nfltr up
Orchestrationorch.nfltr.xyzthe distributed Claude Code hub and the nodes its agents run on
Relayrelay.nfltr.xyzthe transport under both: hosted at nfltr.xyz or run on your own network (beta), and the Go SDK
The blog, sign-in, the dashboard, the install page and the privacy page stay on nfltr.xyz.

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.

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:url and structured-data URL of a page are its address on its own host;
  • each site has its own sitemap.xml listing its indexable pages, and a robots.txt that 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.

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.

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:

  1. a service line for it in the site agent's config. The test above fails until the services and the products match;
  2. 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;
  3. 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.

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 are hand-written HTML; there is no Markdown authoring. 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 is not data-driven yet

  • Layouts and styles. The homepage template, the product landing pages and the docs pages are HTML and CSS. The file fills in text and links; it does not change a layout.
  • The docs sidebar. It is defined in TypeScript, not in the file.
  • Posts and guides. They are hand-written HTML files, as above.
  • Serving a new host. The site-agent service line, the reserved label and the host mapping are outside the file.
  • Links printed by the CLI and the dashboard. The CLI's "Tutorials:" lines and the dashboard's docs links still point at nfltr.xyz addresses; moved guides answer them with the redirect page. Printing the new host would need the URL map in the CLI.
  • Quick-start snippets. The check that runs documented commands reads HTML and Markdown, so the snippets in products.yaml are not covered by 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

  • All three sites built from the real tree with the link check, the header on every page, the canonical on its own host, robots and sitemap per site, and a redirect page for every page that lives elsewhere.
  • Every file the three sites served before the split is still served, as itself or as a redirect (17 moved).
  • The fourth-product test, and the 23 validation error cases.
  • A local preview of all three sites in Playwright at 390 px and 1440 px wide: no horizontal scroll on the three landing pages, the docs home, both CLI references, a moved guide, install, the blog and the 404 page.

← All posts