# 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?

**For:** platform developers.

## 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:

```rust
let host = Host::from(env!("CARGO_MANIFEST_DIR"))?.build()?;
snapfire_fsr_host::actix::serve(Arc::new(host), ("127.0.0.1", 8080)).await
```

`Host::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](https://snapfirers.com/fsr/the_guide/201-graduating-to-rust.md) 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 binary
```

A `[[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:

```toml
[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:

```toml
[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:

```ts
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:

```toml
[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 own
```

Leave 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>`:

```toml
[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:

```toml
# 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.
