Changelog
All notable changes to Vigil are documented here. Versions follow semantic versioning: breaking changes bump the major, additive features the minor, fixes the patch.
1.29.0 — 2026-08-25
An incident that opens now writes down what was known at that moment: the observation that failed, the last one that succeeded, what changed between them, what four read-only probes found when they re-checked the target, and which other monitors were failing for a reason we can name. Both editions.
Added
- A snapshot per incident, written once at onset. Everything the answer is derived from expires: observations are pruned at ninety days, the monitor’s own history is rewritten by every later check, and the correlated failure elsewhere in the fleet has recovered by the time anyone reads the incident. So it is copied into a row of its own, keyed on the incident, and kept for a year after the incident resolves.
-
The failing layer, always with its basis. DNS, TCP,
TLS, HTTP, application, browser — or
unknown, alongside how it was established: measured by a diagnostic, named by the failure itself, or proven by the check. A bare timeout names no layer, so it staysunknownunless a diagnostic resolves it. Guessing would put a sentence on an incident page that is wrong often enough for an operator to stop reading the field. - What changed since the last success. A status code that moved, a certificate that stopped being reported, a response time that doubled. Durations are filtered by ratio so ordinary jitter does not crowd out the fact that actually moved.
- Related failures, by rule rather than by score. Time proximity within ten minutes and at least one strong shared signal — hostname, registrable domain, resolved address, failure signature, or a remote probe location that did not see the target healthy. Every signal carries the value it matched on, so the claim can be checked rather than trusted. A shared timeout and a shared HTTP status do not count: they are what unrelated monitors have in common every day.
- A bounded onset diagnostic. At most four read-only probes — resolve, connect, handshake, request — one socket each, no redirects followed, five seconds of budget raced against per-step deadlines, two at a time per worker. It writes no observation, moves no monitor status, touches no incident, pages nobody, feeds no objective and reaches no status page, and it runs after the page has gone out rather than before it. Shadow mode refuses it outright.
What it does not do
- No language model looks at any of this. The layer is named from codes a resolver, a kernel or a TLS stack actually emitted, and “we do not know” is a normal answer.
- Nothing here reaches a public status page. The snapshot is operator-facing, and it holds no request bodies, no cookies and no credentials.
1.28.1 — 2026-08-25
A packaging fix, with no change to the product. Vigil Core 1.28.0 was
published with vigilctl committed non-executable, so a
clone of that tag could not run ./vigilctl. The
commercial edition was never affected.
Fixed
-
The mirror was taking file modes from the disk instead of from
git.
The Core tree is built by piping
git ls-filesthrough tar, and tar reads permissions off the filesystem. On Linux that is accidentally right, because checkout materialises the mode the index recorded. On a host with no executable bit there is nothing to read, and the whole chain quietly produces644. Files Core already tracked kept the mode already recorded against them, which is why it took eleven releases and the first new executable file to show. -
The mode now comes from the commercial git index.
One script reads
git ls-files -sand writes the answer withgit update-index --chmod, both of which behave identically on every host, so the fix is not conditional on the platform. A file in the mirror is executable if and only if the source records the same path as executable; both directions are applied, and publication now fails rather than shipping a mirror whose modes disagree with the source. - Two bench scripts get their executable bit back, having been wrong in Core since 1.26.0 for the same reason.
What it does not do
-
The Core
v1.28.0tag was not moved, deleted or rewritten. A published release pins its tag, and that guarantee is worth more than a tidy history; the tag’s own release notes state the mode and the workaround, and this release is the upgrade.
1.28.0 — 2026-08-24
vigilctl: the lifecycle of a self-hosted install, as one
command. Install, doctor, backup, restore, update, rollback —
the sequence that used to live in a document, with the refusals in it.
Bash and Docker are the only requirements, because the host that runs
the supported install has Docker on it and frequently no Node and no
Postgres client. Ships in both editions.
Added
- Four exit codes, the same for every command. 0 succeeded, 10 nothing to do, 20 refused with one actionable reason and nothing changed, 1 tried and did not finish. The distinction between 20 and 1 is the one that matters at 3am: a refusal is safe to ignore or to override and a failure is not, and “did it print the word success” is not a contract a wrapper script can hold.
-
An install that repairs, and does not bounce.
Installs get interrupted, and the recovery from every way that
happens is the same command. A repeat run compares a checksum of the
.envthe stack was started with and exits without touching a healthy installation. Secrets are created once and preserved after that, including never inventing a new database password for a volume that already has one. Health means the app answering/api/healthand the worker recording a scheduler pass, not a container reachingrunning. -
A doctor that changes nothing. Every call it makes
is a
ps, aSELECTor an HTTP GET: prerequisites, config, services, the migration journal against the migrations in the checkout, app health, and whether the worker is still scheduling — which nothing else notices, because a dead worker looks exactly like everything being fine. -
Updates with a way back.
update --tonever picks a version for you, takes a verified backup and writes the rollback record to disk before the checkout moves, and an interrupted run is finished by re-running it rather than restarted.rollbackmoves the checkout and the database together; it restores the pre-update dump rather than inventing down-migrations, and says out loud that anything written after the update is not in it. -
It fails closed, with one reason and one next action.
An auto-loaded override or an edited compose file, a project running
overlay services it was not given, or a checkout that cannot be
moved.
--allow-customizedis the escape for the first two, and a customized copy still upgrades the way the upgrade guide describes. - Proved on real containers. A required CI job runs install, repeat install, seeded data, backup, a corrupt-archive refusal, restore, update, a rollback that removes what came after it and brings back the rest, an update that cannot build, and a rollback from that. A unit suite drives the same CLI against a Docker stub to assert the order of operations: the archive is validated before anything stops, the safety dump precedes the restore, a refusal leaves nothing in the log.
Fixed
-
The documented backup path was unusable on the deployment it
documents.
backup.sh --dockerdumped through the container and then validated the archive with the host’spg_restore, which cannot read what the container’s newerpg_dumpwrites. It took a good backup, declared it unreadable, deleted it and failed. The reader matches the writer now. - A forced restore of a real Vigil dump always reported failure. Three constraint drops fail as a matter of course on any database that has run a worker, and the script read that as fatal after restoring everything correctly. The error classes a clean pass produces by itself are now tolerated; anything else still fails, with the offending lines quoted.
- Three test-harness defects that made results mean less than they looked, including a suite whose sixty-one cases could not spawn a shell on Linux and passed only on the machine that wrote them.
1.27.0 — 2026-08-24
The Better Stack migration bridge. The one-time importer answers “can my monitors move”; the bridge answers “can I trust the move”, and the difference is evidence: connect a Better Stack account read-only, import into a non-paging shadow mode, run both systems side by side, and get a frozen cutover report with a SAFE or NOT SAFE verdict and every reason written out.
Added
- Shadow mode, stored rather than inferred. Everything a bridge import creates detects and records like any other monitor and announces nothing: no pages, no channels, no recovery actions, runbooks or escalation ladders, no public status page presence, no error-budget consumption. Suppression reads stored columns, so a deleted bridge or a mid-flight cutover cannot silently change what is allowed to make noise.
- Evidence, with its gaps on the record. A worker job polls Better Stack’s incident history read-only every fifteen minutes, follows up by id on copies that leave the list feeds so a multi-day outage’s resolution is observed, and records every poll, failures and unparseable feeds included. A recorded source outage is evidence by itself; claims of source silence require coverage; whatever fits neither is reported unprovable rather than counted for either side.
- A conservative verdict. SAFE requires at least 24 hours of overlap between evidence and observation for every compared pair, no provable misses, no live disagreements, and a healthy evidence feed. Losses, manual work and heartbeat repointing are listed by name. An operator can still cut over against a NOT SAFE verdict; the report’s job is to make that a conscious decision.
- Heartbeats now import. The Better Stack adapter reads heartbeats and heartbeat groups as Vigil push monitors, one new capability row, and never reads the token in the ping URL.
What it does not claim
- Alerting fan-out is not compared (who Better Stack would have paged is not read), response times are not compared (the source API holds 24 hours at most), and status pages, escalation policies and on-call calendars still migrate by hand, exactly as the import report says.
1.26.0 — 2026-08-23
Operations tasks: the human half of the runbook engine. A task is one piece of work a person has to do, written down beside the work automation does by itself, with a state, an owner, a deadline and a cause. It lands in an inbox, and the record of who had it and what they said outlives the incident.
Added
- An operations inbox, not a project tracker. No projects, no boards, no milestones, no dependencies between tasks and no comment threads. What a task has is five states, an append-only event log of who did what and when, a bounded checklist, and links back to the incident, monitor, maintenance window or runbook run it came out of.
- A runbook can hand work to a person and wait. Two new typed actions in the same registry, not a second engine: one writes the work down and carries on, the other writes it down and suspends the run until somebody answers. Waiting costs a row rather than a held worker, exactly as an approval does.
- Recurring operational work, with a bound. Daily, weekly or monthly, at a local wall time in a named time zone, expanded by the same daylight-saving arithmetic the maintenance scheduler uses. A task appears as the work becomes owed rather than thirty days early, and a worker that was down has an explicit policy for what it missed: skip it, or create the most recent one marked late. Never a month of somebody’s daily checks in one pass.
- Deadlines that page. A reminder before, an alert after, each sent exactly once, routed by whichever channels subscribe to the new Operations tasks event class. No separate escalation ladder: late work goes where your routing rules say.
What it does not claim
- A completed task is not a verification. It records that a person said they did something. Whether the service actually recovered is still the business of a verification step reading what Vigil observed, and a plan that needs to know puts one after the hand-over. Nothing lets a checkbox become evidence about a system.
- Work owed is not work cancelled. A runbook run that gives up waiting does not close the task. Automation running out of patience is not evidence that the work stopped being necessary, so the task stays open and the timeline records that nothing is waiting for it any more.
Fixed
- Two scale problems found by measuring rather than by guessing, and fixed before the release. The inbox read every task the workspace had ever closed to show fifty; it now reads fifty. And the recurrence pass stopped after a hundred templates, so a workspace with more than that got no tasks from the rest, silently; it now works through all of them.
- The Web Push settings check now catches a key pair whose halves come from two different pairs, instead of accepting it and leaving you to find out from notifications that never arrive.
- The multi-gigabyte database was the job queue, and it is bounded now. The queue library’s defaults keep every finished job seven days and delete once a day; on a large installation checking every minute that is millions of rows holding work whose outcome Vigil already recorded elsewhere. Finished high-churn jobs are now deleted after an hour, the sweep runs every ten minutes, and an upgraded installation drains the inherited backlog on its first start. A 1,000-monitor install’s queue table drops from roughly ten million retained rows to under ninety thousand. Measured before and after on a seven-cell soak campaign whose raw artefacts ship in the repository; memory, throughput and latency are equivalent, and worker event-loop utilisation fell 8 to 16 percent under load.
- Steady state under abuse. Response bodies a check or a delivery does not need are drained under a fixed cap and an errored stream destroys its socket; the synthetics runner admits or refuses work before reading the request body, keeps a ledger of every browser context and recovers leaked ones, and recycles the browser at idle. Half an hour of journeys engineered to exhaust their budgets mid-run leaves no orphan browser process behind.
1.25.0 — 2026-08-20
Runbooks: the automation you would otherwise perform by hand while an incident is open, written down once and executed with a record of what it did. Something happens, a runbook that governs it starts, and it walks a list of typed steps.
Added
- Typed steps, and nothing else. There is no expression language, no template interpolation, no shell and no user code of any kind. A runbook is data validated by a schema, and the only things it can do are the twelve actions the product implements: notify, escalate, call a webhook, wait, check a monitor now, run a scripted journey, fire the monitor’s recovery endpoint, verify a monitor, a service or an objective, ask a person, and write on the incident timeline. An automation you cannot audit is one you should not arm.
- Forward only, so it cannot loop. Every branch target is a later step or a terminal state, which means the number of external effects one trigger can cause is bounded by the plan somebody wrote rather than by a runtime guard.
- A run pins a version. Publishing produces an immutable version, enforced by the database rather than by convention. Editing a runbook while a run is in flight changes nothing about what that run is doing or what its timeline says it did.
- It tells “it failed” from “nobody knows”. Every attempt is written before anything is dispatched, and the moment a request leaves the process is committed before it leaves. A worker that dies before dispatching leaves work that is simply tried again; one that dies after leaves a run stopped in unknown, with nothing attempted automatically and a person paged. A refused connection is not unknown, because nothing was sent.
- Approvals bound to what the approver saw. A decision names the run, the step and the version, and one that does not match all three is refused. Two approvers clicking in the same second produce one decision and one “already decided”. Silence is a refusal, because a gate that proceeds when nobody answers does nothing on the night it exists for.
- Two runbooks cannot remediate the same thing at once. A step that acts takes a lease keyed on what it acts on, so a second run waits rather than landing its restart while the first is still coming up. Unrelated work is untouched.
- Waiting costs a row, not a worker. A step waiting on a timer, an approval or a busy resource releases its lease, so a four-hour approval window survives a rolling restart for free.
Unchanged
- The per-monitor recovery action is exactly as it was. The two automations were not merged: merging them would have meant rewriting a shipped remediation path to gain nothing anybody asked for. What they share is every primitive that matters, including one implementation of a signed outbound request and one judgement about what a failed one means.
Nothing here is exactly-once and the product does not claim it is. External effects are at-least-once with a stable idempotency key: every attempt of a step carries the same key, so a receiver that honours one collapses the duplicate.
1.24.0 — 2026-08-20
Service level objectives, error budgets, and multi-window burn-rate alerting. An objective is a target, a rolling window and a budget that is spent when the target is missed — and an alert that fires while the budget is going, rather than a report that says it went.
Added
- Objectives over a monitor, a service or the workspace. Availability or latency, a target above 0% and below 100%, and a rolling window (7, 28 and 30 days offered by name). Compliance is the same duration-weighted number the monitor list and the status page already report, not a second opinion — a test pins the two together on the same evidence, because a client holding a report saying 99.4% next to a status page saying 99.1% is the one outcome this feature could not survive.
- Explicit answers for everything that is not a clean pass. Degraded counts as serving by default and can be told to spend budget instead. A latency objective counts a failure as bad however fast it failed, and refuses to call an observation that measured no round trip fast. Time nothing vouched for is reported as uncovered rather than as downtime, and an objective with no evidence says no data, never 0%.
- Planned maintenance is a policy of the objective. Independent of whether a maintenance window silences the pager: the two answer different questions, and neither reads the other. It is a read-time choice, so flipping it re-reads every past day instantly with nothing recomputed.
- Burn-rate alerts that watch two windows. The long one decides whether enough budget has gone to be worth a page; the short one decides whether it is still happening, so an outage that ended twenty minutes ago stops paging. Three rules are created with every objective, sized for its window. Firing and clearing use different thresholds, the clear condition has to hold uninterrupted, and a rule that flares back up does not send a second page for the same outage. Too little evidence and the rule declines to move in either direction.
- No new alerting seam. Routing policies govern a burn alert, maintenance windows can silence it, on-call ladders are reachable through the routing target, and the message carries its own working: both burn rates, the threshold, budget spent, projected exhaustion, the monitors in scope and the incidents that overlap. Several workers cannot page twice for one episode.
- Ninety days of compliance is about 160 rows. Objectives materialize into five-minute, hour and day buckets, and a window read picks the coarsest that tile it. Measured: a hundred objectives over two hundred monitors render in 99ms and cost 1.1s of background work a minute.
- Client reports carry an objectives table, measured over the report’s own period rather than over the rolling window. Objectives are deliberately not on public status pages: a target is a term between you and your customer, not a promise to the public.
Fixed
- Durations past forty-eight hours now read in days. A four-day incident said “97h 12m”.
1.23.0 — 2026-08-19
Scripted synthetics. Two new check types watch a sequence rather than an endpoint: sign in, act, read back, and assert on each answer. The failures that cost money are almost never one endpoint returning 500 - they are a token endpoint that stopped issuing tokens, a create that succeeds and a read-back that cannot see it, a checkout that returns 200 with an empty body. None of those is visible from one request.
Added
- API journeys. Multi-step HTTP sequences with typed assertions on status, headers, body and JSON paths, values chained from one step into the next, and a per-run cookie jar so a sign-in step actually signs in. They run inside the worker through the same egress guard every other HTTP check uses, so they need nothing installed.
- Browser journeys. A real browser driven through a sign-in, a search or a checkout, asserting on what a customer would see, with screenshots kept as evidence. The browser runs in a separate container this release ships a Dockerfile and a Compose overlay for.
- A journey is data, never code. No scripting surface: no expression evaluator, no template with function calls, no regular expressions. What that buys is that the whole journey is checked before it ever runs - every reference resolves, nothing reads a value a later step writes, and no two steps claim one name. A journey that saves is one whose variable graph is already proven.
- Named secrets, and evidence you can show people. Credentials are edited beside the steps that use them and never travel back to a browser. Run history records every step, its timing, the step that failed and its screenshots; request header values, cookie values and extracted values are never stored at all.
- Check now, for every scheduled check type, and Duplicate, which copies a monitor paused with its credentials intact.
Unchanged
- Real browser monitors. Nothing is migrated and nothing is rewritten. Converting one to a journey needs a runner, an allow-list and a journey, so it stays your decision.
1.22.1 — 2026-08-18
The night scheme, recut in Vigil's own charcoal - warm grays from the porcelain's cloth, nothing pure black, states a step quieter. And a rule the first cut broke is now enforced: a dark page never shows a light screen.
Fixed
- Dark pages showed light screenshots. Every product capture this site embeds now exists twice, and the page swaps in the charcoal twin when your system prefers dark. The printed client report stays white in either scheme - it is paper.
- The demo ignored your system's theme. The exported demo used to be pinned to the theme of the machine that captured it. It now follows the visitor: dark system, dark demo.
1.22.0 — 2026-08-18
A night scheme. Light stays the approved porcelain, and under a dark system preference the product and this site render the same structure on GitHub Primer's dark palette, switching live when the system does.
Added
- The whole product follows the operating system's color scheme. Console, status pages, site and docs all pick porcelain or Primer dark from the system preference - a token remap, not a redesign, and the status page's manual toggle still outranks the system. Every page declares a theme color per scheme, so the browser chrome matches the ground the page stands on.
- Vigil Core's README shows the product. The free edition's README carried one screenshot where the commercial one carried ten. It now has the same grid, and every capture in it was taken from a running Core build.
Fixed
- The 404 page rendered unstyled for most URLs that reach it. Its stylesheet link was relative and a 404 is served at whatever depth the missing URL had. The references are absolute now.
1.21.0 — 2026-08-12
Capacity, and a way to tell whether monitoring is running. The release started as high availability and the measurements turned it into something narrower and more useful.
Fixed
- A fleet that was behind stayed behind, and nothing said so. Measured before it was changed: 1000 monitors on a 60-second interval need 16.67 checks a second, one worker delivered 10.10, the backlog aged to 185 seconds and never cleared, and 4% of the fleet went unprobed for the whole window. Nothing failed and nothing logged; uptime was simply computed over less of the window than it should have been. Throughput is not what changed, since both builds keep up from two workers on. The backlog is: its age at two workers went from 106 seconds to 2.1, and the catch-up burst the old build never drained now clears in under a minute.
- A tick re-enqueued monitors whose checks were still running. The queue permits one waiting job beside the running one, and selection had no way to know it had already asked. The tick now claims what it selects, so two schedulers running at once take disjoint sets without knowing about each other.
- Ordinary monitors were scheduling themselves down a path meant for sub-minute ones. A check that finishes decides whether the next one is due before the next tick, and it decided that by reading the clock a moment after both timestamps had been written. A 60-second monitor, which is the default, measured a hair under 60 seconds and took the fast path every single time - a path that enqueued without claiming, and so outside the exclusion the fix above provides.
- The scheduler kept reselecting monitors it could never schedule. A monitor the high-frequency plane owns is never given a new due time, so it sat permanently at the head of its workspace's queue, was filtered out, and the slot was thrown away. They stand down for a tick now - on the live lease and never on the flag, so a monitor whose fast worker died still falls back to the ordinary cadence instead of to silence.
- One workspace with a large fleet starved the ones beside it. Selection is ranked within the organization now, as the notification queue already was.
Added
- A worker fleet view. Every worker heartbeats, and a page says whether monitoring is running, how far behind the scheduler is, and what each replica last did. With the worker dead, nothing used to say so: checks stopped and every page went on showing the last status every monitor had.
- A reproducible capacity benchmark. It spawns the workers itself and writes an artefact carrying the commit, the machine and the caveats that say what the number is not. The published table is checked against it in CI.
1.20.0 — 2026-08-12
Planned work, and a written answer to “why did nobody get a message”. Two features that had to arrive together, because a maintenance window that does not participate in routing is a window that silences one of the three audiences and looks like it worked.
Added
- Maintenance windows. Planned work declared in advance: one-off or repeating daily, weekly or monthly, scoped to the workspace, a service or named monitors. Recurring windows carry an explicit IANA time zone and are computed in it, so “every Sunday at 02:00 Europe/Berlin” is still 02:00 in Berlin after the clocks change rather than drifting an hour twice a year. Checks keep running throughout and every observation is still recorded, so uptime, the check history and the incident timeline stay complete and you can see exactly what the work took down. What stops is the alerting. An incident opened under a window is held rather than swallowed, and the moment the window ends it is paged for if it is still open.
- Alert routing policies. One decision about who hears about what, written once and assigned to the workspace, a service or a single monitor, with the closest assignment winning. Rules match on event class, event name and severity and send to any number of notification channels or to an existing on-call escalation policy. Exactly one policy governs any monitor, so two assignments can never both fire. An installation with no policies behaves exactly as it did.
- Why an alert was or was not sent. Every dispatch records the decision that produced it, with the policy, the assignment level, the rule that matched, and what happened to each destination: queued, disabled, or pointing at a channel somebody deleted. Shown newest first and unfiltered, because the interesting row is almost always the one that says nothing was sent.
Changed
- A deleted routing destination fails visibly. The rule keeps a target whose pointer is gone rather than losing the row, so the audience never narrows quietly because something else was deleted, and the remaining destinations still fire.
- The importers no longer claim Vigil has no maintenance windows. Five source systems’ maintenance schedules were reported as unimported with that sentence attached. It is now false, so it is gone. The windows are still not imported, and each is still reported by name so it can be recreated deliberately.
1.19.0 — 2026-08-12
The migration system, and an audit of what the product claims about itself. Two of the defects below are the kind that make a monitoring tool report the wrong thing, and one of those reported it as healthy.
Added
- Migrate from fifteen monitoring services. Fourteen hosted providers through their own APIs, plus an Uptime Kuma database file. Every source check is translated, reported on by name, and either imported or refused with a reason. A check Vigil cannot honestly reproduce is listed rather than approximated: a monitor that watches something subtly different from what you asked for is worse than one that was never created.
- Every check type is configurable from the dashboard. A type now declares its own settings as data and the monitor dialog renders them. Twenty-four of the forty types previously stored settings no control could reach, so a monitor created here took whatever the schema defaulted to and there was no supported way to change it. The conformance suite compares the dialog against the registry from five angles, so a type cannot ship with a setting nobody can enter.
Fixed
-
A read-only member could read a database password out of the page
source.
A
postgresorsqlservermonitor is addressed by a connection string, and the placeholder tells the operator to put the password in it. The monitors list and the monitor page both crossed that string into a client component, so it landed in the page source of anyone who could open them, including a viewer, whose permissions are otherwise empty. The target is now stripped of its credential on every read path, masked rather than hidden in the edit dialog so the operator can still edit around it, and restored from storage when the mask comes home untouched. The audit trail and the export carried their own copies; both are closed. -
A password-protected Redis read as healthy. With no
password control the probe sent a bare
PING; a server wantingAUTHanswers with an error rather than a pong, the assertion declined to judge a non-boolean, and the verdict was up. This is the worst shape a monitoring defect can take. - Nine check types reported false outages against a target that needed a setting the dialog could not supply, and five more reported a permanent misconfigured state. Both classes are closed by the configuration work above.
- Running an Uptime Kuma migration twice created a second copy of every monitor. There was no dedup on that path and no constraint behind it, so an operator who fixed three refused checks and imported again got three fixed monitors and a duplicate of all the others, each probing the same endpoint and paging twice for one outage. Monitors now record which source record they came from, as a digest rather than the id itself, with uniqueness enforced by Postgres.
-
A synchronous connect failure could kill the worker.
The pinned transport answered DNS inside the caller’s stack,
so a host with no route raised
ENETUNREACHbefore the error listener was attached. An error event with no listener is an uncaught exception, and the process that died was the one doing the monitoring, at the moment the network broke.
Changed
- What the site says about the two editions is now checked against the code. The page listing what the licence buys named the four-role RBAC, the audit trail and the branded status page; all three are free in Core. Remote probes, on-call and branded client reports are commercial-only and were on no list at all. Claims now carry a key that resolves to a module the strip actually deletes, checked in both directions.
- The offer describes a delivery rather than a duration. Vigil is a copy of the source, delivered at checkout, running on your servers, with no account and no key check. What you pay once for is unchanged and says so.
1.18.3 — 2026-08-04
Findings from an adversarial security review of the whole system. Four defects, three of them on paths that are reachable without an account.
Fixed
-
The outbound egress guard failed open when its own DNS lookup
failed.
A lookup that threw became an empty address list; the classification
loop then had nothing to refuse, no address was pinned, and the
request was handed to the global
fetch, which resolves a second time with no classification and no pin. The premise written into the code was that the transport would “fail identically”; it is a separate lookup and is free to succeed, so one dropped packet was enough — and an attacker-controlled nameserver did not have to stop at one. It affected every HTTP-family check, every HTTP notification provider, webhook delivery and recovery triggers, including the checks channel whose shipped policy forbids private space. A hostname that does not resolve is now refused, carrying the resolver’s own error, so a domain that stopped resolving is still reported as down. - A notification worker that had lost its lease still sent. The renewal is fenced, but it returned nothing, so an update matching zero rows — the signal that somebody else owns this delivery now — looked exactly like success. The fence then refused to record the outcome, which protects the ledger and not the person whose phone rang twice. The shipped defaults reach this unaided: a 250-row batch at four concurrent with a ten-second timeout runs past the ten-minute lease. Losing the lease now skips the send.
- The status-page password gate was unthrottled. It verifies with a deliberately expensive, synchronous hash on a public endpoint that needs no account — about thirty requests a second to saturate a process and stall every other request in the deployment. Now capped per page, far above anyone typing a shared password, and a refusal is indistinguishable from a wrong password so it reveals nothing about which pages exist.
- The rate limiter never released a key. Its keys arrive with the request — a push token, an email address, a page slug — and nothing removed them, so guessing tokens grew the map for the life of the process. It is bounded and swept now, and the push endpoint hashes the token before using it as a key, which the probe endpoint already did.
Changed
-
Next.js 16.2.10 to 16.2.12, which clears seven advisories.
shadcnmoved to development dependencies: nothing imports it at runtime, and it was the only reasonundiciwas in a production install.
1.18.2 — 2026-08-04
An incident transition and the record that its consequences are owed now commit together. Everything below follows from that one change.
Fixed
- A worker that died after claiming an incident’s notification left it open, marked notified, and silent — permanently. The claim is exactly-once by design and committed on its own; the notifications were assembled afterwards. A process killed in that tail had already spent the claim, and the repair path requires an unspent one, so nothing could ever page for that outage. The claim and a durable dispatch intent now commit in one transaction.
- The all-clear could be lost the same way. Every repair predicate reads “not resolved”, so a resolved incident is invisible to all of them: subscribers told an outage had started were never told it ended, and nothing would notice. The resolve now writes what it owes in the transaction that closes the incident.
- Two operators posting updates during one outage produced one broadcast. The key identifying the transition was read after the commit, so both callers computed the same one and the second was silently dropped. It is now the id of the row the transaction inserted.
- Deleting a monitor mid-outage told nobody. The orphaned incident was closed and nothing was announced, so status-page subscribers were left believing the outage was live, permanently.
- An operator resolving an incident by hand sent no all-clear to the people who had been paged. Only the automatic path sent one, so anyone woken at 3am by an auto-opened incident a human then closed was never told it was over.
- An escalation rung could page the same person twice, including by phone. The job expires after 120 seconds and is re-run without being cancelled, and the rung paged recipients one at a time with no idempotency key anywhere. Escalation SMS and voice are now outbox rows keyed per recipient — which also gives them the retry and the ledger they never had.
- A recovery trigger fired against a world that had changed under it. The action, monitor and incident were read once, before a probe that may take the check’s whole timeout. Resolving the incident, pausing the monitor, disabling the recovery action or rotating its secret in that window changed nothing. All four are re-checked immediately before the trigger goes out.
- A stale recovery verification still acted. Jobs now carry the incident generation they observed. A stale one records what it saw and does not notify, schedule or escalate.
- A crashed incident handler could trigger a customer’s restart endpoint twice. Starting a recovery attempt now claims its number under the incident’s row lock.
Changed
- Status-page subscriber emails go through the outbox. They were the last direct transport in the product: no key, no retry, no evidence, no ledger entry — for the one audience that is not staff. The message is rendered at delivery, so the unsubscribe link is never stored, and unsubscribing between the incident and the send is honoured.
- Resolved is terminal at the database. A trigger refuses to move a resolved incident’s status or resolution time, whatever writes it.
- The queue-health card counts notifications that are owed but not yet turned into messages. A queue stuck at that step used to read as empty.
1.18.1 — 2026-08-04
A flaky test turned out to be hiding six real ones.
Fixed
- An incident whose worker died before paging anyone was never paged, by anyone, ever. Every consequence of an incident — the page, the recovery job, the escalation ladder — hung off “I am the transaction that inserted the row”, so a worker that committed the insert and then died left an incident open with nobody notified, and no later check repaired it. A later check now finds it, claims the notification in the database, and pages exactly once however many workers reach it.
- A monitor that recovered could still have an incident opened for it. A recovery check landing between the decision and the open resolved the old incident and marked the monitor up, and the open then created a brand-new incident for a monitor that was fine — one page-resolve-page flap per race. Opening and resolving now serialise on the monitor row.
- A resolved incident could still be acknowledged, re-prioritised, or given a public update. All three read the status, checked it, then wrote with a predicate carrying only the id. The public update is the one that reached customers: it passes the status page’s filter, so a customer-facing line could appear on a timeline the page had already closed.
- Every incident update after the first was silently dropped for the life of the incident, because the outbox key named the incident rather than the transition. A team posting four updates during an outage broadcast one.
- Deleting a monitor mid-outage orphaned its incident forever, leaving it in the active list and the dashboard count permanently. It is now closed in the same transaction, with a timeline note saying why.
- The incident timeline was ordered by the wrong clock. It sorted on the transaction start time, so two concurrent writers could appear in the reverse of the order they committed.
Changed
- The concurrency tests force the race instead of hoping for it. Four of them asserted an interleaving they never forced — measured, two concurrent status changes overlapped in 29 of 30 warm runs and serialised in the 30th — and two more passed for the wrong reason: deleting the guard they existed to prove left them green. Every fix above has a test that fails without it, verified by mutation, one at a time.
1.18.0 — 2026-08-04
A delivery engine that survives a provider being down for an afternoon, and shows you every attempt it made.
Added
- Durable retries. Twenty attempts over a six-hour horizon, backed off exponentially to a half-hour ceiling and jittered. The entire retry window used to be 31 to 62 seconds, so a provider outage lasting longer than a minute ended with every queued alert marked failed. Two bounds end a chain and the terminal state says which: dead letter ran out of tries, expired ran out of time. The schedule lives on the row, so a restart or a redeploy mid-outage changes nothing.
- An append-only record of every attempt, written before anything is sent and never overwritten by a later one, plus unknown as an outcome in its own right for the case where a request went out and its fate is not known — the only case where a duplicate at the far end cannot be ruled out.
- Lease fencing, so a worker paused past its lease can no longer write its result over the worker that replaced it.
- Fair draining. Selection round-robins across tenants instead of taking the globally oldest, so one organization’s thousand-channel fan-out cannot starve another’s single page.
- Replay and retention. Finished deliveries can be queued again as new work without rewinding the original; terminal rows and their evidence are pruned after thirty days, and nothing queued is ever deleted.
- A delivery ledger in the app: queue health, per delivery attempts, next retry, age, final reason, filters and an attempt timeline.
Fixed
- A worker that died holding a message never spent an attempt, so a row could cycle between crash and re-claim forever without ever reaching its budget. Attempts are now spent at the claim.
Notes
- Migration 0026 is additive; upgrading from 1.17.0 is applying it. Measured at 1, 100, 1,000 and 10,000 queued deliveries: planning stays at about 16 ms at ten thousand, a tick costs the same whatever is behind it, and the connection pool never waits.
1.17.0 — 2026-08-03
Twenty-five providers Vigil implements, and a bridge that is honest about not being one.
Added
- Fifteen new native provider types, taking the registry from ten to twenty-five, in both editions: PagerDuty, Jira Service Management, Mattermost, Rocket.Chat, Matrix, Zulip, LINE, Pushover, Pushbullet, Bark, Web Push, Home Assistant, Twilio SMS, Twilio WhatsApp and Amazon SNS. Native means Vigil implements that service’s own documented API and pins the version it was written against — every provider now carries that version in the registry, shows it in the editor and publishes it in the docs, because “we support PagerDuty” is not checkable and “Events API v2” is. Every credential is still your own: no relay, no funded API, nothing hosted on any provider’s side.
-
Alert lifecycle, where the provider has one.
PagerDuty and Jira receive an outage, not a message feed. Each event
derives a key from its cause, so monitor down and incident opened
for one outage land on one PagerDuty alert and
one Jira issue, and the recovery resolves the alert or
transitions the issue. PagerDuty is deliberately never sent
acknowledge: that means a human picked the alert up, and Vigil does not know that. - An Apprise bridge, and a boundary around it. One more registry entry that forwards to an Apprise API server you run. There is no managed Apprise, no shared relay and no instance operated by anyone selling Vigil, and not one service behind that bridge has been implemented, pinned or tested here. So the published number is 25 native providers, plus additional services through your own Apprise server, and the two halves are never added together.
- Provider capabilities as data — native, lifecycle, duplicate suppression, receipt — shown as badges in the editor, published as columns in the docs, and checked against the code by tests.
- A searchable provider picker, because twenty-six entries in a dropdown is a list you scroll past rather than choose from.
Fixed
- An independent audit found six things and they are fixed here rather than filed. LINE’s 409 means the push arrived, so recording it as a permanent failure would have put a lie in the ledger the outbox exists to keep honest. Recovery events name their incident in a different field, so the correlation key was resolving nothing. HTTP Basic sends credentials base64-encoded, which the raw-value scrub could not see. Three capability flags claimed duplicate suppression that SMTP, standard SNS topics and Jira do not have. And the retry window is 31 to 62 seconds where three pages said half an hour — corrected on the pages, with the consequence stated, because widening it is a change to delivery behaviour and belongs in a release about delivery behaviour.
Notes
- No migration. The provider column has been text since 1.15.0 precisely so that adding providers is not a schema change. Upgrading from 1.16.0 is a deploy.
- Amazon SNS is signed in-tree with Signature Version 4 and Web Push is encrypted in-tree per RFC 8291 and RFC 8292, rather than through SDKs that would perform their own HTTP outside the egress policy.
- The published drain limits are unchanged: 250 messages a tick, four in flight, ten per channel per tick. A mixed thousand-channel fan-out across every provider type is tested end to end, with no external call, and still clears in four ticks.
1.16.0 — 2026-08-03
As many destinations as you have places to send.
Added
- Unlimited notification channels. The cap of twenty per organization is gone and nothing replaced it. Forty Slack channels, one per client, each pointing at a different workspace, is a supported configuration; so are two pointing at the same one. Channels are identified by id, and nothing about one has to be unique — not its provider, endpoint, address or name. Both editions. “Unlimited” means this application imposes no count; it is not a throughput promise, and every provider still enforces its own rate limits.
- Routing by monitor. A channel left alone is a workspace default and hears about everything, including events that belong to no monitor. Point it at specific monitors and it hears about those and nothing else — one Slack workspace gets the lot, another gets only its own client. A channel matched by several rules is still sent exactly one message.
- A channel list built for hundreds: search, provider and status filters, paging, bulk enable/disable, and duplicate — which copies the credentials and arrives disabled, so a copy cannot page anyone before you have edited it. Each row shows its last delivery and most recent redacted error.
Changed
- Listing channels no longer decrypts anything. The redacted destination is a column written when a channel is saved, rather than a value derived on read by opening every sealed envelope. Measured, the settings page costs the same at one channel and at a thousand. That is what had to change before the cap could go.
- Dispatch resolves its routes in one indexed query and enqueues the whole fan-out in one insert, with the secret blobs never selected at all.
1.15.0 — 2026-08-03
Ten ways to be told, and one pipeline that does the telling.
What this release does not claim. Every provider runs on your own credential — a webhook URL from your Slack, a bot from your BotFather, your own mail server. Vigil funds no relay, hosts no gateway, and stores nothing it could not show you: channel secrets are encrypted at rest, never returned to a browser, and scrubbed from every recorded error. Delivery stays at-least-once, said plainly, because nothing that hands a message to a third party can honestly promise better.
Added
- Ten notification channel providers, in both editions: Slack, Discord, Microsoft Teams, Telegram, Google Chat, Gotify, ntfy, the signed webhook, SMTP and Resend. One registry drives the editor, the docs and the published list, so no page can claim a provider that is not shipped. Teams speaks the Workflows webhook with an Adaptive Card — the connector format Microsoft retired in May 2026 is never sent. SMTP refuses to authenticate without TLS and has no ignore-certificate-errors option.
- One channel editor: pick a provider, fill exactly the fields it declares, send a test before saving, choose event classes, enable or disable. Secret fields are write-only.
- Event class routing. Monitor down/up, incident lifecycle, certificate and domain expiry, and — commercial — recovery results and remote-probe quorum. Every event belongs to exactly one class, so one logical event cannot reach the same channel twice. An expiring certificate pages the channel that asked for expiry warnings, not the 3am pager.
- A delivery history on the notifications page: provider, event, destination, attempts, final state, and the error with credentials removed.
- Per-channel rate limiting, and a provider's Retry-After honored: the backoff never schedules earlier than the provider asked.
- Telegram as a support route: @s8kur on the support and contact pages, for pre-sale questions. Support only, and buying stays on the site; a guard fails the build if the support handle ever drifts into a sentence about money.
Changed
- The single organization webhook became a channel. The upgrade migrates a configured endpoint by host — a Slack URL becomes a Slack channel, a Discord URL a Discord channel, anything else a generic webhook with its signing secret and wire format unchanged — subscribed to everything it received before.
- Signed webhook deliveries now ride the durable outbox with leases, jittered backoff and the ledger, and an organization-scoped drain right after dispatch keeps a chat alert inside the first second of an incident.
- Every URL-based provider goes through the egress policy: resolve, classify, pin, refuse metadata and link-local space, never follow a redirect.
1.14.0 — 2026-08-02
Probes you host, and a quorum over what they say.
What this release does not claim. Vigil hosts nothing: no regions, no relay, no managed probes, no recurring cost. A probe is a container you run on hardware you already pay for, and the location field says what you typed because there is no way for Vigil to check it. Two agents on one machine are not two locations, and the app says so where you assign them.
Added
- Remote probe agents with quorum. A monitor can be executed by agents you run on your own machines instead of by the controller. A probe is a headless Docker container that connects outbound over HTTPS, leases work, and runs the checks through the same engine the controller uses. No inbound port, so it works from an office LAN, a home connection or a laptop with no network change at all.
- Quorum you configure: any, majority, all, or an explicit K-of-N, plus a minimum number of probes that must have answered before any conclusion is drawn. A probe that did not answer is never counted as a failure, in any mode. Fewer answers than the minimum reports “no conclusion”, not an outage, and never starts a recovery action.
- Partial failure is its own answer. When some probes cannot reach a target and the rest can, the monitor reports degraded, the dissent is named, and nobody is paged for a route that is broken for a third of the world.
- One incident per transition, never one per probe, enforced by the database rather than by careful sequencing.
- A retention policy for probe data. Raw per-probe rows for 14 days, the round and its decision for 90. A round where everybody agreed the target was up is dropped early, because the observation it produced is already recorded elsewhere. Measured, that is the difference between 6.9 GB and 0.95 GB on a twenty-monitor installation.
Fixed
- A monitor executed by probes opened its incident and then never started its recovery action or its on-call ladder.
1.13.0 — 2026-08-01
Trust, and a migration path off Uptime Kuma.
Both editions are cut from this commit. The work here is mostly not features: it is the difference between a number this product publishes and a number it can prove. Where the two disagreed, the number changed — including the uptime percentage on every dashboard.
What this release does not claim. 500 ms is a configurable check interval, not a detection time and not a notification time. Cadence holds exactly to 100 monitors on one worker; at 1000 it does not, and the run that failed is published alongside the ones that passed.
Added
-
Twenty-six check types, 14 → 40: SSH, FTP, IMAP,
LDAP, NTP, Memcached, Elasticsearch, UDP, gRPC, Kafka, RabbitMQ, SQL
Server, Oracle, RADIUS, SNMP, WebSocket, Steam, GameDig, Tailscale,
Globalping, systemd services, a real-browser check and SIP — plus
push,groupandmanual, three monitors that dial nothing. A heartbeat endpoint, a state derived from other monitors, and a status an operator sets by hand for what Vigil cannot reach. -
Import from Uptime Kuma 2.4.0. Upload your
kuma.db, review a summary that is a real dry run against the file you uploaded, confirm. All 31 of Kuma's selectable monitor types map, and every one of its 111 monitor columns is classified — nothing leaves Kuma without a line in the report saying what became of it. A type having an equivalent is not a promise that every monitor of that type imports, and the two numbers are always stated separately. - Half-second checks, on a data plane of their own, so they cannot starve the ordinary scheduler or write a row per probe forever. Opt-in per monitor and off by default.
- A durable outbox for notifications. The decision to alert is written in the same transaction as the state change that caused it, so a crash between deciding and sending delivers late instead of never. Delivery is at least once, and the documentation says so rather than claiming better.
- Monitor export and import with credentials masked, password reset, and backup/restore scripts that refuse to restore over a database that already holds tables.
Changed
- Uptime is weighted by duration, not by how many rows agree. Two monitors watching the same outage at different intervals now report the same uptime, and time nobody measured is excluded from the ratio instead of counting as up. Published percentages will move, and the new number is the one the old one should have been.
- Postgres enforces one active incident per monitor instead of a read followed by a write. The migration reconciles duplicates an older install already has, keeping the oldest and closing the rest with the reason on their timeline.
- One module decides every outbound request, revalidating each redirect hop and pinning the socket to the address it checked — so a name that resolves twice cannot resolve to the metadata service the second time.
- An edit that never mentions a setting no longer clears it.
Fixed
- TLS expiry reported nothing for the certificates it exists to catch. The handshake was validated before the certificate could be read, so a self-signed certificate — most of them, on an internal network — and an already expired one both returned no data instead of a number.
- A monitor created by the importer could reach a private address the create form would have refused.
- The worker could not start on a fresh database.
- A monitor enrolled in the half-second plane could be starved forever: monitors created together come due together, and a stable iteration order handed the same ones the same win every tick.
- On an account with more than one status page, the second page's settings controls drove the first page's.
1.12.0 — 2026-07-28
Eight check types, and two bugs that only existed once they did.
This is not the migration release. The importer, the notification channels and the one-line install are not in it, and the four were meant to ship together — an importer that lands two hundred monitors and drops the user's routing is a demo, not a migration. What is here stands on its own and waits for the rest.
Added
-
Eight check types, 6 → 14:
postgres,mysql,mongodb,redis,docker,mqtt,smtp,json-query. No new dependencies —postgresusespg, which was already one, and the rest speak the wire protocol by hand. A monitoring check needs "is it reachable and answering", which is a handshake and one command. -
White-label status pages (migration
0014): a toggle for the "Powered by Vigil" footer, free in both editions, because Uptime Kuma's pages are white-label for nothing.
Fixed
-
A monitor target could carry a credential out of the
building.
No target could until
postgresshipped with a connection-string target, and the first thing it did was put the password into every incident email and webhook body — both send paths readmonitor.urlraw, bypassingdescribeMonitorTarget, which exists for exactly this. Emails go through it now; the webhook payload carries a trimmed monitor, so it goes through a new string-levelredactTargetCredentialsthat cannot fail open when a check type is missing from the build. -
A dead multi-homed host could read as up. When
every address of a host fails, Node raises an
AggregateErrorwhose ownmessageis the empty string.judgetestsif (result.error), so a falsy error meant "no transport failure" and the check fell through to its assertions — filed as an assertion failure, or as up for a type whose assertions all skip on missing facts. Present inprobes/tcp.tssince 1.7.0. -
Two tests used
"redis"as their example of a check type this build does not have. One of them started testing the real descriptor the day redis shipped. -
The Core mirror carried the commercial edition's
docs/UPGRADE.md, which does not mention that 1.0.x has no in-place upgrade path — so the people who needed that sentence were reading the wrong file.
1.11.1 — 2026-07-28
Packaging fix for the first generated Core release, and the reason both editions are on 1.11.1 rather than 1.11.0.
Fixed
-
The published lockfile claimed the commercial licence.
package-lock.jsoncarries its ownname,versionandlicense, andnpm cidoes not object when they disagree withpackage.json— so Core 1.11.0 shipped an Apache-2.0 repository containing"SEE LICENSE IN LICENSE"and the namevigil, and nothing in the pipeline failed. Found by cloning the published repository and reading it, which is now part of publishing rather than an afterthought. The publish job patches the lockfile and refuses to push if either string survives.
1.11.0 — 2026-07-28
Both editions are cut from this commit. Vigil Core 1.11.0 is the same monitor, published from the same tag, and stays in lockstep from here.
Both editions now build from this tree.
npm run gate strips the commercial code and proves what
is left: Core typechecks, passes 455 tests, builds, migrates onto an
empty Postgres with no commercial table or column present, and serves.
It exits 0.
No behaviour changed in the commercial edition. 531 tests, up from 525.
Added
-
An organization owns many status pages (migration
0013), each with its own URL, components and subscribers. Uptime Kuma gives unlimited status pages away free; putting them behind the paywall would have broken the rule the edition split rests on. What the commercial edition sells here is isolation between clients, not a count. -
monitor-check.tsno longer knows recovery or on-call exist. It asks@/modules/incidents/hookstwo questions — may this incident's alerts be held and for how long, and an incident opened so is there anything to schedule — and acts on the answers. With nothing registered the answers are "no" and "nothing", which is exactly the free edition. A handler that throws cannot stop an incident opening or a page going out; every failure degrades toward paging. -
monitor_escalation_policies, replacingmonitors.escalation_policy_id(migration0012, backfilled and dropped in one transaction). Both ends cascade, so unlike the oldON DELETE SET NULLcolumn the association can never point at a policy that is gone. -
scripts/strip-ee.mjsandscripts/edition-gate.sh.
Changed
- The minimum check interval is 2 seconds, down from 10 — measured, not guessed. A monitor asking for a 1-second baseline is checked every 2002 ms at the median; ten monitors on the same baseline move that to 2003 ms. The wall is pg-boss's job poll, flat, and not contention on the ledger's per-actor sequence, which does not notice this rate. Ten was a number an operator could type and not receive.
Fixed
-
Subscriber notification was a fan-out collapsed to one
send.
notifyStatusPageSubscriberspicked the organization's page withfindFirst, correct while an organization had exactly one. It now mails every published public page that lists the monitor, and applies the listing rule per page. -
setStatusPageMonitorsActiondid not revalidate the public page, so a removed component kept being served for up to 60 seconds. It was deferred from 1.10.1 because nothing named which page to revalidate; the input names it now. -
team-size.test.tshad a half that guarded nothing. Measured against better-auth 1.6: removingmembershipLimitleaves the invitation test passing, because that path never consults the option. The list path is the whole bug. The file now says so, pins the truncation at exactly 100 as a characterization test, and no longer depends on being able to create a second organization — which only worked because this edition is multi-tenant.
Licence and honesty
No product code changed. This entry exists because the licence did, and because several published claims about the free edition were false.
Licence — commercial licence 1.3
- Every future version is now included. Was "all updates published for major version 1 for at least 12 months from the purchase date"; is now every version published after your purchase, with no limit of time or major version. The optional $99/year renewal is withdrawn — $99 against a $149 purchase is a two-thirds maintenance rate, which is a subscription wearing a perpetual licence's clothes.
- New section 8, sunset. Every released version is additionally licensed under Apache-2.0, effective twenty-four months after that version was published, or immediately once twelve months pass with no release at all. The grant needs no act, no notice and no living author, and is irrevocable once effective. Versions published before licence 1.3 are covered, measured from their own publication dates.
- Licence 1.3 applies to every licence already sold. It grants more than 1.2 and takes nothing away; no earlier purchaser is left on worse terms than a later one.
-
The published licence and the shipped
LICENSEhad diverged. Section 5 on the website limited liability to the licence fee paid, while the file buyers actually received said their sole remedy was a refund "under the published refund policy" — a policy removed from the site weeks earlier. Reconciled to the website's wording, andlanding/license.htmlis now generated fromLICENSEso the two cannot drift again.
Changed — Vigil Core is now Apache-2.0
- Core moved from AGPL-3.0 to Apache-2.0, and every licence string on this site moved with it. Three of them were not string swaps: the "licensing wall" was one of the four stated reasons to upgrade and no longer exists, so it is gone and the list is three walls again; the "why is there a paid edition" answer said the commercial licence removes a copyleft obligation, which it no longer does; and the careers page described a contributor agreement that the relicense deleted. Apache-2.0 also means Core may be resold, so the compare page can no longer call Uptime Kuma "more permissively licensed" than Core.
Changed — claims about Vigil Core
- The site called Vigil Core "the same monitor", "the same codebase" and "the full uptime monitor". All three were false. Core is 1.0.1 and HTTP-only; this edition is 1.10.1 with six check types. Core was published on 25 July 2026 already ten releases behind — it did not drift, it shipped behind — and no script anywhere builds one edition from the other, which is why porting features would not have fixed it.
-
/open-source.htmlnow states the whole gap, including that Core → Commercial is not a clean migration today. Core's 100-member cap was going to be disclosed here too; it was fixed instead — a promise with an exception is worth less than the promise. - Check-type counts are no longer framed as a scoreboard against Uptime Kuma. Every check type ships in the free edition; monitoring capability is the product's identity, not its price fence.
- The published test count was stale: 516/207 everywhere, actually 525 total and 216 against a real Postgres. Both corrected.
Added
-
/commitments.html— nothing that ships in Core ever leaves Core, no caps, both editions cut from the same commit, and Core on Apache-2.0; plus the sunset clause and a plain list of what is deliberately not promised. -
docs/EDITIONS.md— the design for building both editions from one tree. Design only; none of it is built. -
verify_site.pynow checkscompare/too. It never had, so the comparison table shipped unverified for its whole life.
1.10.1 — 2026-07-27
Two escalation rungs that paged the wrong person or nobody at all, and
a status page that published monitors the operator had excluded from
it. One additive, data-only migration (0011) that
repoints escalation steps a deleted schedule left pointing at nothing.
Contains a security fix. Upgrade if you publish a status page and do not put every monitor on it — see Security below.
Fixed
-
An escalation step whose on-call schedule was deleted paged nobody.
escalation_steps.schedule_idand.user_idareON DELETE SET NULL, so deleting a schedule left the step readingon_callwith nothing on the other end — a combination the policy editor refuses to save, manufactured by the database and matched by no branch in the code that turns a step into people. The rung went silently unpaged, and the delete dialog told the operator so.Such a step now pages the responder set. Over-paging beats silence, and nobody chose this state. Deleting a schedule also rewrites its steps to "all responders" in the same transaction, so the editor shows what will actually happen instead of an empty schedule picker that blocks saving the whole policy. The incident timeline says when a rung fell back.
Deliberately narrow: a schedule that exists but has nobody in it, or a step naming someone who has been offboarded, still resolves to nobody — those are visible on the settings page, and the timeline already records "no one reachable".
Steps orphaned by a delete under an earlier version are repointed by migration
0011, becausedeleteSchedulecan only fix what it deletes itself. And the service layer now refuses to write the pair at all, rather than leaving that to the form: it used to mean "page nobody" and now means "page everyone", which is not something a seed script should be able to do by omission. -
Editing an escalation policy during a live incident fired the wrong rung. Queued steps were re-resolved by their position in the policy at firing time. Reorder or shorten a ladder while an incident is unacknowledged and position 2 is a different rung — paged at the old rung's delay, over the new rung's channel — or no rung at all, in which case the step vanished with nothing written anywhere.
Each queued step now carries the rung it was scheduled for. Steps already queued when you upgrade still resolve by position; if one no longer exists, that now reaches the incident timeline instead of disappearing.
What the queued rung carries is which rung — target, channel, position — and not whether the thing it points at still exists. Those are different, and conflating them would have reintroduced the bug above through its own fix: delete a schedule while a ladder is in flight and the queued job holds an id that is dangling but not null, so the responder fallback never fires. Pointers are re-checked when the rung fires.
-
A pause and a resume inside one probe flight could still page. 1.10.0 made a pause mid-probe win the race. Both a pause and a resume fit inside the same 30-second window, though, and the row reads "not paused" again by the time the probe's write lands — so it went through and restored the failure-run timestamp the resume had just cleared. The monitor computed "failing since last week" from its first check back and paged immediately, webhooks and subscriber emails included. The write is now conditional on that timestamp too.
-
Domain-expiry monitors rejected every multi-label suffix.
bbc.co.uk,company.com.auandexample.co.jpwere all refused, whilegov.uk— which nobody can register — was accepted. Every other check type accepts those hosts and RDAP resolves them fine. There is now a table of two-level public suffixes rather than a relaxed label count, because "two or three labels" would letwww.bbc.co.ukpast the form to a registry 404 and a page on a typo. -
The on-call settings page listed people who had been offboarded. The rotation read joined the user table rather than current membership, so a departed engineer still appeared in the schedule — and the dialog handed their id back to the save, which then failed naming a member the operator had never touched. The pager itself was already correct; this was the settings page disagreeing with it.
-
A status-page headline went amber over an absence of evidence. A monitor Vigil cannot measure must not sit under a green headline, but "unknown" also covers a monitor nobody has checked yet and one that has just been resumed — neither of which is evidence of anything wrong. The headline now looks at the monitor's latest observation rather than its stored status.
"Unknown" also covers a monitor that is failing right now but has not been failing long enough to be called down, so the same rule has to count a failed probe as evidence, not only an unrunnable one. Otherwise the banner went green over a live failure — and moved the wrong way on new information, since a ping monitor going from "ICMP is not permitted" to "reached it, and it is down" turned amber into green.
-
83 links across 29 pages pointed at the old GitHub account name, including the
codeRepositoryin the site's structured data. They resolved only through GitHub's rename redirect, which stops working the moment anyone registers the freed username.
Security
-
Incidents for monitors excluded from a status page were published on it, and mailed to its subscribers. Affects 1.0.0 through 1.10.0 on the page itself, and 1.9.0 through 1.10.0 for the subscriber email — email subscriptions shipped in 1.9.0, and the page leg has been there since the first release. Fixed here.
A status page shows the components the operator chose. Incidents were not filtered the same way: they were selected by organization alone, so an incident opened automatically for any monitor in the organization appeared on the page. Auto-opened incidents are titled after the monitor — in practice an internal hostname — and the page is served to unauthenticated readers. Confirmed subscribers were sent the same title by email.
There is no attacker in this. It needs nothing but a published status page, one monitor deliberately left off it, and that monitor failing.
Both the page and the subscriber mail now show an incident only when it belongs to a monitor on the page. Incidents that name no monitor — the manual, org-wide announcements you write by hand — still appear everywhere they did before; that is why this is a filter and not a join.
The filter keys on what opened the incident, not on whether it still names a monitor. Those come apart: the monitor reference is cleared when a monitor is deleted, so a filter reading "no monitor" as "announcement" would have republished every incident of every deleted monitor — the same disclosure, reached by an ordinary delete instead of by a failure.
Two visible consequences, both stated plainly. Deleting a monitor now removes its incidents from the public page, including a monitor that was a listed component. And if you delete a monitor while one of its incidents is still open, subscribers who were emailed "Incident opened" will not receive the "Incident resolved" that follows.
Both have the same cause and the same answer. The component row is deleted along with the monitor, so afterwards nothing remains that could distinguish "was published" from "was excluded" — and letting the later notice through on the grounds that an earlier one was sent would republish the name of a monitor that may never have been on the page at all. Between a missing resolved-notice and a leaked hostname, this fails closed. Closing the gap properly needs the incident to record that it announced itself, which is a schema change and waits for the next release.
What to do. Upgrading is sufficient — the only migration in this release is unrelated, and there are no configuration changes.
To find out what was exposed, work from your own Incidents list, not from the public page: after upgrading, the public page is exactly the thing that no longer shows the evidence. Compare that list against the components on Settings → Status page. Any incident belonging to a monitor that is not a listed component was readable by anyone with your status-page URL while it was open, and for fourteen days after it resolved. If you have confirmed subscribers, assume those titles were delivered by email as well. An incident whose monitor has since been deleted no longer says which monitor it was — those are worth checking by title.
1.10.0 — 2026-07-27
Four new check types, thresholds measured in seconds instead of
checks, and intervals that adapt. Additive migration
(0010) — see
docs/UPGRADE.md for the full
procedure.
Contains a security fix. Upgrade if more than one person can create escalation policies, and read the first entry under Security below even if you run a single organization — one leg of it affects single-tenant installs too.
Added
-
Four new check types:
ping,dns,tls-expiry,domain-expiry. Ping sends an ICMP echo; DNS resolves a record type and can assert on what comes back; the two expiry types watch how long a certificate or a domain registration has left. All four use transport primitives the codebase already had — no new dependencies. Domain expiry speaks RDAP over plain HTTPS, so there is no WHOIS client and no port 43.With HTTP(S) and TCP that makes six selectable types.
-
A check-type registry. Types are now data: a descriptor, a validation spec, a set of declared assertions and a probe, joined in one map. There is no dispatch to edit and no
switchto extend — adding a type is five files and no change to any existing code path, and a conformance suite applies every rule to it automatically. See docs/CUSTOMIZATION.md. -
A shared condition engine. Probes emit facts; types declare assertions over them; one function judges. Judgment is written once for every type instead of once per type, and because it is a pure function of (assertions, spec, facts), a stored observation can be re-judged later without re-probing anything.
-
A third answer: "I don't know." A probe that cannot run in this environment — ICMP without a raw socket, a check type a downgraded build no longer has — reports unknown with an explanation. It is deliberately not down: an operator error that is indistinguishable from an outage is the one failure a monitoring product may not have.
-
Ledger foundations on the observation record. Every check now carries actor identity, a hybrid logical clock, a per-actor sequence and hash chain,
signatures[]as a collection, and the spec version it was judged under. Chains are per-actor rather than global, and a chain can be recomputed from the stored columns alone.Two honest limits.
recovery_attemptshas the same columns but nothing writes them yet — a recovery attempt is inserted and then mutated as it progresses, which needs the supersede record that compaction will also need, and that is 2.0 work. And deleting a monitor cascades its observations away from the middle of an actor's chain, so everything after the gap stops verifying; the reasoning and both candidate fixes are written out beside the table insrc/db/schema/monitors.ts. Nothing in the product verifies a chain today, which is why both are acceptable now and neither will be once something does. The fields are here because adding them later means rewriting the largest table in the product. -
Spec versioning on monitors. Editing a rule creates version N+1 instead of overwriting it, so an observation recorded in March stays interpretable after the rule changes in July.
Changed
-
Failure thresholds are time-based. "Down for 90 seconds" replaces "3 consecutive failures", which stops meaning anything once the interval between those failures varies. Existing monitors are converted to the window that reproduces their behaviour exactly.
-
Check intervals adapt. The six-value dropdown becomes any value from 10 seconds to 24 hours, and that value is a baseline: a monitor that looks suspicious is probed harder, one with a long clean run backs off, and one already declared down goes back to its baseline rather than being hammered. A check due sooner than the next cron tick schedules its own follow-up, so the ten-second floor is delivered rather than nominal. Scheduling policy now lives in one function,
nextEvaluationAt(spec, recentObservations), instead of being encoded in the query that selects due monitors. -
The status controller is level-triggered.
becameDownandbecameUpare gone. Status is derived from observed state, and the controller is safe to run repeatedly, at any time, having missed anything — so a monitor that ended up down with no incident, or up with a stale open one, repairs itself on the next check instead of staying wrong. This is the property that backfill, replay and any future offline probe depend on. -
The RBAC matrix is assembled from the registry rather than from a static object, so gating a check type becomes a property declared on the type. Role definitions are versioned. The resulting permissions are byte-identical to 1.9.3.
-
Incident emails and timeline entries now say "had been failing for 2 minutes" instead of "failed 3 consecutive checks".
Security
-
Escalation targets were not checked against the caller's organization. Affects 1.8.0 through 1.9.3, fixed here.
A monitor's
escalationPolicyId, and an escalation step'sscheduleIdanduserId, were written from client input and read back without an organization predicate. A member of one organization who supplied another organization's id — the ids are UUIDs and are not otherwise guessable, which is the only thing that bounded this — could cause Vigil to page that organization's engineer about an outage that was not theirs, by email, SMS or voice, including monitor names and a link to the incident. The leak runs both ways: the paged person's real name is then written into the first organization's incident timeline, which its own members can read.A second leg needs no attacker and affects single-tenant installs. Removing someone from an organization deleted their membership row and nothing else, so a person named in an escalation step, or sitting in an on-call rotation, kept being paged after they were offboarded — indefinitely, with monitor names and incident URLs.
Every one of those ids is now resolved against the acting organization before it is stored, and every read that turns an id into a person joins through current membership:
resolveStepRecipients,currentOnCallandescalationStepsForMonitor. Membership is checked at page time rather than trusted from configuration time, so offboarding takes effect on the next page rather than needing a manual audit of every policy.What to do. Upgrading is sufficient — no data migration is involved and no configuration changes. If you run more than one organization from one deployment, review Settings → Escalation in each, and your audit log for
escalation.created/escalation.updated, for steps naming people you do not recognise. If you have offboarded anyone since 1.8.0, confirm they no longer appear in a rotation. -
pingmonitors could not run in the shipped container, andDEMO_MODEcould not be switched on from it. The worker image runs as a non-root user with no ICMP capability, so everypingmonitor reported "unknown" forever;docker-compose.ymlnow grants an unprivileged ICMP socket and installsiputils. Separately,DEMO_MODEwas never passed to theappservice, so an operator followingdocs/DEMO.mdwould stand up a public, sign-up-enabled host believing it was a read-only demo, with the demo owner password published in our own README. Both are compose changes; rebuild and recreate.
Fixed
-
Domain-expiry monitors reported outages for things that were not outages. Every RDAP failure — a rate-limited lookup, a slow registry, a ccTLD that does not publish an expiry date at all — was judged
down, which paged on-call, reddened the public status page and permanently lowered published uptime for a domain that was fine. The no-expiry case was worse than a false alarm: it pinned the monitor red forever, because closing an incident needs a positive observation and that branch can never produce one. Those now report "unknown". A 404 means down only when a registry answered it.RDAP_BASE_URLpoints at a bootstrap redirector, which returns 404 itself for a TLD it has no entry for — .de, .io, .ch, .at, .jp, .nz and .ru among them — so keying on the status code alone made every domain under those TLDs a permanent, unresolvable false outage. Vigil now compares the responding host: a registry saying 404 is a lapsed registration and pages you, the bootstrap saying 404 means that TLD publishes no RDAP service and reports unknown. A malformed or oddly-shaped registry response also no longer throws out of the worker, which used to leave the monitor re-enqueued every minute with no observation ever recorded. -
Pausing a monitor mid-check could still page you. The paused flag was read before the probe and never re-checked when the result was written, so an operator pausing a monitor seconds before its failure window elapsed — precisely to stop the page — got paged anyway, and the resulting incident could not auto-resolve, because every subsequent check returns early while paused. The observation is still recorded; nothing is derived from it.
-
A status page could show "All systems operational" over a component it could not measure.
unknownfell through to operational, so a component whose probe had stopped working sat under a green headline next to its own "No data" chip. It now reads "Degraded performance". A paused monitor still does not colour the headline — that is an operator's decision, not a gap in what Vigil knows. -
Recovery verified a different rule than the one that failed.
worker/jobs/recovery.tsbuilt its own probe spec and had drifted from the one inworker/jobs/monitor-check.ts: it omittedtlsCheckandtlsWarnDays. The verification probe was therefore laxer than the check that opened the incident — the direction that makes a fix look successful when it was not. Both now go through onetoCheckSpec.
1.9.3 — 2026-07-26
Patch release: removes a team-size limit nobody chose. No schema migration.
Fixed
-
Organizations were capped at 100 members. Better Auth's organization plugin defaults
membershipLimitto 100 when the option is not set, and Vigil never set it. That cap bit on two separate paths: the 101st person could not be invited (ORGANIZATION_MEMBERSHIP_LIMIT_REACHED), and — quieter, and worse — the member list query was truncated to the same number, so Settings → Members and the escalation-target picker would have shown 100 of however many members existed, with nothing on screen to say the list had been cut short.Vigil is licensed once per company, never per seat, and the pricing page already advertised unlimited seats, so the cap contradicted the product it shipped in. Team size is now explicitly unlimited, and the two pages that render members ask for all of them — lifting the option alone is not enough, because the member join falls back to 100 unless the caller passes
membersLimittoo. Roles and permissions are unchanged: owner, admin, responder and viewer still mean exactly what they did.Covered by integration tests that build a 131-member organization and assert both the full list and a successful invitation past the old limit.
1.9.2 — 2026-07-26
Security patch. Upgrade if you publish more than one status page from a single deployment. No schema migration.
Fixed
-
Status pages could serve another organization's content. The cached public-page query captured the slug in a closure instead of passing it as an argument, so Next derived an identical cache key for every page in the deployment — it builds that key from the callback's source text, the key parts and the call arguments, and the slug appeared in none of them. With two or more published status pages, the first slug requested populated the shared entry and every other slug served its organization name, component names, uptime history and incident timeline for the 60-second cache window. The entry is written to disk, so it also survived a restart. Single-status-page deployments were never affected.
The slug now travels as an argument. The end-to-end suite gained a second organization with its own published page and asserts each slug serves only its own content — verified to fail against the previous code and pass against this one.
1.9.1 — 2026-07-11
Patch release: repository packaging and operator docs. No schema or application-code changes.
Fixed
-
.env.examplenow actually ships. The quick start's first command (cp .env.example .env) referenced a file the.env*gitignore pattern had silently kept out of the repository, so a fresh clone couldn't follow its own setup instructions. The placeholder template is now tracked and exempted from the ignore rule.
Added
-
Backup, restore & troubleshooting documentation.
DEPLOYMENT.md gains a full backup/restore/disaster-recovery
procedure (one
pg_dumpcovers all state; keep the sameBETTER_AUTH_SECRETacross restores — sessions and subscription tokens are signed with it) and a symptom → cause → fix troubleshooting guide from real deployments. The README environment table now lists the Twilio variables, and the Docker build no longer emits a false-positive secrets warning for the documented build-time placeholder.
1.9.0 — 2026-07-11
Minor release: status-page subscriptions. One additive schema
migration (status_page_subscribers); no change to
existing pages.
Added
- Status-page email subscriptions. Visitors to a public status page can subscribe to be emailed when an incident opens, receives a public update, or resolves. Double opt-in: subscribing records a pending row and sends a confirmation link (a signed, self-authenticating token — no guessable ids in URLs); only confirmed addresses are ever emailed. Every notification carries a one-click unsubscribe link. Internal notes and quiet self-healed incidents never reach subscribers — they mirror exactly what the team is notified about. Subscriptions are offered only on public pages; the subscribe endpoint is rate-limited per address. Operators see confirmed/pending counts under Status page → Subscribers.
Fixed
-
Public status page 500 on a cache hit. Since 1.6.0
the page query is wrapped in
unstable_cache, which JSON-serializes its result — so on a cache hit the incident timestamps came back as strings and the template threwstartedAt.toISOString is not a function. Any public page with an incident errored on its second view within the 60s cache window (the e2e smoke test only loaded it once, on a cache miss, so CI never caught it). The cached incidents are now date-revived at the boundary, and the smoke test reloads the page to exercise the cache-hit path.
1.8.0 — 2026-07-11
Minor release: notification channels, on-call schedules and escalation
policies. Two additive schema migrations — on-call/escalation tables
plus monitors.escalation_policy_id and incident
acknowledgement columns (0007), and
user.phone (0008). Existing monitors keep
their current behaviour: with no policy attached, an incident still
notifies all responders exactly as before.
Added
-
Notification channels. Escalation delivers over
email, SMS or
voice call. Email uses the existing transport (log
transport without a key). SMS and voice route through
Twilio — set
TWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKENandTWILIO_FROM_NUMBERto enable them; until then those steps are a clean logged no-op so the rest of escalation still runs. The delivery code is complete — a provider is the only thing an operator supplies. - On-call schedules. A named rotation of members that advances one shift every N days from a fixed anchor. Whoever is on call when an incident opens is the person an on-call escalation step pages. Managed under Settings → Escalation.
- Escalation policies. An ordered list of steps, each firing a set delay after the incident opened, targeting the on-call person, all responders, or a specific person, over a chosen channel. Attach a policy to a monitor from the monitor's form. The ladder stops the moment the incident is acknowledged or resolved.
- Incident acknowledgement. Acknowledge an open incident from its detail page to halt escalation without resolving it. Recorded as an internal timeline note and in the audit log.
- Per-user phone number. Set your escalation phone (E.164) under Settings → General → Your profile; used for SMS and voice steps.
1.7.0 — 2026-07-11
Minor release: TCP/port monitoring and TLS-expiry checks. One additive
schema migration (monitors.check_type, port,
tls_check, tls_warn_days,
tls_days_remaining); existing HTTP monitors are
unaffected.
Added
- TCP / port monitoring. A monitor's check type can now be TCP — a raw connect to a host and port (databases, message brokers, any listening service). Success is a completed connection; the connect time is the response time. Same SSRF posture as HTTP checks, and recovery verifies the connection the same way.
-
TLS certificate expiry. Enable it on an
httpsmonitor and Vigil also reads the peer certificate on each check, records the days remaining, and reports degraded when fewer than the configured threshold (default 14) are left — so a certificate that's about to expire surfaces as a warning before it becomes an outage.
1.6.0 — 2026-07-11
Minor release: status-page privacy and incident visibility. One
additive schema migration (status_pages.visibility +
password_hash, incident_events.internal);
existing public pages are unaffected.
Added
-
Private & password-protected status pages. A
status page can be
public(as before),private(only signed-in members of the owning organization), orpassword(anyone with a shared password — entered once, remembered for 12 hours via a signed, page-scoped cookie). Passwords are scrypt-hashed. Set it under Status page → Visibility. - Internal incident notes. Post an update as an internal note and it stays on the operator timeline (badged "Internal") but never reaches the public status page — alongside the recovery mechanics that were already withheld. Internal notes also don't fire the org webhook.
1.5.0 — 2026-07-11
Minor release: keyword/content assertions. One additive schema
migration (monitors.body_keyword,
monitors.keyword_absent); existing monitors are
unaffected.
Added
-
Keyword / content assertions. A monitor can now
assert that its response body contains a string — or, in
"absent" mode, that it does not (e.g. catch a
Database errorpage that still returns HTTP 200). Applies to GET checks; the body is read up to a 1 MB cap so a huge or hostile response can't exhaust worker memory. A keyword failure is a hard down (never "degraded"), the specific reason lands in the check history (Body does not contain "…"), and recovery verifies the same assertion before declaring a target healthy. Set it under a monitor's Advanced options.
Fixed
- Public status pages no longer render internal recovery-progress events; the AI client now bounds its request timeout.
1.4.2 — 2026-07-11
Patch release: recovery bookkeeping. No schema changes.
Fixed
-
Stale recovery attempts are closed. A worker
interruption between trigger and verification could leave an attempt
in
runningforever (recovery jobs deliberately never retry — they are not idempotent mid-flow). The nightly retention job now closes attempts stuck inrunningfor over an hour as failed, with an explicit "worker was interrupted" summary. Alerting never depended on this — the escalation failsafe pages independently — this keeps the recovery record honest.
1.4.1 — 2026-07-11
Patch release: recovery operability. No schema changes.
Added
-
Test recovery trigger. The recovery card gained a
"Send test trigger" button: it delivers a signed
recovery.testevent to the saved endpoint so operators can verify their receiver's wiring and signature check before the first real incident — including on a still-disabled action. Receivers must not run their recovery command for test events; the bundled example receiver acknowledges them without executing. Nothing is written to the recovery record.
1.4.0 — 2026-07-11
Minor release: recovery safety and learning. No schema changes.
Added
- Restart-loop guard. Recovery attempts were bounded per incident but not per monitor — a flapping target (down → recovered → down …) opens a fresh incident each time and would re-trigger recovery forever. The worker now counts executed triggers per monitor over the last 24 hours and stands down at 10: the attempt is recorded as skipped, the timeline says why, and held alerts fire immediately — a loop points to a deeper problem that needs a human. The cap is a fixed constant on purpose; a configurable safety limit invites configuring it away.
- Recovery effectiveness stats. The monitor page's recovery record now shows what the runtime has learned: how many attempts actually recovered the target and the median time from trigger to verified recovery — the numbers an operator needs before trusting recovery enough to hold alerts.
- Deployment guide gained an automatic-recovery section (receiver contract, bounds, alert holding).
1.3.0 — 2026-07-11
Minor release: alert holding. One additive schema migration
(incidents.notified_at,
recovery_actions.hold_alerts); default behaviour is
unchanged — alerts still fire at incident open unless a recovery
action explicitly opts into holding them.
Added
-
Hold alerts while recovery runs. A recovery action
can now defer the incident's email and webhook alerts until the
recovery loop has had its chance: if recovery verifies the target
healthy, nobody is paged and the incident resolves quietly (the
timeline and recovery record still document everything); if recovery
fails, exhausts its attempts or a worst-case deadline passes, the
original alerts fire immediately. Escalation is exactly-once — a
notified_atclaim on the incident arbitrates between the open path, exhaustion and the deadline failsafe — and the failsafe is scheduled up front, so a crashed recovery chain can never swallow an alert. Off by default.
1.2.0 — 2026-07-11
Minor release: automatic recovery. One additive schema migration
(recovery_actions, recovery_attempts);
existing behaviour is unchanged until a recovery action is configured
and enabled.
Added
-
Automatic recovery actions. Each monitor can point
at one recovery endpoint inside your own infrastructure (a restart
hook, a runbook trigger). When the monitor's incident opens, the
worker re-verifies the failure with a fresh probe, POSTs a signed
recovery.executepayload (same HMAC-SHA-256X-Vigil-Signaturescheme as notification webhooks) to that endpoint, waits a configured delay, then probes the monitor again to verify recovery. Attempts are bounded (1–5 per incident) with a cooldown between them; when they are exhausted the incident simply stays open for a human. Recovery is off by default and only ever runs against the explicitly saved URL. Every attempt is recorded twice: as an immutable row in the new recovery record (visible on the monitor page) and as system events on the incident timeline. Resolving the incident stays owned by the regular check loop — a verified recovery is confirmed by the next passing check, never assumed.
1.1.0 — 2026-07-10
Minor release: chat notifications. No schema changes; native webhook payloads are unchanged.
Added
-
Slack & Discord alerts. Point the organization
webhook at a Slack (
hooks.slack.com) or Discord (discord.com/api/webhooks/…) URL and Vigil delivers a readable one-line message in that service's format — event, incident title, severity, monitor and a link — instead of raw JSON. Detection is by exact host, so no other receiver can be misclassified; every other URL keeps receiving the signed, versioned native payload. TheX-Vigil-Signatureheader is still computed over the exact body sent.
1.0.1 — 2026-07-03
Patch release: deployment and UI fixes. No schema or API changes.
Fixed
-
Docker: app now starts in a container. The
standalone server bound to
process.env.HOSTNAME, which Docker sets to the container id, so the web container crashed at startup (getaddrinfo EAI_AGAIN <id>). It now binds0.0.0.0. -
Docker: Postgres data persists.
postgres:18relocatedPGDATAunder/var/lib/postgresql; the compose volume now mounts there so data survivesdocker compose downinstead of landing in an anonymous volume. - UI: no horizontal page scroll on tablets. Wide data tables (monitors, incidents, audit) forced the whole page to scroll sideways between ~768–1080px; the table now scrolls within its own container.
- UI: password fields show a placeholder for a consistent, premium empty state on the sign-in and sign-up forms.
1.0.0
Initial release.
Monitoring
- HTTP(S) uptime monitors with configurable intervals (1 min – 1 hr), timeouts, expected status codes and degradation thresholds.
- Postgres-backed worker (pg-boss): cron fan-out, per-monitor dedup, drift-corrected scheduling, 90-day check retention.
- SSRF hardening: domain-only target validation plus DNS-resolution checks against private address space.
Incidents
- Automatic incidents on consecutive failures; automatic resolution on recovery, with notifications to owners, admins and responders.
-
Manual incidents, severity levels, a strict lifecycle (
investigating → identified → monitoring → resolved), immutable timelines and blameless postmortems.
Status pages
- Public per-organization status page with live component health and a 90-day uptime history, ISR-cached.
Platform
- Multi-tenant organizations, invitations and four roles (owner / admin / responder / viewer) enforced by a single permission matrix across server actions, API and UI.
- Optional AI copilot (Anthropic API): postmortem drafts and public status-update suggestions, permission-gated and rate-limited.
- Email notifications (HTML + text) via Resend, with a structured-log fallback when no key is configured.
- Generic outbound webhooks: HMAC-SHA-256-signed, versioned JSON payloads for the incident and monitor events, with retries and a "Send test event" action.
- Audit trail, structured logging, security headers, light/dark themes.
Developer experience
- 135 tests (unit + integration against real Postgres) and a Playwright golden-path suite; GitHub Actions CI.
- Multi-stage Docker images, one-command Compose stack with migrations.
- Read-only demo mode, deterministic demo seed, screenshot automation, and a standalone marketing landing page.