nfltr.xyz now runs on nfltr

Until this week, the pages you are reading were compiled into the relay. The landing page, every docs page, every blog post, the install page and the demo recordings were embedded in the same binary that carries tunnels and agent traffic. Fixing a typo in a guide meant a relay release: a new build, a restart and a drain of every connection.

Now the public sites are served the way any user's site would be: by an ordinary nfltr agent, nfltr up serving static directories, connected to the relay like any other client. The relay keeps only what is really dynamic: sign-in, the dashboard, the API and shares. This post covers how that works, how we publish and roll back, what we measured, the bug that took the first site down in production, and what is still limited. The agent side uses only commands you have, so you can host a static site the same way.

What moved where

HostWhat it isServed by
nfltr.xyz and www.nfltr.xyzlanding, docs, blog, privacy, install page, robots.txt, sitemap, mediathe site agent
nfltr.xyz and www.nfltr.xyzsign-in, dashboard, API, shares, health checks, the relay's own scriptsthe relay
orch.nfltr.xyzthe orchestration overview and guidesthe site agent
relay.nfltr.xyza new landing page for the relay, its guide and the encryption pagethe site agent
One agent process serves all three sites, one http service per site. The relay's gRPC endpoint for agents stays on its own host name and never goes through this path.

orch.nfltr.xyz went first, as a pilot, and has been live since v1.0.684. The apex, www and relay.nfltr.xyz followed, and with them the embedded site was removed from the relay.

The agent side: serving a directory

The one missing piece was small. An http target that is a path (starting with /, ./, ../ or file://) is now a directory the agent serves itself:

$ nfltr http ./public

The agent starts a file server on a loopback port for that directory and points the tunnel at it, so everything else (shares, end-to-end encryption, services in nfltr up) is the same path as for a local app on a port. There is no second process and no extra dependency to install. The file server does a few things a site needs:

  • index.html for a directory, and page.html for an extensionless /page, so docs URLs such as /docs/guides/nodes work without the .html.
  • Cache-Control: no-cache on every response. Browsers revalidate with Last-Modified, so a new version is visible at once.
  • A 404.html at the root makes the directory a site: any missing path answers that page with status 404, and no directory is ever listed.
  • X-Nfltr-Static: 1 on every response, so a check can prove the agent's file server answered and not something else.
  • The directory is resolved on every request. If it is a symlink, pointing the symlink somewhere else publishes a new version in one step (more on that below).

Several sites run from one nfltr up file. This is the shape of ours, with your own directory names:

agent: site
mode: inspectable
services:
  www:   { http: ./sites/www/current,   no_share: true }
  orch:  { http: ./sites/orch/current,  no_share: true }
  relay: { http: ./sites/relay/current, no_share: true }
$ nfltr up -c site.yaml

agent: site runs all three services under one agent over one connection; each is reached as site/<service>. no_share: true means no share link, because the relay routes our product host names to these services instead (next section). mode: inspectable lets the relay read the HTTP request, which it must do to route by host name.

How the relay routes a product host

Names like orch and relay are reserved labels on nfltr.xyz: no user can take them. The operator maps each one to one account's agent and service in the relay's settings:

HTTP_SITE_HOSTS=orch=<owner>/<agent>/orch,relay=<owner>/<agent>/relay
HTTP_SITE_APEX=<owner>/<agent>/www

The relay refuses to start on a malformed entry, a label that is not reserved, a label mapped twice, or www mapped beside the apex. A request for a mapped host reaches the agent only when, at that request, all three hold:

  1. the agent is connected to this relay process;
  2. the relay's user store says the configured account owns it;
  3. it declares the mapped service.

Anything else answers 503 with Retry-After: 30 and Cache-Control: no-store: a small JSON error for API clients, a short page that asks not to be indexed for browsers. It never names the agent or the account, and it never serves another account's content. Agent IDs are scoped by key, so another account's agent with the same name has a different ID, and an agent the mapping names but someone else owns fails check 2. The relay logs a reason once each time a host goes unavailable or comes back, not per request, so a crawl against a down site cannot flood the journal of a small VM.

Past those checks, the request takes the same dispatch path as a share: the relay names the agent and service, removes any copy of the service header a client sent, and hands it on. There is no separate code path for our own sites. TLS is ended by the relay with the same wildcard certificate it uses for shares.

The apex split

orch and relay are pure sites. The apex is not: nfltr.xyz is both the landing page and the place where you sign in, use the dashboard and call the API. So the relay splits it, and the split fails closed toward the relay. A request for nfltr.xyz or www goes to the site agent only when all of these hold:

  1. no relay handler ahead of authentication took it (shares, sign-in, the dashboard, the public API, webhooks);
  2. it is a GET or HEAD;
  3. it carries no relay credential: no Authorization, X-API-Key, X-Agent-ID or TLS client certificate. A keyed request on the apex is the CLI talking to the relay, as before;
  4. its path is not one the relay owns: /api/, /dashboard, /login, /logout, /session, /oauth/, /auth/, /share/, /browse/, /connect/, /healthz, /metrics, /.well-known/, /contact, /help, /install.sh, the favicon, the relay's own vendored scripts and a few more.

The relay-owned paths are plain prefixes, so a near miss stays with the relay. Everything else on the apex, including unknown paths, is the site's: its page, its 404 page, or the 503 page when the agent is away. Relay-owned paths never depend on the agent: with the site agent stopped, sign-in, the dashboard, the API and health checks keep answering.

We considered two other designs. Sending everything that is not relay-owned to the agent, credentials included, would have broken the CLI's keyed calls on the apex. A list of site paths kept inside the relay would have been a second list to keep in step with the site, and a relay release for every new top-level page. The relay's list changes only when the relay adds a route.

Static means static

The site agent never needs to know who you are, so the relay makes sure it cannot. For every site host it forwards no Cookie, Authorization, Proxy-Authorization or X-API-Key: the apex holds your session cookie, and a site has no use for it. It also drops every Set-Cookie in the agent's response, so a site cannot set a cookie for nfltr.xyz. (The session cookie has the __Host- prefix and could not be overwritten anyway; other cookies could.) The relay's response writer now writes the header before an early flush, so a removed header cannot leak that way; a test covers it.

Two things go the other way. The relay keeps the site's own Cache-Control (the file server's no-cache with Last-Modified) instead of the no-store it puts on other agent content, and the apex's pages get the relay's own browser security headers, as the embedded pages had.

