Upgrade guide
How to take Vigil updates after you've customized your copy.
The workflow
Treat the purchased source as an upstream remote:
git remote add vigil-upstream <your private access URL>
git fetch vigil-upstream
# Review what changed
git log --oneline HEAD..vigil-upstream/main
git diff HEAD...vigil-upstream/main --stat
# Merge (or rebase your changes onto the new version)
git merge vigil-upstream/main
Because customizations concentrate in predictable places (branding
files, globals.css, your own modules), merges stay
small. The architecture keeps upstream-heavy areas
(src/modules, src/worker,
src/db/schema) separate from buyer-heavy areas
(branding, landing, your custom routes).
After every upgrade
npm install # dependency changes
npm run db:migrate # new SQL migrations apply in order
npm run typecheck && npm run lint && npm test
Database changes always ship as ordered files in
drizzle/, never edit an existing migration; upstream
never rewrites one.
Version policy
- Patch/minor updates: additive migrations, no breaking service signatures.
- Breaking changes (major): called out in the release notes with a migration section, and service-signature changes are listed explicitly.
Upgrading to 1.26.0 (from 1.22.x)
The 1.23.0 to 1.26.0 releases ship as one line: scripted synthetics, service level objectives, runbooks and operations tasks. An installation on 1.22.x takes all four in one step. Four migrations, all additive: every statement creates a new table, type or index, or alters a table created in the same file. No existing row is read, rewritten or deleted, and a CI fixture per release builds a real database of the previous version, upgrades it over the top and asserts the existing rows survive byte-identical.
git pull
npm install
npm run db:migrate
On Docker Compose, rebuild rather than restart, because the images
changed:
docker compose build && docker compose up -d.
Expect the job-queue schema to shrink, once, at first worker
start: this release bounds queue retention, and the first boot drains
the inherited backlog. A large installation can reclaim gigabytes
of PostgreSQL; the deletion touches only the queue’s own
finished rows, never your monitors, checks or incidents. Nothing
new is required to keep running exactly as before: all four
features are opt-in per object. Browser journeys need the
synthetics runner container (a shipped Compose overlay) and a
shared token; the synthetics guide covers its trust boundary and
sizing.
Upgrading to 1.10.1
This release contains a security fix. A published status page showed incidents for every monitor in the organization, not only the ones on the page, and mailed their titles to subscribers, and an auto-opened incident is titled after the monitor, which is usually an internal hostname. The page leg has been live since 1.0.0; the subscriber email since 1.9.0, when subscriptions shipped. It needs no attacker: a published page, a monitor left off it, and that monitor failing. The full description and what to check afterwards are in CHANGELOG.md under 1.10.1 → Security.
Check what was exposed from your own Incidents list rather than from the public page, after upgrading, the public page is precisely what no longer shows it.
One additive, data-only migration (0011) and no
configuration or compose changes:
npm install && npm run db:migrate, restart,
done.
That migration repoints escalation steps that a deleted schedule (or a deleted user) left pointing at nothing. Deleting a schedule now rewrites its steps to "all responders" as it goes, but that only covers deletions performed from 1.10.1 onward, steps orphaned under an earlier version would otherwise keep an empty schedule picker in the policy editor, and that editor refuses to save the whole policy while one is present, which blocks unrelated edits to other rungs.
Either way this is a behavior change worth knowing about: a step whose on-call schedule has been deleted now pages all responders instead of nobody. If you deleted a schedule without repointing its steps, those rungs were silently paging nobody and will start paging your responder set. Settings → Escalation shows which.
Escalation steps queued before the upgrade, rungs of a ladder for an incident that is open right now, still resolve by their position in the policy. Ladders scheduled after the upgrade carry the rung with them, and re-check at firing time that its schedule or person still exists.
Upgrading to 1.10.0
This release contains a security fix. Escalation targets, a monitor's escalation policy, and a step's schedule and user. Were written and read without an organization check, from 1.8.0 through 1.9.3. One leg of it needs no attacker and affects single-organization installs: an offboarded person named in a step or a rotation kept being paged. Upgrading is sufficient; the full description and the two things worth reviewing afterwards are in CHANGELOG.md under 1.10.0 → Security.
Two compose changes come with it and need a
docker compose up -d --build rather than a plain
restart: the worker now gets an unprivileged ICMP socket (without
it, ping monitors report "unknown" forever)
and the app now receives DEMO_MODE.
Additive, as a minor release must be.
npm run db:migrate is the whole procedure; there is
no downtime step and no manual data fix-up.
Migration 0010 does five things. All of them preserve
what you configured, and a CI fixture
(tests/integration/upgrade-from-1.9.test.ts) builds a
real 1.9.x database, upgrades it and asserts so, including for a
monitor that is mid-outage when the upgrade lands.
1. monitors.check_type becomes
text.
It was a Postgres enum. Postgres refuses to use a newly
added enum value in the same transaction that adds it, and Drizzle
wraps every migration in one, so with an enum, shipping a check
type would have needed two deploys, forever. Values are cast
verbatim; validation moved to the check-type registry. The
practical effect: a monitor whose type this build does not know
now reads as misconfigured and stays editable and
deletable, rather than failing a column cast.
2. Failure thresholds become time-based.
failure_threshold (a count) is superseded by
failure_window_seconds. "down for 90
seconds". Counting consecutive failures only means something
while every check is the same distance apart, which stops being
true in this release.
Your monitors are converted to the window that reproduces their
existing behavior exactly:
(failure_threshold - 1) × interval_seconds. At a
fixed interval the Nth consecutive failure lands (N−1) intervals
after the first, so this is equality, not an approximation. A
threshold of 1 becomes a window of 0, open on the first failed
check, which is what a threshold of 1 already meant.
failure_threshold is
left in place, deprecated and unread, so this
migration stays additive and a downgrade to 1.9.x still runs. It
is removed in 2.0.
3. Check intervals stop being a fixed ladder. The six allowed values (60/120/300/600/1800/3600) become any value from 10 seconds to 24 hours, and the number you set is now a baseline: a monitor that looks suspicious is probed harder, one with a long clean run backs off. Your existing intervals are unchanged and remain valid.
Scheduling now reads monitors.next_evaluation_at,
seeded from where each monitor already was, so the first tick
after the upgrade does not stampede every monitor at once.
4. A nullable config jsonb column.
New check types keep their settings there. Nothing is backfilled
and no column is dropped; every 1.9.x monitor keeps
config = null and its flat columns.
5. Ledger columns on monitor_checks and
recovery_attempts.
Actor identity, a hybrid logical clock, a per-actor sequence and
hash chain, signatures[], and the spec version a
check was judged under. All nullable, and
existing rows are left null on purpose: a 1.9.x
observation had no actor and no chain, and backfilling one would
be a lie about who saw what. New observations are stamped from the
first check after the upgrade.
What to expect afterwards
-
Incident emails and timeline entries now say "had been
failing for 2 minutes" instead of "failed 3
consecutive checks". If you have customized
src/modules/notifications/email-templates.ts, therenderIncidentOpenedEmailinput tookfailureThresholdand now takesfailureWindowSeconds. -
recordCheckOutcomereturns{ monitor, reconciliation }instead of{ monitor, becameDown, becameUp }. Status is derived from observed state now rather than from transitions, so a monitor that somehow ended up down with no incident (or up with a stale one) repairs itself on the next check. If you called it directly, act onreconciliation, the calls it guards are idempotent by design. -
performChecktakes aCheckSpecbuilt bysrc/modules/monitors/spec.ts. Build it withtoCheckSpec(monitor)rather than by hand; two hand-written copies had already drifted. -
One new optional environment variable:
RDAP_BASE_URL, used only bydomain-expirymonitors. It defaults to the public bootstrap redirector; set it to a mirror or a registry in an air-gapped install. -
Ping monitors need an ICMP socket the worker is allowed to open.
The shipped
docker-compose.ymlgrants one withnet.ipv4.ping_group_rangeand the worker image installsiputils;cap_add: [NET_RAW]is not an alternative, because the worker runs as a non-root user and capabilities are not inherited without file caps. Without it, ping monitors report unknown with an explanation, never a false outage.
Signatures that changed
The version policy above promises no breaking service signatures on a minor. These are the exceptions, all of them internal. None is reachable from a route or a server action, and nothing in the UI calls them directly. Listed because a buyer who has customized the worker or the status page will meet them in a merge.
-
resolveStepRecipients,currentOnCallandescalationStepsForMonitorinsrc/modules/oncall/service.tseach take a leadingorganizationId. That is the security fix above: these are the three reads that turn an id into somebody's phone ringing, and they now establish the tenancy boundary themselves rather than trusting the id they were handed. -
DailyUptime.uptimePctisnumber | nullrather thannumber. Null means the day holds observations but none of them measured anything, which the strip renders as "no data" instead of as 0%. If you render it yourself, handle the null, coercing it withNumber()produces 0 and paints the bar red. -
PublicComponentgainedpaused: boolean, because the headline has to tell "switched off by an operator" apart from "cannot be measured". -
observationClaimtakes the observation's timestamp as a second argument, and the hash now coversok,checkedAtandfailureClass. Chains written by 1.10.0 are not comparable with any written by a pre-release build of it.
If a merge goes wrong
Your escape hatch is the service layer: as long as
src/modules/*/service.ts functions keep their
(db, actor, input) signatures, UI and worker from
either side keep working. Resolve conflicts there first, run the
integration suite (npx vitest run tests/integration/), then reconcile UI.