---
title: "Ship it"
audience: "anyone deploying an FSR app"
description: "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."
snippet_caption: "terminal"
snippet: "$ fsr bundle shop/app --verbose\nserve/static/js/app              serves /static/js/app\nserve/static/js/vendor           serves /static/js/vendor\nserve/static/css                 serves /static/css\nserve/static/js/fsr              serves /static/js/fsr\napp/clients/shop.openapi.json    read by the host\napp/generated/contracts          read by the host\napp/generated/plan.sexp          read by the host\napp/importmap.json               read by the host\nconfig                           read by the host\nconfig/bundle.toml               read by the host"
---

import { Install } from "@src/ui/Install";

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.

<Install captured="0.x" />

## Run the checks

```shell-session
$ 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

```shell-session
$ 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.

```toml title="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:

```shell-session
$ cp fibre_logging.production.yaml dist/fibre_logging.yaml
```

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

```shell-session
$ 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](https://snapfirers.com/fsr/learn/500-move-a-loader-into-rust), ships its own binary instead of `fsr-server`. From the directory holding `Cargo.toml`, build the app, then the binary, then the tree:

```shell-session
$ 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:

```shell-session
$ cp target/release/shop dist/
$ cp fibre_logging.production.yaml dist/fibre_logging.yaml
```

On the server, run it from inside the directory:

```shell-session
$ 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:

```shell-session
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](https://snapfirers.com/fsr/learn/300-run-one-app-on-two-domains) 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](https://snapfirers.com/fsr/learn/310-put-a-docs-site-inside-your-app) uses for pinning.

Next up: [420. Debug a page in production](https://snapfirers.com/fsr/learn/420-debug-a-page-in-production).
