EN FR

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.

6 min read10 sectionsChapter 16 / 22

What the host must be able to do

Four capabilities, and nothing else. Any host that has them will do.

CapabilityWhy
Serve static files from a CDNThat is the whole site: pre-built HTML.
Run server functionsThe four /api/* routes. Without them the site displays but is not editable.
Build on every pushA publish is a commit; the site must rebuild itself.
Offer key-value storageRate counting, shared across instances. Named RATE_LIMIT.
What does not fit

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.

PlatformWhat serves the routesAttempt counting
file-tree discoveryfunctions/ + wrangler.tomlRATE_LIMIT binding
Netlifynetlify/ + netlify.tomlthe site's object storage
container, VPS, othernpm run servememory — a single instance

Configuring the first one takes three lines. Nothing else in the code depends on the host.

A site created before create-inline 1.2.0

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.

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

  1. Create the project, wired to the repository

    Build commandnpm run build
    Output folderdist
    Production branchmain — the same as GIT_BRANCH
    Node version20 or later (NODE_VERSION variable if needed)
  2. 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=…
  3. Declare the RATE_LIMIT key-value binding

    Create a key-value store, then bind it to the project under the exact name RATE_LIMIT — that is the name the code looks for.

  4. Deploy, then verify

    See the checklist below. Three commands and two manual trials.

Preview deployments

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 Secure attribute, so it is not sent in the clear. Over HTTP, authentication cannot work.
  • Check the redirect from the domain without www to the one with, or the other way round — a single canonical address.
  • Update site in astro.config.mjs: it is what builds canonical URLs and hreflang links.

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.

a host with no shipped adapter ts
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/*' };
On Netlify: do not declare 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 platformWhy
Functions receive a standard web RequestThe code reads request.headers, request.text(), request.formData().
Variables are readable at runtimeSome platforms expose process.env, others a context object.
A key-value store is availableOtherwise, implement RateLimitStore on what the platform offers.
The CPU allowance covers ~350 msThat is the cost of one key verification on /api/auth.
The caller address is readableRate counting relies on it.
The maximum request size reaches 20 MBThe image upload cap.

Serving locally, like production

bash bash
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

This one comes before all the others

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.

bash bash
# 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_LIMIT binding 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 /admin produces a commit, then a rebuild.
  • The domain is on HTTPS, with a single canonical address.
  • site in astro.config.mjs points 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:

text text
[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.