Publishing and rolling back

Each site is built into a directory: the docs and blog pages rendered as before, links checked, a 404 page, robots.txt and a sitemap per site. A deploy copies every built site to a new release directory on the VM, named by time and commit, and checks that each one has index.html, 404.html, robots.txt and sitemap.xml. Only when all of them pass does it switch each site's current symlink, by rename, so the switch is atomic per site. The newest three releases are kept.

$ ln -sfn releases/2026-10-09T1200Z-abc1234 current.new
$ mv -T current.new current

Because the file server resolves the directory on every request, the next request sees the new release. A content deploy restarts nothing: not the agent, not the relay. The agent is restarted only when its binary, config or key changed, and the relay is never touched by a site deploy.

Rolling back content points current at the previous release, the same rename and no restart. Rolling back the routing removes the mapping from the relay's settings. The apex mapping is its own setting rather than one more label so that rolling the relay back to a release older than it is safe: the older relay ignores the setting and serves its embedded site again.

Every release then checks, from outside, that each of nfltr.xyz, www.nfltr.xyz, orch.nfltr.xyz and relay.nfltr.xyz answers 200 with X-Nfltr-Static: 1, and that on the apex and www /login, /dashboard, /api/v1/version and /healthz come from the relay: no X-Nfltr-Static, no 503. A failed check fails the release.

Running the agent

