checkfleet

Usage

checkfleet check <all|certs|http|nats|haproxy|stream|patroni|consul|postgres|dns> --config checkfleet.yml [--output text|markdown|json] [--exit-on-bad]
checkfleet version

Scaffolding a config with init

Start from a ready-to-edit config instead of a blank file:

checkfleet init                                   # a starter config (certs + http)
checkfleet init --modules certs,http,dns,tls      # pick the modules you want
checkfleet init --list                            # list the modules init can scaffold

init writes a commented checkfleet.yml with placeholder targets that already load and validate — edit the values, keep secrets in the environment, then run checkfleet check all. It refuses to overwrite an existing file unless you pass --force.

Recipes: start from a stack, not a module list

checkfleet init --recipe web       # http + certs + dns
checkfleet init --recipe db        # postgres + redis + tcp
checkfleet init --recipe edge      # haproxy + certs + tcp + ntp
checkfleet init --recipe media     # stream + ingest + http
Recipe Why those modules
web is it answering, is the certificate alive, do the records still point at us
db connectivity, replication lag, memory pressure
edge backend health, TLS expiry, reachability and clock skew
media manifest freshness, live-edge age, and the ingest endpoints behind it

The value isn’t saving typing. It’s that someone new to this doesn’t yet know a web tier needs dns (records drift after a migration and nothing else notices) or that an edge tier needs ntp (clock skew breaks TLS and tokens long before anyone suspects the clock).

From an Ansible inventory

The inventory already knows every host. Generate the targets from it instead of retyping hostnames:

checkfleet init --from-inventory hosts.ini                          # certs + http
checkfleet init --from-inventory hosts.ini --modules certs,http,tcp
checkfleet init --from-inventory hosts.ini --group web

Addresses come from ansible_host where the inventory sets one, otherwise from the host name. For http/certs you may want the public DNS name instead — SNI and the certificate CN follow the name, not the IP — which is why the generated file says so in a comment.

Only modules whose target can be derived from a hostname alone are available here (certs, tls, http, tcp, dns, ntp, redis, memcached, haproxy, consul, nats). Ask for postgres and it refuses with an explanation rather than inventing a DSN: a config that looks ready and fails on its first run is worse than being told up front.

Output is deterministic (hosts sorted by name), so regenerating after an inventory change produces a diff of the change and nothing else.

The check command

checkfleet check all   --config checkfleet.yml                    # every configured module
checkfleet check certs --config checkfleet.yml                    # a single module
checkfleet check http  --config checkfleet.yml --output json      # machine-readable

all runs every module present in the config. Naming a single module runs only that one; if it isn’t configured, the command fails.

Flags

Flag Default Meaning
--config checkfleet.yml Path to the YAML config.
--stack Overlay a per-stack profile checkfleet.<stack>.yml on the base config. See Configuration → multi-stack.
--output text Output format: text, markdown, json, junit, prometheus, slack, or webhook. See Output formats.
--out-file Write the output atomically to this file instead of stdout (e.g. a node_exporter .prom file).
--no-color Disable ANSI colour in the text output. Colour is on automatically only when writing to a terminal; it is always off when piped, redirected to a file, or when NO_COLOR is set.
--webhook-env SLACK_WEBHOOK Env var holding the webhook URL (used by --output slack and --output webhook).
--only Show only these checks (comma-separated, e.g. --only certs,http).
--min-severity Show only findings at or above ok|warn|bad|error.
--target Show only targets matching this glob (e.g. --target '*.example.com').
--history JSONL file to append each run to; enables flap detection across runs.
--flap-changes 3 Minimum status changes in the window to flag flapping.
--flap-window 10 Number of recent runs to evaluate flapping over.
--ping-url-env Env var with a dead-man’s-switch URL (e.g. Healthchecks.io) to ping at the end of the run.
--exit-on-bad off Exit 2 when any BAD/ERROR finding is present. For CI gates.

