200. The stock host and its configuration
The question this chapter answers: what runs an fsr application when nobody has written a server, where does it read its configuration and how do you know what it decided?
The host library
snapfire_fsr_host turns a configuration directory, the plan file and the contracts into a service over HTTP types. It matches the route, opens the session from the cookie, runs the loaders, renders the page, serves the statics, answers actions and persists the session into the response. hyper serves it directly; axum can nest it; actix reaches it through a shim behind the actix feature, which is what the storefront uses so its two backends and the host can share one binary.
A stock host is two lines; the storefront's main.rs adds one Rust route between them and its two backends around them:
let host = Host::from(env!("CARGO_MANIFEST_DIR"))?.build()?;
snapfire_fsr_host::actix::serve(Arc::new(host), ("127.0.0.1", 8080)).awaitHost::from takes a project root, a config/ directory or a single file, finds the app directory from it, reads the plan file and the contracts under generated/ and binds every row. Every builder method before build is an extension point that chapter 201 uses. An application that needs none of them needs only those two lines.
The configuration ladder
Configuration is TOML or YAML under config/, read as a ladder of files where a later one overrides an earlier one; the ladder is chosen by three environment variables rather than by listing files:
| Stem | From |
|---|---|
app | always |
development | RELEASE_ENV, default development |
local | APP_ENV, default local |
<region> | APP_REGION, when set |
<env>-<region> | both, when the region is set |
bundle | always, last |
For each stem the host reads <stem>.toml then <stem>.yaml, whichever exist, in that order, then lets C5_-prefixed environment variables override any key with __ as the separator. A file that is absent is simply not on the ladder, so a checkout with only app.toml runs while a deployment adds production.toml and production-eu.yaml without touching the base. The report lists every file it read under config, in order, so you can see which files were read. A secret goes in the same files as a c5store .c5encval, the decryptor, the key name and the ciphertext, which c5cli writes into TOML, YAML or JSON; the host decrypts it while loading with the key of that name from config/private_keys or from C5_SECRETKEY_<NAME>. A Rust host changes where keys come from through Loader::secrets.
bundle.toml is the last rung and a project does not write one. fsr bundle writes it into the deploy tree it produces, naming the paths that moved when the files were laid out; chapter 303 covers what it holds. It loads after every deployment overlay because the overlays describe a deployment while bundle.toml describes the directory layout, which no deployment overlay sets.
The host reads these sections. [server] names the listen address, the plan file and the contracts directory. [document] names the title and the template a route's title is written into, each a literal or a message key with its fallback, the shell, the entry script, the import map, the stylesheets, which build of the client a page loads, whether the head carries a preload link per module, plus the Content-Security-Policy. [session] holds the signing key, the store, the TTL, the capacity and whether the cookie is secure. [cache] turns on the render memo with a capacity and a lifetime; without it nothing is cached. server.dev turns the live refresh on or off; absent, it is on whenever RELEASE_ENV is unset or development. [locales] names the locales the host serves, the default that goes unprefixed and whether a chosen prefix is remembered in a cookie. [clients.<name>] gives each service its document and base URL. [[static]] maps a route to a directory. [[route]] declares a pattern the application registers in Rust, so the doctor and anything else reading the configuration know the deployment answers it; a declaration nothing registered shows on the boot report, which the stock host prints for every one since it registers no Rust route. server.static_max_age says how long a browser may keep what one answers. [public] holds the deployment's own values, the one section whose keys the application names.
What the host infers
Most of [document] and [[static]] is never written, because the host infers it from the app directory and says so. The bundle's own facts file names the public path, so the static root for dist/ and the entry script come from there. A vendor/ directory is served at its conventional path. A styles/ directory is compiled with the bundle and every stylesheet in it is linked into the head, minified outside development, with each url() it names an image or a font by taking the URL the build gave that file. Each [clients.<name>] without a document gets clients/<name>.openapi.json or the .proto beside it. The boot report has an inferred section listing every one of those decisions:
inferred document.entry from dist/.snapfire-build.json
static /static/css from styles/
clients.inventory.document from clients/The styles/, icons/ and fonts/ directories are conventions a [dirs] table moves, with the routes staying what they are; what fsr build derived from the images and the fonts reaches the head through generated/assets.json, which the host reads at boot when the file is there. Chapter 108 covers both.
One prefix is not inferred and cannot be written away. /static/js/fsr is answered out of the binary, because @snapfire/fsr-client is carried by snapfire_fsr_host itself, so the client a page loads is the version of the host serving it and there is no copy in the project to fall behind. The boot report gives it a client row of its own rather than a static one:
client /static/js/fsr 23 modules, minified, 156 KiB from the binaryA [[static]] root on that route takes the prefix back and the host serves nothing there, which is how an application ships a client it built itself.
The binary carries two builds of those modules and the row says which one is being served. document.client decides: auto, the default, is the minified build unless server.dev is on; readable or minified say so outright. The minified modules are about a third smaller and import their siblings by .min.js, which the host answers either way, so the setting picks the entry point's build and the rest of the module graph follows it. The application's own modules are served minified the same way once fsr build has minified them, which it does unless the configuration writes server.dev = true: outside development the host names the entry, the preloads and the component stylesheets by their .min twins.
The origin a canonical link points at
A crawler reads rel=canonical and rel=alternate as absolute URLs only. A crawler ignores either tag when it holds a path, so name the origin this deployment is reached at:
[document]
origin = "https://example.com"The host then writes every path href on those two rels absolute, whichever of two places it came from: a canonical() or alternate() a route's meta returned or the host's own locale canonical, the one pointing /en_US/about at /about so a prefixed request for the default locale is not a second page. An href that is already absolute is left exactly as written, which is how a cross-domain alternate still works. Every other rel keeps its path, since a crawler reads those relative to the document.
The value holds the scheme and the host only. A trailing slash or a path is refused at boot rather than producing https://example.com//about on a live page. A value with no scheme is refused the same way, since the scheme is the part a host name cannot supply on its own.
This is a deployment's one preferred origin, not the host a request arrived on. The two differ on purpose. A canonical link exists to merge two host names that serve the same pages, so a self-referential one per host would mark both as originals and create the duplicate it is meant to prevent. An application that serves a different site per host wants ctx.host and a canonical() it builds itself, which passes through untouched because it is already absolute.
A body reads this value as ctx.origin, string | null, for a URL the host does not rewrite: a JSON-LD block, a feed, an absolute link in the page. It is the same checked value, so nothing has to be copied into [public] to reach a loader.
Robots and the sitemap
A [crawl] section has the host answer robots.txt and a sitemap at the paths it names. A file with no key is not served:
[crawl]
robots = "/robots.txt"
sitemap = "/sitemap.xml"
disallow = ["/drafts/"]robots.txt allows everything and names the sitemap. It disallows the framework's /__fsr/ and /_sf/, /auth/ when [auth] mounts the identity routes and every path under disallow. The section needs document.origin, since a sitemap holds absolute URLs. The host refuses to boot without it.
The sitemap lists every page an anonymous visitor is served. The first request for it makes the host ask itself for each candidate page, through the same front door a browser uses, with no cookie. The middleware, the loaders and the head all run. A page is listed when it answers 200 with an HTML document whose head has no robots meta saying noindex or none and whose response has no X-Robots-Tag saying either. So a route the middleware redirects a signed-out visitor away from stays out, as does a page whose meta returns robots("noindex"), a 404 and a handler's JSON. The result is kept until the next reload or until the application calls Host::refresh_sitemap() from a job of its own, which the host never runs itself. Requests keep the sitemap held while it rebuilds. A failed rebuild keeps it.
A page's lastmod is the date it gives itself. modified from @snapfire/fsr/head writes <meta property="article:modified_time">, the Open Graph property a social card reads. The sitemap reads the same row:
import { modified } from "@snapfire/fsr/head";
export const meta = ({ data }: MetaCtx<Data>) => ({ title: data.post.title, head: [modified(data.post.updated)] });The value is a W3C date, 2026-06-01 or 2026-06-01T09:30:00Z. A response with a Last-Modified header and no such row takes the header's date. A page that names neither has no lastmod, which is better than a guessed one: a crawler that finds the dates on a site inaccurate stops reading them. An index entry takes the newest date in its document.
The candidates are the fixed routes plus one path per value of a route's paths export, the one fsr prerender reads. A route with a parameter and no paths has nothing to list. The boot report names it on the crawl rows. With [locales] serving prefixes, each page is listed once per locale with xhtml:link alternates naming the others, fr_FR written as fr-FR and the default locale as x-default.
Past 50,000 URLs the sitemap becomes an index, the pages going in sitemap-1.xml, sitemap-2.xml and so on beside it. A mounted site is part of the shell's routes and needs nothing of its own.
The host this deployment answers on
A body reads ctx.host. It is null until [server] lists the hosts the deployment answers on:
[server]
hosts = ["example.com", "www.example.com"]The list is an allowlist. A request's Host is lowercased and compared whole, port included, so a deployment on a port lists the port and localhost:3000 is a separate entry from localhost. A header naming anything the list does not hold answers null rather than the value the client sent, so nothing a caller writes reaches a body unless the deployment already named it.
The server in front must set the header. ctx.host is only as trustworthy as whatever terminates the connection, because a value the list happens to hold is indistinguishable from the same value sent by hand. With nginx that is proxy_set_header Host $host; under a server_name that matches, plus a default server for the socket so a request naming something else is answered there instead of being passed through. The boot report says so on every start while the key is set:
hosts example.com
www.example.com
ctx.host reads the request's Host against these; the server in front must set it; a client otherwise names its ownLeave the key out and the header is never read. That is the default and it suits an application that does not need the host.
The deployment's own values
[public] is the one section whose keys the application names. Each value is a scalar, a string, integer, float or boolean, under a key that is a TypeScript identifier. A body reads it as ctx.config.<key>:
[public]
analytics_id = ""
support = "help@example.com"app.toml declares every key with the value development runs under, which is also what types the read. An overlay sets the deployment's own:
# config/app.sfo1.toml
[public]
analytics_id = "G-65TR8XV7YE"The values are public in the plain sense: a loader returns them, a page renders them, a store seeds the browser with them. A secret does not go in [public]. Put it in a [clients] credential or somewhere the application's own Rust reads. The boot report lists every key with its value on a public row. fsr doctor reports a body reading a key the section does not declare.
A section the host does not own is left alone. An application that runs the host inside itself hands it one branch of its own store, Config::from_store_at(&store.branch("fsr"), root) and keeps the rest of config/ for itself. Its rungs are named after the same deployment axes, so the application and the host share one local.yaml. The host reads the sections it names and leaves every other top-level key where it is, listing them on the report's ignored row so a misspelt section is still visible; fsr bundle and fsr doctor read the directory the same way. A key it does not know inside a section it owns is still an error.
The boot report
Boot prints the application's report, every route, source, action and rendered module with its owner, then the services with their transport and base URL, the static roots, the configuration files and the inferences. It is the same table the build printed, with the host's own rows added. A Rust host that prints host.report() prints all of it. fsr serve prints only host.report().brief(), the rows that need attention such as a route nothing registers or a key the host does not read, unless --verbose, which fsr dev --verbose passes on. A host that cannot bind a name refuses to boot with the name, rather than serving a plan it cannot answer; the failure modes are a source nothing answers, an action nothing answers, a route claimed twice without an override and a service with no transport.
The lab
Start the storefront and read the config rows: one file, config/app.toml. Add config/development.toml containing only [document] with title = "Deals" and start again. The report lists both files and the tab reads Deals; nothing else changed, since the ladder merged one key. Now set APP_ENV=staging and start once more: development.toml is still read, since it is the release environment's file; a staging.toml would be next on the ladder if it existed. Remove the file when you are done.