Running verge-asm
How to deploy, configure and operate the stack. For what to do once it is up, see using.md; to build and test from source, see verifying.md.
The authoritative reference for every decision below is
docs/spec/packaging-and-configuration.md.
This guide is the operational digest.
Prerequisites
- Docker with the Compose plugin. That is the whole list — the Go toolchain,
sqlcand every dependency are baked into the images. - A host that can reach the addresses and names you intend to measure.
- For exposure findings: a separate Linux host to run as a prober. The
deploy/prober/recipe stands one up withdocker compose; the worked walkthrough is prober.md (overview in using.md → Provision a prober).
The images build and run on linux/amd64 and linux/arm64 only. Both are
first-class; the prober binary for both architectures ships in every image, so an
arm64 instance can push to an amd64 VPS and vice versa.
First launch
web runs the goose migrations against Postgres on startup — there is no separate
migrate step. Watch the stack come up:
Then follow using.md for the setup token and first-run checklist.
Configuration
The environment configures the process; the database configures the product. The environment holds only what must exist before the database does. Everything you declare — seeds, exclusions, scans and their cadences, source enablement, vantages, notification routing — is a row edited through the UI by an authenticated admin, because those acts need an author in the audit trail. There is no config file to mount.
Environment variables
Set these in .env (compose reads it automatically) or your orchestrator.
| Variable | Service | Required | Default | Purpose |
|---|---|---|---|---|
POSTGRES_PASSWORD | all | yes | — | DB credential. Compose fails rather than defaulting it. |
POSTGRES_USER | all | no | verge | DB user. |
POSTGRES_DB | all | no | verge | DB name. |
VERGE_LISTEN_ADDR | web | no | :8080 | Listen address for the UI. |
VERGE_SETUP_TOKEN | web | no | generated | Pin the first-run setup token instead of reading it from the logs. Single-use. |
VERGE_SECURE_COOKIES | web | no | off | Set truthy (1/true/yes/on) when a TLS-terminating proxy fronts web, so the session cookie is marked Secure even though web sees plain HTTP. |
VERGE_PROBER_PATH | worker | no | /app/prober | Path to the prober binary inside the image. Rarely changed. |
VERGE_STATE_DIR | web, worker | no | /app/state | On-disk home for generated secrets (session key, prober SSH private key). |
VERGE_PUBLIC_URL | worker | no | empty | Absolute base URL used to build the link in each notification body. Empty leaves the link off rather than fabricating one. Add it to the worker service env if you configure notification channels. |
DATABASE_URL is assembled from the POSTGRES_* values by docker-compose.yml; you
only set it directly if you run the binaries outside compose.
The local vantage resolver
The one product-side default you may need to change before the first scan is the
recursive resolver the dns scan queries, carried on the shipped local vantage. It
ships as 127.0.0.11:53 — Docker's embedded DNS — which works on this docker compose
deployment out of the box. Off compose (bare-metal or a host-network install, where
127.0.0.11 is not routed) set it to your own recursive resolver before the first dns
trigger, or the scan resolves nothing and commits a silent Gap. The local vantage is
resolver-only and has no prober page; change it on the row directly — see
using.md → Run the first batch for the exact command.
Where secrets live
Ruled by ADR-0053. The database holds no secret.
| Secret | Held by | Origin |
|---|---|---|
| Database credential | environment | you supply it (POSTGRES_PASSWORD) |
| Session signing key | web-state volume | generated by web on first boot |
| Prober SSH private key | worker-state volume | generated by worker at provisioning; only the public half leaves |
| Setup token | nowhere | generated, logged once, consumed on use |
web never renders a secret value — only set / not set, and the prober's
public key. A database dump therefore leaks neither the session key nor the SSH
private key: they live on the per-service state volumes, not in Postgres.
Volumes
docker-compose.yml declares three named volumes. Back these up:
| Volume | Holds | Losing it means |
|---|---|---|
pgdata | the entire estate — subjects, observations, spans, all declared data | total data loss |
web-state | session signing key | all sessions invalidated; a new key is regenerated |
worker-state | prober SSH private key | provisioned vantages must re-install the new public key |
For the restore side this section omits — taking and restoring a consistent pgdata
dump, what each state volume regenerates when lost, retention tuning, and a
back-up/test-restore checklist — see backup-and-restore.md.
Networking and security posture
postgrespublishes no port. It is reached only over the compose network bywebandworker.- Only
webpublishes a port (8080). Put a TLS-terminating reverse proxy in front of it for any real deployment, and setVERGE_SECURE_COOKIES=true. - Every service runs non-root (
65532:65532),cap_drop: [ALL],no-new-privileges. The prober inherits the same posture on the host it is pushed to — it is invoked as an ordinary unprivileged SSH user and needs no capability, because probing uses TCP connect rather than raw sockets.
The instance is a high-value target: its database is a complete, current map of
your attack surface. Treat access to web and to pgdata accordingly.
Operating
Health
Both web and worker ship a -healthcheck flag that compose runs on an interval;
docker compose ps shows the result. web's check hits /healthz; worker's
verifies it can reach Postgres.
Scaling workers
Workers are byte-identical and carry no per-instance configuration, so you can run several safely — none can drift to a different aperture from its siblings:
On-demand scan triggers
Scans normally fire on their own cadence. To dispatch one immediately — the operator/CI path that produces observation rows on demand — trigger the worker by scan kind. The triggered worker enqueues that scan, drains the queue synchronously, then exits:
The scan kinds
Six kinds ship. Each is an accepted value for -trigger, and each has its own shipped
cadence:
| Kind | What it does | Cadence | Ships |
|---|---|---|---|
dns | Resolves the name-scope seeds from every configured vantage (no port list). | daily | enabled |
hot | Active TCP connect scan of the verge-core "hot" port set, per vantage. | daily | enabled |
cold | Active TCP connect scan over the full 1–65535 range, per opted-in scope. | monthly | disabled |
tls-acceptance | TLS-handshake enumeration over the open Service population (no port list). | weekly | enabled |
zone | Worker-read ingest of uploaded zone files (no vantage, no prober). | monthly | enabled |
ct | Worker-read crt.sh certificate-transparency poll (no vantage, no prober). | daily | enabled |
Three things to know before you trigger one:
-
hotandcoldare active port scans. They open real TCP connections across the target ports, so ahotscan can run for minutes, and acoldscan (all 65,535 ports) considerably longer.dns,zoneandctare cheap by comparison. -
coldships disabled, and a trigger refuses a disabled scan — it does not run it once as a one-off:coldenables itself only once you opt a seed scope into it; then-trigger colddispatches normally. The same refusal applies to any kind an admin has disabled. -
A trigger is an extra fan-out, not a reschedule. It enqueues the scan keyed to "now" and does not reset the cadence schedule; the unique
(scan, scheduled_time)key keeps a manual run from colliding with the automatic one.
run vs exec
Use docker compose run, not exec:
run starts a fresh, throwaway worker container (same image, env and volumes as the
long-running worker service) that takes the trigger path, drains the queue, and exits —
--rm removes it afterward. It does not start the dispatcher loop, retention, or
delivery runners; those belong to the daemon.
docker compose exec worker … runs a command inside the already-running worker
container instead of spawning a new one — useful for -healthcheck, but the wrong tool
for a trigger. If a long-running worker daemon is up, it shares the same Postgres queue,
so it may claim and drain the jobs your trigger enqueued (each job is claimed by exactly
one worker, never both) — the trigger still works, but the fresh run container is the
clean, self-contained way to do it.
Logs
The setup token, prober self-test result, and dispatcher/delivery/retention status all surface here.
Retention
Two retention sweeps run inside worker, both off until you set the dial in the
UI (v1 ships the corpus growing without bound):
- Dispatch retention — retires expired operational dispatch rows. It never touches observation or span data.
- Observation retention — retires only evidential observations past their own per-timeline bound and your dial; a derivation always reads live-tier data.
Upgrades
web applies any new migrations on the way up. Because the schema change lands
before the new web/worker code serves traffic, take a pgdata backup first for
anything you cannot afford to roll forward through.