With --history <file>, each run is appended to a JSONL log and any check/target that changed status at least --flap-changes times over the last --flap-window runs gets an extra flap WARN finding — useful to spot unstable targets that pass/fail intermittently. Zero dependencies (plain JSONL).

Filters apply to the rendered output (and therefore to --exit-on-bad and the JSON worst), so --min-severity bad --exit-on-bad gates only on real problems.

With --ping-url-env, checkfleet pings a dead-man’s-switch at the end of the run: the base URL on success, <url>/fail when the worst finding is BAD/ERROR. Combined with cron it also catches the case where checkfleet didn’t run at all.

The serve command

Run checkfleet as a Prometheus exporter: it re-runs the configured checks on an interval and exposes the latest findings as metrics on /metrics.

checkfleet serve --config checkfleet.yml --listen :9876 --interval 60s
Flag Default Meaning
--config checkfleet.yml Path to the YAML config.
--listen :9876 Address to listen on.
--interval 60s How often to re-run the checks.
--log-format text text (human) or json (structured) logs for run start/end and errors.

Metrics exposed:

Metric Meaning
checkfleet_finding_status{check,target} Severity of each finding: 0=OK, 1=WARN, 2=BAD, 3=ERROR (worst wins per check/target).
checkfleet_findings_total{status} Count of findings per status.
checkfleet_worst_status Worst severity across the run.
checkfleet_run_duration_seconds Duration of the last run.
checkfleet_last_run_timestamp_seconds Unix time of the last run.
checkfleet_module_findings{module} Findings produced by each module in the last run.
checkfleet_module_errors{module} ERROR findings (measurement failures) per module.

The server also exposes /healthz (liveness — the process is up) and /readyz (readiness — returns 503 until the first run completes, then 200), for Kubernetes/Nomad probes.

This is the bridge to Grafana/alerting: checkfleet keeps the domain logic, and Prometheus does the graphing and alerting — it doesn’t replace them.

The report-issues command

Turn BAD/ERROR findings into tracker issues: one issue per check/target, opened when it fails and closed automatically when it recovers. Idempotent — safe to run on a schedule. Works with GitHub (via gh) or GitLab (via glab).

checkfleet report-issues --config checkfleet.yml               # GitHub (default)
checkfleet report-issues --config checkfleet.yml --forge gitlab
checkfleet report-issues --config checkfleet.yml --dry-run     # preview, no changes

--forge github|gitlab picks the tracker; the matching CLI (gh/glab) must be installed and authenticated.

Flag Default Meaning
--config checkfleet.yml Path to the YAML config.
--stack Overlay a stack profile (see multi-stack).
--dry-run off Print what would open/close without touching any issue.

Managed issues carry the checkfleet-finding label and a [checkfleet] check/target title (the dedup key). Requires the matching CLI authenticated: gh for GitHub (in CI provide GH_TOKEN) or glab for GitLab (--forge gitlab).

The validate command

Check the config without running any check — useful in CI or a pre-commit hook. It reports missing targets/URLs/DSNs, incoherent thresholds (e.g. warn past crit), an empty checks, and misspelled keys — with the fix, not just the complaint.

checkfleet validate --config checkfleet.yml            # exit 0 if usable
checkfleet validate --config checkfleet.yml --stack prod
checkfleet.yml: 3 problem(s):
  - unknown key "postgress" at `checks` — it is ignored, so nothing you configured under it runs → did you mean "postgres"?
  - unknown key "timeout_second" at top level — it is ignored, so nothing you configured under it runs → did you mean "timeout_seconds"?
  - certs: warn_days (5) should be >= crit_days (30) → the warn threshold must trigger before the crit one — check the two values are not swapped

Why misspelled keys matter most

YAML unmarshalling silently drops anything it doesn’t recognise. Write postgress: instead of postgres: and the module simply never runs — no error, no warning, and a check all that reports a healthy fleet because it checked nothing. validate compares the keys in your file against the ones the config actually accepts (read off the structs, so the list can’t drift) and suggests the nearest match by edit distance, or by prefix (elasticelasticsearch).

