Deployment — two rules your host must follow

Civility apps are static bundles — drag-and-drop deploy to any host (see the quickstart). Two things aren’t automatic outside civ start: your host must send one response header (§1), and your deploy must be additive (§2).

Why: §17.6 / §18.5.


1. The header #

Service-Worker-Allowed: /

Required on exactly one route: <distPrefix>/<versionDir>/worker.js — e.g. /dist/1.0.1-a3f9c1e2/worker.js. distPrefix is your outdir’s served path (/dist by default, per civility.json); versionDir is different on every build, so match with a wildcard, never a literal path.

Without it, navigator.serviceWorker.register() throws a SecurityError — the app still loads and works online, but the service worker never controls the page, so it never goes offline and update handling breaks silently.

civ start / civ start --prod already send this. It only matters once you deploy the build output to a different host.


2. Deploys must be additive #

Never delete old version directories from the origin. Publish the new build alongside them.

civ build writes each build to its own directory — /dist/4.4.8-012b4e36/, holding that build’s index.js and worker.js — and a user’s install stays pinned to the one it committed to until they choose to update (that’s the point: §18.1). By default (meta.autoupdate: 'never') they stay pinned indefinitely.

So a deploy that publishes only the current build — CI from a clean checkout, rsync --delete, a fresh container image — deletes the directory those installs are pinned to. They don’t fall back and they don’t roll forward; they 404 and render nothing. The service worker doesn’t save them, because its script lives in the same deleted directory, and losing it takes the offline cache with it. There is no in-app recovery: the code that could fix it is the code that won’t load.

The fix is to make the deploy accumulate:

# Good — adds the new build, leaves the old ones
rsync -a dist/ user@host:/srv/app/dist/

# Bad — the --delete flag removes every older version directory
rsync -a --delete dist/ user@host:/srv/app/dist/

In CI, the usual cause is a clean workspace rather than a flag: the runner has no dist/ from previous builds, so “upload dist/” publishes exactly one version. Either fetch the deployed dist/ before building (or cache it between runs), or use a host whose upload is additive by default.

civ build --prod warns when your outdir holds only the version it just built, which is what this looks like from inside the build:

⚠ dist contains only this build's version (4.4.8-012b4e36).
  If your deploy publishes just this directory ...

Old builds are small (the bundle for one version) and immutable, so keeping them is cheap. If you eventually need to prune, drop the oldest directories only, and expect anyone still pinned to one to be unable to load the app until they clear site data — check current.json’s versions array to see what a deploy actually retained.


3. Cloudflare (Pages, or Workers with static assets) #

Declarative, no code — drop a _headers file in your build output root (civility.json’s outdir, alongside index.html):

/dist/*/worker.js
  Service-Worker-Allowed: /

Adjust the path if you’ve customized outdir. Both Pages and Workers’ static-assets binding read _headers the same way.

If you’re already running a Worker script in front of the assets (so _headers isn’t in play — a Worker-generated response bypasses it):

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const response = await env.ASSETS.fetch(request)
    const path = new URL(request.url).pathname
    if (/^\/dist\/[^/]+\/worker\.js$/.test(path)) {
      const modified = new Response(response.body, response)
      modified.headers.set('Service-Worker-Allowed', '/')
      return modified
    }
    return response
  },
}

4. Bunny.net (Pull Zone + Edge Scripting) #

Attach a middleware script to your Pull Zone — onOriginResponse runs after the origin serves the file, before it’s cached and returned to the client:

import * as BunnySDK from '@bunny.net/edgescript-sdk'

const WORKER_JS = /^\/dist\/[^/]+\/worker\.js$/

BunnySDK.net.http.servePullZone()
  .onOriginResponse((ctx) => {
    const path = new URL(ctx.request.url).pathname
    if (WORKER_JS.test(path)) {
      ctx.response.headers.append('Service-Worker-Allowed', '/')
    }
    return Promise.resolve(ctx.response)
  })

Adjust the regex if you’ve customized outdir.


5. Any other static host #

Same pattern everywhere: match <distPrefix>/*/worker.js, add Service-Worker-Allowed: /, leave everything else untouched. Netlify’s _headers file uses the same format as Cloudflare’s (§3).

worker.js always sits one directory deeper than the origin root (the version directory), so no outdir layout avoids this. A host that can’t set a response header on a specific route — no _headers-style file, no edge function, no reverse proxy you control — can’t serve a Civility service worker; put a proxy (Caddy, nginx) in front of it, or pick another host.


Next #

  • UI<civ-version> surfaces update state once the worker registers correctly.