The site agent runs on the relay's own VM, a small one with 1 GB of memory, and connects out to the relay like any client. It runs under systemd as a dynamic user with a read-only view of the system, with the relay's secrets and state hidden from it. Its memory is held by GOMEMLIMIT=64MiB (Go's soft target) and MemoryMax=128M (hard), and OOMScoreAdjust=500 makes sure that under memory pressure the kernel kills the site agent before the relay. Its API key is an ordinary key of the operator account, used only by this agent, and it reaches the agent through a root-only environment file, never the command line or a log.

What we measured

Time to first byte of the same docs page, locally, with the agent connected to the same relay process (median of 50 requests, three runs each):

Served byMedianp90
the site agent through the relay0.73 / 0.82 / 0.75 ms0.84 / 0.97 / 0.87 ms
embedded in the relay0.77 / 0.79 / 0.76 ms0.83 / 0.88 / 0.81 ms
Same machine, loopback. The agent hop adds no measurable latency locally.

In production, from a client far from the server, the same docs page's time to first byte was 0.76 s served by the agent against 0.79 s embedded. Network distance dominates; the extra hop disappears in it.

Memory: the site agent's resident size on the 1 GB VM is about 39–41 MB, serving all three sites. Locally, after a burst of requests, we measured 46–52 MiB; most of it is the mapped binary.

Size: with the site gone from the embed, the stripped linux/amd64 binaries got 5.25 MB smaller each. The relay server went from 64,934,048 to 59,687,072 bytes and nfltr from 60,625,056 to 55,378,080 bytes; the 3.9 MB launch video was most of it. The size budget our build enforces for nfltr went down with it, from 62,000,000 to 56,200,000 bytes, so the savings cannot quietly grow back.

What went wrong

The pilot release, v1.0.683, shipped with a green end-to-end test, and orch.nfltr.xyz answered 503 in production.

The site agent's key had been created with nfltr config login. That path recorded the key's owner in one place, on the key itself, but never wrote the separate row that says which account owns an agent; only keys made in the dashboard did. The ownership check behind check 2 reads that row only, so the site agent had no owner, and the relay correctly refused to serve it. It also logged the reason at debug level, so we could not see why.

Our test had passed because it set up keys and users from files, a path production does not use. The bug was wider than the site: every key from nfltr config login was affected, and every feature that asks who owns an agent treated those agents as unowned. It failed closed, so nothing was exposed; your own agent list and key management kept working, because they read the owner from the key.

The fix, in one place for every way a key is created:

  • Minting a key now records its ownership in the same step, for nfltr config login and both dashboard paths. If recording fails, the key is deleted again and the mint fails, so no key is handed out without an owner.
  • A database migration added the missing row for every active key whose owner is a known account and whose agent had no owner. An existing owner is never overwritten, and a revoked key stays released.
  • The site host's state changes are now logged at info level, once per change, without the owner's email.
  • The end-to-end test now creates the owner's key through the real sign-in flow, with a fake Google sign-in, then restarts the relay with the mapping. Against the v1.0.683 relay it fails exactly as production did.

The lesson we took: a test that reaches the same state through a different door does not test the door production uses.

Limits

  • A relay restart shows the 503 page for a moment. After the relay restarts, the static pages answer 503 until the site agent has reconnected. Locally that took 38–332 ms after the relay was ready, and in production the agent is on the same VM. Sign-in, the API and every relay-owned path answer as soon as the relay does.
  • One rate budget for all sites. The relay limits each agent to 100 requests per second (burst 200), and all three sites are one agent. If crawlers ever hit that, we will raise it or key site hosts by client address.
  • A single relay process. Check 1 needs the agent on the same relay process that receives the request. Our relay is one process on one VM, so that holds; a relay with several replicas would answer 503 for an agent connected to another replica until the ownership check can look further.
  • Smaller losses. The landing page lost the admin-message banner, and the docs pages lost the docs helper's Ask box, which is turned off in production. A relay without the apex mapping, such as a local relay, no longer has a landing page or docs at all.

Host a static site the same way

The agent side is everything above, with no special access. For one folder:

$ nfltr http ./public

The command prints a share URL that serves the folder, end to end encrypted by default (TLS ends at your agent). Add 404.html at the root for a real 404 page. A share link lasts 24 hours unless you pass --share-ttl (the relay may cap it), and it stops when the command stops. Our own host names are permanent because they are reserved labels the operator maps in the relay's settings; that mapping is not something an account can set.

For several sites from one process, a file like ours, with share links on:

agent: mysite
services:
  docs: { http: ./sites/docs/current }
  blog: { http: ./sites/blog/current }
$ nfltr up -c sites.yaml

Each service gets its own share URL, which reaches that service only. To publish, build into a new release directory, check it, then swap the symlink by rename as shown above; the next request sees it, and nothing restarts. To roll back, point the symlink at the previous release. To run it for good, give it its own API key, keep that key in a file only root can read, and set a memory limit; the agent has needed about 40 MB for us. More options for shares and for nfltr up are in Share a local app.

← All posts