nfltr.xyz now runs on nfltr
nfltr connects machines through a relay they dial out to, so a service on a laptop or a server can be reached without opening an inbound port. Until this week, the pages you are reading were compiled into that relay: the landing page, every docs page, every blog post, the install page and the demo recordings shipped 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. That couples the slowest-changing, most critical process to the fastest-changing content.
What we did and found: the public sites are now 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. Locally the extra hop added no measurable time to first byte, the agent needs about 40 MB, and the relay binary got 5.25 MB smaller. The pilot release still took a site down in production, because of a key-ownership bug our end-to-end test could not see; the fix and the lesson are below, with the limits. The agent side uses only commands you have, so you can host a static site the same way.
What moved where
| Host | What it is | Served by |
|---|---|---|
| nfltr.xyz and www.nfltr.xyz | landing, docs, blog, privacy, install page, robots.txt, sitemap, media | the site agent |
| nfltr.xyz and www.nfltr.xyz | sign-in, dashboard, API, shares, health checks, the relay's own scripts | the relay |
| orch.nfltr.xyz | the orchestration overview and guides | the site agent |
| relay.nfltr.xyz | a new landing page for the relay, its guide and the encryption page | the site agent |
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.htmlfor a directory, andpage.htmlfor an extensionless/page, so docs URLs such as/docs/guides/nodeswork without the.html.Cache-Control: no-cacheon every response. Browsers revalidate withLast-Modified, so a new version is visible at once.- A
404.htmlat 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: 1on 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:
- the agent is connected to this relay process;
- the relay's user store says the configured account owns it;
- 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.
Update: part of this is now public, in relay-core: the http.site_hosts setting (platform/config), the reserved labels (core/session) and the HTTP front, whose TestSiteHostKeepsAgentCaching covers the site's own Cache-Control described below. Since October 27, 2026 the operator can also give the mapping as a file the relay reads again when it changes, so a site host is added or removed without a relay restart.
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:
- no relay handler ahead of authentication took it (shares, sign-in, the dashboard, the public API, webhooks);
- it is a
GETorHEAD; - it carries no relay credential: no
Authorization,X-API-Key,X-Agent-IDor TLS client certificate. A keyed request on the apex is the CLI talking to the relay, as before; - 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 by | Median | p90 |
|---|---|---|
| the site agent through the relay | 0.73 / 0.82 / 0.75 ms | 0.84 / 0.97 / 0.87 ms |
| embedded in the relay | 0.77 / 0.79 / 0.76 ms | 0.83 / 0.88 / 0.81 ms |
Evidence: the agent hop adds no measurable time to first byte locally
- Setup
- One Mac (darwin/arm64), a private relay and the site agent on loopback, the agent connected to the same relay process; the page
/docs/guides/nodes; 50 requests per run, three runs per variant; October 9, 2026, the pilot releases (v1.0.683 and v1.0.684). - Numbers
- Median 0.73 / 0.82 / 0.75 ms through the agent against 0.77 / 0.79 / 0.76 ms embedded; p90 0.84–0.97 ms against 0.81–0.88 ms.
- Reproduce
- (a) With the released binaries, for your own folder:
nfltr http ./public, thencurl -s -o /dev/null -w '%{time_starttransfer}\n' <share URL>repeated (this goes through the hosted relay, so it measures your network too). (b) Our check, in the closed nfltr repository:make smoke-test-site-on-nfltr($0, host-local; it prints time to first byte and the agent's resident size). - Limits
- Loopback only, one machine, three runs: it shows the hop costs nothing measurable when the network costs nothing, not what it costs on a real path. The embedded column needs a relay from before the site was removed; current builds can only measure the agent side.
These numbers are about one extra hop for small pages. How the relay's data path itself compares with other tunnels, in throughput, latency and memory, is measured in A faster, leaner relay, measured against frp, rathole, chisel and bore.
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.
Evidence: in production the hop disappears in network distance, at about 40 MB
- Setup
- nfltr.xyz: one relay process on one small VM (1 GB of memory), the site agent on the same VM; a client far from the server; October 9, 2026. Memory locally: the smoke test after about 110 requests, three runs.
- Numbers
- Time to first byte 0.76 s through the agent, 0.79 s embedded. Agent resident size 39–41 MB on the VM, 46–52 MiB locally.
- Reproduce
- (a) Time to first byte of any nfltr.xyz page:
curl -s -o /dev/null -w '%{time_starttransfer}\n' https://nfltr.xyz/docs/guides/nodes; the embedded variant no longer exists to compare against. (b) The production numbers are a one-off live measurement; no checked-in harness reproduces them. The local memory figure comes from our check in the closed nfltr repository,make smoke-test-site-on-nfltr. - Limits
- One client location, one page, taken during the switch; how many requests each figure covers is not recorded. The difference is small next to network jitter, so read it as "no visible cost", not as "faster". The resident size is read after deploys, not under sustained load.
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.
Evidence: both binaries 5.25 MB smaller
- Setup
- Stripped linux/amd64 builds (
-trimpath -ldflags "-s -w") of the relay server andnfltr, before and after removing the embedded site; October 9, 2026; one build of each. - Numbers
- Relay server 64,934,048 → 59,687,072 bytes;
nfltr60,625,056 → 55,378,080 bytes; thenfltrbudget 62,000,000 → 56,200,000 bytes. - Reproduce
- (a)
ls -lon a released linux/amd64 binary shows its current size, which later releases have changed. (b) Our check, in the closed nfltr repository:make check-nfltr-binary-size(fails over budget and lists the largest modules). - Limits
- Sizes of those two commits only; the binaries have since been split into three programs and their sizes moved again.
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 loginand 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.
Evidence: a relay restart shows the 503 page for 38 to 332 ms
- Setup
- A private relay and the site agent on one machine, the relay restarted with the agent running; October 9, 2026; the smoke test's restart step (how many restarts the range covers is not recorded).
- Numbers
- 38–332 ms from the relay being ready to the site answering 200 again; relay-owned paths answered throughout.
- Reproduce
- (a) With the released binaries, for a share rather than a mapped site host: serve a folder with
nfltr http ./publicthrough your own relay (nfltr relay serve, see Run your own relay), restart the relay, and time how long the share URL takes to answer 200 again. (b) Our check, in the closed nfltr repository:make smoke-test-site-on-nfltr($0, host-local; the restart step). - Limits
- Local reconnects only; in production the agent reconnects over the VM's own network path, which we have not timed separately.
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.
Is it for you?
Fits if you have a static site, docs or a build output on a machine you control and want it reachable without an inbound port, a web server to configure or a CDN account; publishing by symlink swap with no restart; and one small agent process (about 40 MB for us) serving several sites.
Not ready for a permanent host name of your own on nfltr.xyz: our product hosts are reserved labels the operator maps, and an account cannot set that mapping, so your site gets share URLs (24 hours unless you pass --share-ttl, and the relay may cap it). The file server has no HTTP redirects and no per-path caching rules (every response is no-cache). All services of one agent share one rate budget (100 requests per second, burst 200). The hosted relay is one process on one VM. The nfltr CLI and the relay server are closed source; the relay's session core, its client SDK and link are public since October 29, 2026 (Four nfltr repositories are now open source). Your own relay is in beta (Run the relay on your own network).
Try next nfltr http ./public for one folder, or nfltr up -c sites.yaml for several; the options are in Share a local app. How the three sites are described and built from one data file is the next post in this series, One YAML file, three sites; the status page, operator access and releases moved onto nfltr in nfltr on nfltr, part 2.