When nothing is close enough, no suggestion is offered: a confidently wrong “did you mean” is worse than none.

You don’t have to remember to run validate to hear about it. Every command that acts on a config — check, serve, report-issues, alert, targets — prints the same notice on stderr, following include: chains and --stack overlays:

checkfleet: warning: unknown key "postgress" at `checks` — it is ignored, so nothing you configured under it runs → did you mean "postgres"?

It stays a warning, not an error: the run continues and the exit code does not change, because a config written for a newer checkfleet has to keep working on an older one. Stderr rather than stdout so the notice can never end up inside a JSON document or a webhook payload. See Compatibility for why that tolerance is a deliberate guarantee rather than an oversight.

Notes vs problems

Some findings are about this machine, not about the config:

checkfleet: checkfleet.yml is valid ✅
  note: environment variable PG_PASSWORD is not set, so it expands to an empty value → export PG_PASSWORD=... before running, or write ${PG_PASSWORD:-default} to make the fallback explicit

An unset ${VAR} is reported as a note and does not fail validate, because a laptop running a pre-commit hook legitimately doesn’t have production secrets exported — failing there would just teach people to skip the hook. Use doctor when the environment is the subject: it treats the same unset variable as BAD.

Exit 1 on any real config defect; 0 when only notes remain. A config that fails to load is reported with its raw-level problems, which usually explain it.

The doctor command

Preflight: why isn’t this working? — about your environment, not your services.

checkfleet doctor --config checkfleet.yml
checkfleet doctor --config checkfleet.yml --no-probe     # config + variables only
checkfleet doctor --config checkfleet.yml --output json
⛔ ERROR network  10.20.30.11:5432          resolves but refuses TCP: connection refused
⛔ ERROR network  db-old.internal:3306      does not resolve: no such host
🔴 BAD   env      ${PG_PASSWORD}            environment variable PG_PASSWORD is not set — it expands to an empty value
🔴 BAD   target   redis 127.0.0.1:65999     implausible port 65999
🟡 WARN  target   http                      duplicate target https://example.com/ (×2)
🟢 OK    config   checkfleet.yml            valid

What it reports:

Check Finds
env ${VAR} referenced by the config but not set (BAD), set only via a :-default (WARN), and ${file:…} secrets that can’t be read (BAD)
config everything validate reports, plus a config that fails to load
target addresses with no derivable host, implausible ports, duplicate targets
network per host: does it resolve, and does the port accept a TCP connection (ERROR when not)

Three things worth knowing:

An unset ${VAR} is BAD, not a warning. It expands to the empty string, silently — the config parses, the check runs, and it fails against an empty password with an error that blames the database. Naming the variable is the whole reason this command exists.

It works on a config that won’t load. The variable scan reads the raw file before any parsing, and a load failure is reported as a finding rather than an abort — a broken config is exactly when you need a diagnostic.

Network problems are ERROR, not BAD, the same distinction the check modules make: “we could not measure from here” is not “the target is unhealthy”. Probes are deduplicated per host:port, so 40 URLs on one host is one line.

doctor exits 0 whatever it finds. It is a diagnostic, not a gate — use check --exit-on for that.

The targets command

What is this config actually watching? targets flattens every target across every module — the answer to “did anyone add the new database to monitoring?”.

checkfleet targets --config checkfleet.yml
checkfleet targets --config checkfleet.yml --module certs
checkfleet targets --config checkfleet.yml --output json
14 target(s) across 3 module(s)

certs (2)
  github.com
  api.example.com

http (11)
  https://example.com/health                           → example.com

Coverage against an Ansible inventory

Point it at the inventory your playbooks already use and it tells you which hosts are unmonitored:

