Tutorial 36 of 54 · for anyone deploying an FSR app · Markdown

Ship it

You'll turn an app into a directory and a binary you copy to a server. First you'll run the checks that catch problems that only appear in production.

Before you start

Install from crates.io. You need no Node and no package manager.

cargo install snapfire_compiler
cargo install snapfire_fsr_cli
fsr --version

Every command and screenshot on this page was captured with fsr 0.x.

Run the checks#

$ fsr doctor shop/app
doctor       13 checks, nothing to report

fsr doctor runs thirteen checks for failures that don't show up in development: a static root whose directory isn't there, a route a static prefix swallows so the page beneath it never runs, a mounted site pinned to nothing or a relative canonical link.

A finding exits non-zero, so this belongs in CI.

Write the tree#

$ fsr bundle shop/app --verbose
serve/static/js/app              serves /static/js/app
serve/static/js/vendor           serves /static/js/vendor
serve/static/css                 serves /static/css
serve/static/js/fsr              serves /static/js/fsr
app/clients/shop.openapi.json    read by the host
app/generated/contracts          read by the host
app/generated/plan.sexp          read by the host
app/importmap.json               read by the host
config                           read by the host
config/bundle.toml               read by the host

...
shop/dist/fsr-server runs it
place beside it: the logging configuration

fsr bundle runs doctor first and refuses on a finding, so a problem is reported before the tree is written.

A web server may hand out everything under serve/. The host process reads everything else at boot. Source files such as .tsx are not in the tree, so they can't be served.

Where files go in the tree#

The bundle places each file by what it is. Configuration goes to config/, what the host reads at boot goes to app/ and a static root goes to serve/ at the route it answers.

The bundle does this because a path in your project only has to work on your machine and a path in the tree has to work wherever it is deployed. A [[static]] pointing at dir = "../../shared/assets" works in a monorepo but means nothing on a server. The tree keeps the route and drops the source path.

This also keeps the bundle inside its own output directory. Destinations are built from a route or a fixed name, never joined out of a configured path, so no setting can write above --out.

config/bundle.toml#

Moving files means your config no longer describes where things are, so the bundle writes config/bundle.toml and the host loads it last, after every environment overlay.

dist/config/bundle.toml
[app]
dir = "app"

[server]
contracts = "generated/contracts"
plan = "generated/plan.sexp"

[document]
entry = "/static/js/app/src/main.js"
import_map = "importmap.json"

[[static]]
dir = "../serve/static/js/app"
route = "/static/js/app"

The bundle ships all of config/, including files this run didn't load, because the tree may be deployed under a different RELEASE_ENV than the one you bundled under.

Run the binary#

An app with no crate of its own gets fsr-server in the tree. It is the stock host built for production, cached once per FSR version and copied into every tree after that. Add the logging configuration beside it:

$ cp fibre_logging.production.yaml dist/fibre_logging.yaml

That directory is what you deploy. On the server, run the binary from inside it:

$ C5_SESSION__KEY=... ./fsr-server --listen 127.0.0.1:3000

Ship an app with its own crate#

An app with its own crate, as in 500, ships its own binary instead of fsr-server. From the directory holding Cargo.toml, build the app, then the binary, then the tree:

$ fsr build app
$ cargo build --release
$ fsr bundle app

...
place beside it: the binary, the logging configuration

fsr bundle copies the plan the last fsr build wrote and builds nothing itself. That build is also the step that reads your #[native] blocks, so run it after every change to them.

The tree holds no fsr-server. Copy your binary and the logging configuration into it:

$ cp target/release/shop dist/
$ cp fibre_logging.production.yaml dist/fibre_logging.yaml

On the server, run it from inside the directory:

$ C5_SESSION__KEY=... ./shop

Check the native modules#

A binary that forgot a .native(...) registration still boots. Only the requests that call the missing module fail, each with no native module `digest` is registered. So read the natives rows in the boot report before sending traffic to a new release:

natives   digest                 rust

Every module your loaders call through native needs a row.

Point nginx at serve/#

location ~ ^/static {
  root /srv/shop/dist/serve;
}

location / {
  proxy_pass http://127.0.0.1:3000;
  proxy_set_header Host $host;
}

nginx answers the assets straight off disk. Everything else goes to the host. ctx.host depends on that proxy_set_header line, which 300 covers.

Release again#

Bundle into a fresh directory, copy it up and move a symlink to it. The old tree is still there if you need to move it back.

Because destinations are derived rather than copied, bundling the same input twice gives you the same tree byte for byte. So a mounted site can be verified against the hash of the bundle that produced it, which 310 uses for pinning.

Next up: 420. Debug a page in production.