Chapter 16Ship
Deployment
What the host must be able to do, the step-by-step configuration, secrets, the counting binding, and the checks that decide whether you ship.
What the host must be able to do
Four capabilities, and nothing else. Any host that has them will do.
| Capability | Why |
|---|---|
| Serve static files from a CDN | That is the whole site: pre-built HTML. |
| Run server functions | The four /api/* routes. Without them the site displays but is not editable. |
| Build on every push | A publish is a commit; the site must rebuild itself. |
| Offer key-value storage | Rate counting, shared across instances. Named RATE_LIMIT. |
Purely static hosting — GitHub Pages, GitLab Pages, a storage bucket, a file server. The site
would display perfectly, /api/* would answer 404, and editing would be
impossible.
The shipped configuration
A created site ships one adapter per family of hosts. None of them holds a rule: the routes
are declared once, in src/lib/api.ts. The folders of a host you do not use can
be deleted without breaking anything.
| Platform | What serves the routes | Attempt counting |
|---|---|---|
| file-tree discovery | functions/ + wrangler.toml | RATE_LIMIT binding |
| Netlify | netlify/ + netlify.toml | the site's object storage |
| container, VPS, other | npm run serve | memory — a single instance |
Configuring the first one takes three lines. Nothing else in the code depends on the host.
It has a single adapter, and keeps working perfectly on its original host. To gain the
other two: update inline-core to 2.1.0 or later, create
src/lib/api.ts with the createRouter line, wire the four files of
functions/api/ to it, then copy netlify/,
netlify.toml, scripts/serve.mjs and
scripts/build-netlify.mjs from a site created with that version. Generating
one next to it and taking the files from there is the simplest route.
# No secret here: variables are declared in .dev.vars locally and in the
# project configuration in production.
name = "martin-bakery"
compatibility_date = "2026-08-17"
pages_build_output_dir = "dist"Deploying, step by step
-
Create the project, wired to the repository
Build command npm run buildOutput folder distProduction branch main— the same asGIT_BRANCHNode version 20 or later ( NODE_VERSIONvariable if needed) -
Set the variables as runtime secrets
Not as build variables. A build variable can end up in the served files; a runtime secret is readable only by the function.
env env EDITOR_KEY_HASH=$argon2id$v=19$m=19456,t=2,p=1$… SESSION_SECRET=… EDITOR_NAME=Site editor EDITOR_EMAIL=contact@martin-bakery.com GIT_PROVIDER=github GIT_REPO=agency/martin-bakery GIT_BRANCH=main GIT_TOKEN=… -
Declare the
RATE_LIMITkey-value bindingCreate a key-value store, then bind it to the project under the exact name
RATE_LIMIT— that is the name the code looks for. -
Deploy, then verify
See the checklist below. Three commands and two manual trials.
If the same secrets are set on the preview environment, a test branch can write to
the production branch: the function writes where GIT_BRANCH points, not
where it is deployed. Two answers: do not set the secrets on previews, or set a distinct
GIT_BRANCH there.
Without the counting binding
Counting falls back to an in-memory counter. It works, it raises no error, and it protects nothing in production: each instance counts on its own, and a cold start resets it. Exactly the kind of gap that only shows up during an incident.
The check is simple: five wrong keys in a row on /admin must end with a wait
message. If the sixth attempt is still refused with “incorrect key”, the limit is not active.
Do not ship.
The domain
- Add the client's domain to the project, and wait for the certificate.
-
HTTPS is required, not merely desirable: the session cookie carries the
Secureattribute, so it is not sent in the clear. Over HTTP, authentication cannot work. - Check the redirect from the domain without
wwwto the one with, or the other way round — a single canonical address. - Update
siteinastro.config.mjs: it is what builds canonical URLs andhreflanglinks.
Another host
Netlify and a plain Node process already ship — see the table above. For a platform without
its adapter, there is a single file to write, and it decides nothing:
api.handle picks the route and the method, the logic stays in the package.
import { api } from '../../src/lib/api';
// The platform provides (request, context); the router expects (request, env).
export default async (request: Request) => api.handle(request, process.env);
export const config = { path: '/api/*' };node_bundler
With node_bundler = "esbuild" in netlify.toml, Netlify emits
CommonJS: the default export becomes exports.default, the function is taken
for a v1, and the runtime calls handler — which does not exist. The site then
answers 502 “handler is not a function” at the exact moment the client
enters their key, and nothing says the key is not at fault.
Without that option, Netlify would have to resolve inline-core's TypeScript
itself, published as source, which Node cannot load. Hence the two-step build command:
npm run build && npm run build:netlify, which bundles the function into
a self-contained ESM module. It checks its own output and fails the build rather than the
deployed site.
| To check on another platform | Why |
|---|---|
Functions receive a standard web Request | The code reads request.headers, request.text(), request.formData(). |
| Variables are readable at runtime | Some platforms expose process.env, others a context object. |
| A key-value store is available | Otherwise, implement RateLimitStore on what the platform offers. |
| The CPU allowance covers ~350 ms | That is the cost of one key verification on /api/auth. |
| The caller address is readable | Rate counting relies on it. |
| The maximum request size reaches 20 MB | The image upload cap. |
Serving locally, like production
npm run build
npm run serve:functions # site + functions on http://127.0.0.1:8788
That is the only mode that actually runs /functions. npm run dev
serves the site without the routes: useful for layout, useless for editing.
Checks before shipping
A site deployed without its functions builds, serves and displays
perfectly. Only editing fails, at the moment the key is entered: /api/auth
answers 404 instead of opening a session, and nothing on screen says why. Suspicion then
falls on the key, which is not at fault. One command settles it in a second.
# 1. The functions are running — check this before anything else
curl -s -o /dev/null -w "%{http_code}
" https://the-site.com/api/auth # → 405
# 2. Content is in the raw HTML
curl -s https://the-site.com/en/ | grep -c "a page title" # → 1
# 3. Writing is closed without a session
curl -s -o /dev/null -w "%{http_code}\n" -X POST https://the-site.com/api/save # → 401
# 4. Unexpected methods are refused
curl -s -o /dev/null -w "%{http_code}\n" https://the-site.com/api/save # → 405
# 5. No secret in the served files
curl -s https://the-site.com/en/ | grep -Ec "argon2|github_pat|GIT_TOKEN" # → 0- The five commands above give the expected result.
- Five wrong keys in a row end with a wait message.
- The
RATE_LIMITbinding is declared and bound to the right project. - Variables are runtime secrets, not build variables.
- The token is restricted to the site's repository only.
- A test publish from
/adminproduces a commit, then a rebuild. - The domain is on HTTPS, with a single canonical address.
siteinastro.config.mjspoints at the real domain.- Previews do not carry the production secrets.
Rolling back
Content
Undo a publish
Every publish is a commit: git revert the offending commit, push, the site
rebuilds. No feature to develop, no backup to restore.
Deployment
Roll back to a live version
Hosts keep previous deployments and can put one back in service. It is faster than a fix, and it buys time to understand.
Monitoring
Function logs are the only place to read a write failure. You will find:
[save] écriture impossible (unauthorized) → token expired or revoked
[save] écriture impossible (not_found) → wrong repository, branch or path
[save] contenu refusé par le schéma [ … ] → invalid content was submitted
[auth] débit dépassé → five attempts, the limit kicked in
[upload] format refusé : svg → a non-image file was sent
None of these messages contains a secret: no token, no hash, no cookie, no client content. That
is verified by check-logs.mjs on every commit.