checkfleet targets --config checkfleet.yml --against hosts.ini
checkfleet targets --config checkfleet.yml --against hosts.ini --group db
coverage vs hosts.ini: 4/5 inventory host(s) covered

not monitored (1)
  db-99

targeted but not in the inventory (2)
  github.com
  0.pool.ntp.org

The last section is not an error: external dependencies legitimately aren’t in your inventory. It’s shown because a typo in a target looks exactly the same — a host you meant to watch, silently watching nothing.

Matching is by hostname, case-insensitive, against both the inventory name and its ansible_host (so web2 ansible_host=10.0.0.5 matches a target naming either). One target can cover several hosts: a MongoDB replica-set URI names every member, and all of them count as covered.

Credentials never appear in the output. Targets for postgres/mysql/mongodb are DSNs with passwords in them; only the extracted hostname is ever printed, so this is safe to pipe into a CI log or commit as a JSON artifact.

Like doctor, this is a diagnostic: it exits 0 even when hosts are uncovered. A coverage gap is something for a human to decide about, not a build failure.

Finding statuses

Status Meaning
OK Healthy.
WARN A soft threshold was crossed (e.g. cert near expiry, slow response).
BAD The target is unhealthy (e.g. cert expired, wrong HTTP status).
ERROR The check itself could not measure — network failure, TLS handshake error. Not the same as BAD.

Findings are always sorted worst-first, stable per check/target — the first line is the thing you must look at.

Exit codes

checkfleet distinguishes “a check found a problem” from “checkfleet itself failed”. A check that ran is a success.

Code When
0 The run completed — even with WARN/BAD/ERROR findings, unless a gate was set. Diagnostic commands (targets, validate’s clean case) also exit 0.
2 --exit-on warn\|bad\|error was set and a finding reached that severity. --exit-code N changes this number.
64 Usage error (missing/unknown subcommand).
1 Systemic error: unreadable config, unknown module, unknown output format, invalid flag.

This semantics is intentional and stable — see CI integration for how to gate on it.

Explain a module

checkfleet explain <module> prints what a module checks and its key thresholds; with no argument it lists all modules.

checkfleet explain            # list modules
checkfleet explain postgres   # what the postgres check verifies

Shell completion

checkfleet completion bash > /etc/bash_completion.d/checkfleet   # bash
checkfleet completion zsh  > "${fpath[1]}/_checkfleet"           # zsh
checkfleet completion fish > ~/.config/fish/completions/checkfleet.fish

Completes subcommands, module names (after check/explain) and --output formats.

Live watch

--watch <interval> re-runs the checks on a timer and redraws a live terminal view (text output), handy during an incident. Ctrl-C to stop.

checkfleet check all --config checkfleet.yml --watch 5s

Diff vs the previous run

With --history <file>, --diff prints only what changed since the previous recorded run — new / resolved / worsened / improved findings — instead of the full table. Great for a cron that only reports deltas.

checkfleet check all --config checkfleet.yml --history runs.jsonl --diff

The alert command

Create/resolve on-call alerts from BAD/ERROR findings (dedup by check/target) on PagerDuty (Events API v2) or Opsgenie. With --history it resolves alerts that recovered since the previous run. The key is read from an env var.

checkfleet alert --config checkfleet.yml --provider pagerduty --key-env PD_ROUTING_KEY --history runs.jsonl
checkfleet alert --config checkfleet.yml --provider opsgenie  --key-env OPSGENIE_KEY --dry-run

AWS SNS (--provider sns) publishes each BAD/ERROR finding to an SNS topic — a stateless sink, so it only publishes (no resolve). Requests are signed with AWS Signature V4 written by hand (no AWS SDK); the region is parsed from the topic ARN and credentials come from the environment.

export AWS_ACCESS_KEY_ID=AWS_SECRET_ACCESS_KEY=…
checkfleet alert --config checkfleet.yml --provider sns \
  --sns-topic-arn arn:aws:sns:eu-west-1:123456789012:checkfleet-alerts