Deployment
This document separates supported deployment modes. They use the same static PWA artifact but do not offer the same HTTP-header or operational guarantees.
PWA Modes
nsite reference workflow
.github/workflows/deploy.yml builds tagged releases and publishes with nsyte using a NIP-46 bunker capability. This is a reference Nostr-native delivery path. It uses an SPA fallback and hash routing.
nsite gateways control their response headers. They cannot be assumed to provide the same CSP, anti-framing, cache-control, or other HTTP-header policy as a conventional host. Treat nsite as a delivery option, not as equivalent to a configured web origin.
Conventional static host
Serve packages/app/build/ from a configured static host such as nginx, Cloudflare Pages, or Netlify. This is the appropriate mode when the operator needs explicit response headers.
sw.js,index.html, andmanifest.webmanifest:Cache-Control: no-cache.- Hashed immutable assets: long-lived immutable cache control.
- Send CSP and
frame-ancestorsas HTTP headers where supported. A meta CSP cannot enforce anti-framing. - Verify the service worker update path after every deploy; users must not need a hard refresh.
Three nginx specifics, each of which bit the reference deployment:
manifest.webmanifestneeds an explicit content type. nginx's stockmime.typeshas nowebmanifestentry, so it goes out asapplication/octet-stream, which is a documented cause of a PWA refusing to install. Force it per-location with an EMPTYtypes { }block plusdefault_type application/manifest+json, an empty block avoids replacing the inherited type map for anything else.- Never use
add_headerin one of these locations. nginx drops every INHERITEDadd_headerat any level that defines its own, so a singleadd_header Cache-Control …silently strips HSTS,X-Frame-Options,X-Content-Type-OptionsandReferrer-Policyfrom the site-wide security snippet for those responses. Use theexpiresdirective, which does not have this effect. The cost is that you cannot add theimmutablecache hint; a longmax-ageon content-hashed filenames is the right trade. - An exact-match location outranks a regex one regardless of file order,
which is what keeps a generic
\.(js|css|…)$rule from puttingexpires 1donsw.jsand letting browsers answer their own update checks from cache.
Coordinator
Log rotation
packages/coordinator/nostrautica-coordinator.logrotate is the reference
logrotate fragment; install it as /etc/logrotate.d/nostrautica-coordinator.
It uses copytruncate, and that is not optional: the systemd unit writes with
StandardOutput=append:, which holds the file open for the life of the process
and never reopens it, so a rename-based rotation would leave the daemon writing
to an unlinked inode and the live log would silently stop growing. The daemon's
stdout descriptor carries O_APPEND, so after truncation its next write goes to
offset 0 rather than leaving a large sparse hole.
Without this the log simply grows forever, the reference deployment reached
~37 MB over seven weeks before anyone looked, on a filesystem shared with other
services. Note that restart-coordinator.sh greps this exact path for the
daemon's readiness marker, so the path must stay stable, and a rotation landing
inside a deploy's readiness window can make that grep miss.
Startup time
restart-coordinator.sh waits for the daemon's own readiness line
(watching for installs, submissions, admin commands) and reports how long it
took. Two thresholds, doing different jobs:
| default | override | on breach | |
|---|---|---|---|
| soft | 60 s | COORDINATOR_READY_WARN |
prints a warning, deploy still succeeds |
| hard | 240 s | COORDINATOR_READY_TIMEOUT |
fails the deploy step |
The hard limit is deliberately far above anything normal. It does not bound a crash (the readiness loop fails within a second when the unit goes inactive, whatever the limit is). It bounds a daemon that is alive and never becomes ready. A limit set too short is the worse failure: the script does not kill anything, so the daemon starts normally while the deploy prints "coordinator is NOT running" and sends whoever pushed into an incident that is not happening.
The soft threshold is the part worth watching. The limit was 45 s until a 2026-09-04 deploy came in at 44 s, which is not a margin.
What actually drives it is per-event startup work, not inbox backfill. That
correction is worth stating plainly, because this section previously blamed the
coordinator's own inbox backfill growing with the daemon's wrap history (98
wraps on one boot, 153 two months later) and told you to go read the [boot] … backfill counts. A 2026-09-10 restart measured 52 s with the wrap count down
to 45 — startup rose while the thing it was blamed on fell, so that story cannot
be the explanation. Timing the phases from one boot's log:
| phase | cost |
|---|---|
per installed event: install → invite-list refresh → 30443/445 roster scan → E_inbox backfill |
~9–12 s each, serialized |
| coordinator-inbox backfill (45 wraps) | ~5 s |
| kind 31611 announce publish | up to ~4.4 s |
Three installed events is most of the budget, and a fourth adds roughly another
ten seconds. The announce is the cheap fix if one is ever needed: main.ts
awaits it between coordinator.start() and the readiness line, and
NostrClient.publish resolves on the first relay ack — but when no relay acks
(all five timed out on that boot) it pays nostr-tools' full publish timeout
before the daemon reports ready. Nothing waits on that announcement.
So when the warning fires, time the phases rather than counting wraps:
ssh <coordinator-host> 'grep -av "not an authorized chat identity" \
~/log/nostrautica-coordinator.log | tail -60'
Read the timestamps between consecutive [install] lines. If they are ~10 s
apart and there are simply more events than there used to be, the fix is making
per-event startup concurrent or deferring the roster scan past the readiness
line, not buying more seconds.
Runtime
The Dockerfile and systemd unit are reference deployment material, not a complete production runbook. The coordinator needs Node 22.5+, ffmpeg/ffprobe, protected provider credentials, a stable coordinator identity, relay connectivity, and durable writable SQLite storage.
The SQLite volume is not disposable when Marmot chat or Cashu is enabled. See COORDINATOR-OPERATOR-GUIDE.md for backup, restore, lifecycle, and operational limitations.
Reference Production Instance
The public reference instance is an operator-managed conventional static host plus coordinator deployment. Its host-specific commands, identities, and secrets are intentionally private and are not a portable deployment recipe. Do not assume that its deployment mechanics or HTTP headers apply to an nsite deployment.
Release Verification
Run
pnpm check.Build the PWA and run a clean-context coordinator Docker build: it must succeed from a checkout with no working-tree state:
rel="$(git describe --tags --always --dirty)"; sha="$(git rev-parse HEAD)"; ts="$(git show -s --format=%cI HEAD)" git archive HEAD | (mkdir -p /tmp/cc && tar -x -C /tmp/cc) docker build -f docker/coordinator.Dockerfile \ --build-arg RELEASE_ID="$rel" --build-arg GIT_SHA="$sha" --build-arg BUILD_TIMESTAMP="$ts" \ /tmp/ccThe image's dependency layer copies the lockfile,
patches/, and the vendored Marmot/MLS workspace packages beforepnpm install --frozen-lockfile, and pins the base image by digest, so this build is reproducible from a clean checkout. Keep it in the release gate. Agit archivecheckout has no.git, so the coordinator's own git-describe provenance is unavailable inside the container: pass theRELEASE_ID/GIT_SHA/BUILD_TIMESTAMPbuild args (as above) so the running daemon reports the real release instead ofv<pkg>/gitSha: unknown. OCI image labels (org.opencontainers.image.*) are set from the same args.Verify app and landing HTTP status, CSP/header policy for the chosen host, and service-worker update behavior.
Exercise an event flow appropriate to the release: join, approval, media where applicable, and coordinator health.
Record the Git revision and deployed app/coordinator versions.
Rollback
Every deployment mechanism in this document is one-directional: it describes how to ship a newer build and says nothing about getting back to an older one. That is the wrong shape for an incident, where the decision is usually "put back what worked" and the time to discover the procedure is not while the site is down.
Decide first whether the coordinator database has migrated. This is the only
step that is not reversible by re-deploying code. The store refuses to open a
database whose user_version exceeds what the binary knows, so rolling the
coordinator back past a schema bump means restoring a database file, not just an
older build:
# On the coordinator host — what schema does the live DB carry?
sqlite3 -readonly "$HOME/nostrautica/packages/coordinator/coordinator.sqlite" "PRAGMA user_version;"
# What does the build you want to roll back TO know?
git show <target-ref>:packages/coordinator/src/store/db.ts | grep 'SCHEMA_VERSION ='
If the live number is higher, you need the pre-migration backup (the operator notes' migration checklist takes one before every schema-bumping push, named for the version it CONTAINS). No backup, no rollback, go forward with a fix instead.
Static site (landing / /app / /docs). The build is a pure function of the
commit, so rolling back is re-deploying the older commit; nothing on the serving
host holds state.
git push origin <good-sha>:main --force-with-lease
--force-with-lease, not --force: it refuses if someone else has pushed since
you last fetched, which during an incident is exactly the case you cannot afford
to guess about. Then verify the served bundle is the one you meant, using the
marker-grep in the release-verification section above: the OK markers only prove
a build ran.
Coordinator. The same push redeploys and restarts it. Two things to check afterwards, in this order:
ssh <coordinator-host> 'systemctl --user is-active nostrautica-coordinator;
systemctl --user show nostrautica-coordinator -p NRestarts --value'
NRestarts climbing means the daemon is dying and being respawned, with
Restart=always that looks identical to "up" from the outside, and a rollback
onto a database it cannot open is one of the ways to get there.
Restoring a database. Stop the daemon first: a copy taken or placed while it holds the WAL is not a consistent database.
ssh <coordinator-host> 'set -e
systemctl --user stop nostrautica-coordinator
DB="$HOME/nostrautica/packages/coordinator/coordinator.sqlite"
mv "$DB" "$DB.failed-$(date +%Y%m%d-%H%M%S)" # keep it — it is the only copy of what went wrong
rm -f "$DB-wal" "$DB-shm" # stale WAL/shm belong to the file you just moved
cp "$HOME/coordinator.sqlite.vN-<timestamp>.bak" "$DB"
systemctl --user start nostrautica-coordinator'
Keep the failed database rather than deleting it. It is the only record of the state the incident happened in, and a migration that corrupted data is diagnosed from it, not from the backup that replaced it.
What a rollback does not undo. Anything already published to relays (a roster, a directory entry, a match list, a coordinator status) is out and stays out; replaceable events can be superseded but not withdrawn, and gift wraps cannot be recalled at all. Provider spend is likewise spent. So a rollback restores the code and the local state, not the world's view of what happened, which is worth saying to an organizer before they ask.