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).
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.