Troubleshooting
verge-asm's whole posture is to tell you when it cannot tell you — a claim it
cannot construct becomes a Coverage statement, not a fabricated answer and not a crash.
That makes most first-run trouble quiet rather than loud: the command exits 0, the
page renders, and nothing measured. This guide is the checklist for confirming that a
thing actually happened, oriented around the failures a first run hits most.
Read it after running.md (the operational digest) and first-run.md (the mental model). For exposure specifically, keep prober.md open; for delivery, notification-channels.md.
A scan that ran vs. one that failed in silence
A scan can commit as completed and still have measured nothing — the classic case
being a dns scan pointed at a resolver that answers nothing, which yields empty records
and a Gap while committing successfully. "The trigger exited 0" is not "it produced
data." Confirm dispatch in this order:
-
The trigger's own output. A manual
docker compose run --rm worker -trigger dnsdrains synchronously and prints how many jobs it enqueued and that it finished:0 job(s) enqueuedmeans nothing was queued at all — usually no seed scope for that kind, or a disabled scan (a trigger refuses a disabled kind rather than running it once;coldships disabled — see running.md → On-demand scan triggers). -
The worker logs. The long-running daemon logs the dispatcher, delivery and retention runners here too:
-
/scans. The scans page lists dispatches and their runs. Each run drills into/run/<id>, where an individual dispatch's job outcomes live — the surface for "did this run touch anything." -
Coverage.
/coverageis where we could not construct this claim lives —Gaps, unread apertures, unevaluable rules. A scan that ran-but-resolved-nothing shows up here as aGap, not as an error and not as absent data. If you expected subjects and Coverage shows aGap, suspect the resolver or an empty scope before you suspect a crash. -
Subjects. Once a batch genuinely commits data, the
Names,Addresses,Services andEndpoints appear under Subjects (served from/inventory), each drilling into its facet timelines.
The single setting most likely to cause a silent empty dns scan is the local
vantage's resolver on an off-compose install. It ships 127.0.0.11:53 (Docker's embedded
DNS), which is not routed on a bare-metal or host-network install; set it to your own
recursive resolver before the first dns trigger. See
running.md → The local vantage resolver.
Exposure is empty or withheld
Exposure is composed from two Reach legs — an internet-class vantage's reading
and an internal-class one's — and exists only where both legs hold a value. If
Exposure is blank, one leg is missing, and the system is degrading to internal-only on
purpose: it will never print firewalled or exposed for something it did not observe
from the internet.
-
No prober provisioned. An internet-class vantage exists exactly where a second host observed this instance's presented address, so exposure requires a prober, unconditionally. Until one exists, exposure claims are withheld and only the surviving (internal) leg's
Reachrenders on its own. Stand one up: prober.md. -
The hairpinning trap. Deploying the instance and the prober both outside your network gives you only the internet leg — two outside observers are still one side of the boundary. Probing your own public address from inside hairpins and never traverses the inbound policy, so that reading would be a trap, not a measurement. You need a vantage inside your network; declare an address scope covering the instance's presented address (a
/32or/128) so its own vantage verifiesinternal. See first-run.md → whyExposureneeds two legs. -
Both vantages verify
internet. After provisioning a prober but before declaring your egress, both legs are internet-class and there is still no internal leg — Exposure stays non-constructible. Declare the egress verge rendered (prober step 3) to unlock it: prober.md → Confirm exposure is now constructible.
Adding the first internet vantage does not escalate your estate overnight. It opens
the Exposure timelines (recorded as revealed, one coverage-class message) rather than
transitioning every service to exposed.
Nothing is being delivered
Notification delivery runs inside worker and is a no-op on a default install: no
channel ships configured, so nothing is ever routed until an admin declares one. If
signals fire but no notification arrives, check in this order:
-
A channel is configured. With no channel declared there is nothing to route, and the delivery runner sits idle by design. Configure one first — notification-channels.md.
-
Deliveryfailures in the logs. A routed delivery that the endpoint rejects rides the queue's shared retry/backoff curve — five attempts over roughly an hour, then dead-lettered — never a second retry mechanism. The shapes:A dead-lettered delivery is a delivery problem only; the underlying
Messageis never touched, so nothing about the finding is lost. -
Missing
VERGE_PUBLIC_URL. The absolute base each notification body's link is built on. It is not required for delivery — an empty value leaves the link off rather than fabricating one, so bodies still send, just without a click-through. If notifications arrive but have no link back, setVERGE_PUBLIC_URLon theworkerservice env (notweb). See running.md → Environment variables.
Migrations on boot
web runs the goose migrations against Postgres on startup — there is no separate
migrate step, and worker applies none (web owns that). The schema change lands before
the new web/worker code serves traffic, which is why an upgrade wants a pgdata
backup first.
A failed migration is fatal for web — the process exits rather than serving against a
half-migrated schema — so the symptom is web restart-looping and never reaching
running / healthy in docker compose ps. Read it from the web logs:
The failure is logged with a web: migrate: prefix and the underlying apply migrations
cause, e.g.:
A healthy boot instead reaches web: listening on :8080. If web never gets past the
migrate line, the cause is in Postgres (an unreachable DB, a bad POSTGRES_PASSWORD, or a
migration that could not apply) — not in the UI.
"Why does it say Gap / Coverage incomplete?"
A Gap is the honest we-could-not-construct-this, not an error. Common causes:
- A scan resolved nothing — the empty-
dnscase above. TheGapis the product telling you the batch committed but found no records; chase the resolver or the scope. - Only one exposure leg — Exposure is unconstructible until both legs hold a value (above). Coverage says so plainly instead of guessing.
- A CDN-fronted domain. If a name resolves to a CDN, anycast, or reverse-proxy edge
(Cloudflare, Fastly, and the like), probing its resolved IPs measures that edge, not
your origin — these edges complete the TCP handshake on nearly every port, so a
hotscan reports the whole range asreached. The numbers are real; they are about the wrong host. Declare your origin IPs as an address scope (each address is then walked directly), and prefer that over a custody extension on a CDN-fronted name. Full treatment: first-run.md → CDN-fronted domain caveat.
Coverage is as much the job as reading Exposure — read /coverage before you conclude the
estate is empty. Deeper reading of these surfaces is in
reading-the-estate.md.
Setup token and first-run access
On first boot, with no accounts yet, web opens a single-use setup window and logs the
token:
-
Pin it instead of reading the logs. Set
VERGE_SETUP_TOKENon thewebservice and that value is used verbatim — the one config that may live in the environment, because it must exist before the database has an admin. -
Token spent /
/setupclosed. The window shuts the instant the first account exists — that is what makes the token single-use. Once an account exists,webcarries no setup token at all, so/setupredirects to/loginrather than offering a form. If you reach/loginwhen you expected/setup, an admin was already created; sign in, or recover the password through/forgot(on a host with no mail the reset link is written to theweblogs, same as the setup token). -
"Invalid setup token." The value submitted did not match. Re-copy it from the
weblogs (or from yourVERGE_SETUP_TOKEN) as one unbroken string — no trailing whitespace or line-wrap.
Healthz and the self-test
Two surfaces confirm a service is live without opening the UI, both reported by
docker compose ps:
-
web's/healthz. The-healthcheckflag hits/healthz, which records a heartbeat and returns{"status":"ok","checked_at":…}with200. A503means the heartbeat write to Postgres failed — a database problem, logged asweb: healthz: record heartbeat:.worker's check verifies only that it can reach Postgres. -
The prober self-test. On startup
workerexecs its prober once to prove the job-spec-in / NDJSON-out contract works. It is logged, not fatal:A failed self-test does not stop the worker, but it is your earliest signal that the pushed-binary path is broken before you try to provision a prober.
Where to look next
| Symptom | Start here |
|---|---|
| Scan ran but no subjects | Confirm a scan ran, then /coverage |
| Exposure blank or withheld | prober.md |
| Signals fire, nothing arrives | notification-channels.md |
web restart-looping on boot | Migrations on boot |
| Reading Coverage / Exposure in depth | reading-the-estate.md |