Skip to main content

Doctor

homebutler doctor checks the machine for the problems that usually hurt a self-hosted server and prints one finding per problem. Each finding has a severity, a category, an explanation, and the command to run next. The check is read-only: it changes nothing on the machine.

homebutler doctor

The output starts with an overall status and the finding counts. This run used a config that sets alerts.memory to 10, so memory counts as almost full:

๐Ÿฉบ Homebutler Doctor โ€” homelab
2026-10-25T18:37:49Z

โŒ Status: FAIL ยท pass 0 / warn 7 / fail 2

โŒ [system] Memory is almost full
Memory is 40%, threshold is 10%.
โ†’ Find memory-heavy processes now; otherwise containers may be killed unexpectedly.
$ homebutler ps --sort mem

Each finding has up to four lines: the severity icon, [category] and title; the detail; the action (โ†’); and the command ($).

FlagDefaultWhat it does
--strictoffExits with code 1 when the status is warn or fail.
--backup-max-age <duration>168hWarns when the newest backup is older than this. Takes a Go duration such as 72h.
--jsonoffPrints the result as JSON.

Severities and exit codesโ€‹

A finding is pass, warn or fail. The overall status is fail if any finding fails, otherwise warn if any finding warns, otherwise pass.

SeverityIconMeans
passโœ…The check ran and found nothing to fix.
warnโš ๏ธWorth a look. The server may be working.
failโŒSomething is broken or about to be.

Without --strict, doctor exits 0 whatever the status, because the diagnosis succeeded. The run above printed Status: FAIL and exited 0. With --strict, a warn or fail status prints the findings and then exits 1 with this message on stderr:

error: doctor found 8 warning(s) and 2 failure(s)

A pass status exits 0 with or without --strict. An invalid argument, such as --backup-max-age 5x, exits 1:

error: invalid argument "5x" for "--backup-max-age" flag: time: unknown unit "x" in duration "5x"

Use --strict in cron or CI so a warning stops the job.

Categoriesโ€‹

Every finding carries one of eleven category values. A script can filter on them: the list is fixed.

CategoryWhat it covers
systemCPU, memory and disk against the alerts thresholds.
dockerStopped containers, and running containers that mount the Docker socket.
exposurePorts listening on all interfaces, and the installed web dashboard.
backupWhether backups exist, how old they are, whether they were drilled, and directory size.
reportWhether a report baseline exists.
notificationsWhether a notification channel exists and whether watch will use it.
watchWhether anything polls the watch list, and how full the incident history is.
configConfig file permissions.
proxmoxProxmox VE endpoints: certificate trust and reachability.
incompleteA collector failed, so doctor could not check everything.
overallThe single pass finding printed when no other finding exists: No obvious issues found.

What each check looks atโ€‹

Systemโ€‹

system compares the current reading with the alerts thresholds in your config. Without a config, the defaults apply. See Alert thresholds.

FindingSeverityFires when
CPU is unusually busywarnCPU is at or above alerts.cpu (default 90).
Memory is almost fullfailMemory is at or above alerts.memory (default 85).
Disk is almost fullfailA mount is at or above alerts.disk (default 90). One finding per mount.
Could not read system healthwarnThe system reading failed.

Docker and exposureโ€‹

FindingSeverityFires when
N container(s) are stoppedwarnAny container is in a state other than running. The detail lists the names.
<name> mounts the Docker socketwarnA running container has /var/run/docker.sock mounted. It can create containers on the host as root. One finding per container.
N port(s) are listening on all interfaceswarnAny listener is bound to 0.0.0.0, :: or *.
Dashboard installed on <address>passA serve install unit exists. The detail says whether it is reachable only from this machine or from the network with web.token set.
An installed dashboard does not say what it bindswarnThe unit names no --host and --port.
The installed dashboard answers the network with no tokenfailThe unit binds a non-loopback address and web.token is not set.

Backupsโ€‹

doctor reads the backup directory (backup.dir, backup_dir, or ~/.homebutler/backups/) and takes each .tar.gz file's modification time as its age. The test run used an archive dated 10 days back:

โš ๏ธ [backup] Latest backup is older than expected
Latest backup is 10d old; expected within 7d.
โ†’ Run a fresh backup. If this app matters, follow up with a backup drill.
$ homebutler backup

โš ๏ธ [backup] No backup has ever been drilled
Backups exist and none has been restored to see whether it comes back.
โ†’ Drill one app to find out whether the archive is worth having.
$ homebutler backup drill --all
FindingFires when
No backups foundThe directory has no .tar.gz file.
Latest backup is older than expectedThe newest archive is older than --backup-max-age.
No backup has ever been drilledArchives exist and no drill record does.
The newest backup has never been drilledThe last drill predates the newest archive.
The last drill did not come backThe most recent drill failed.
Backups have no retention limit and the directory is largeThe directory holds 20 GB or more and backup.retention is not set.
Could not check backups, Could not read backup timestamps, Could not check drill history, Could not read the drill historyThe directory or a record could not be read.

All backup findings are warn, so --strict exits 1 on a host that has backups and has never drilled one.

The MCP doctor tool takes the same threshold as backup_max_age_hours, an integer number of hours.

Report, watch and notificationsโ€‹

FindingSeverityFires when
No report baseline yet (report)warn~/.homebutler/reports/snapshots/ has no snapshot.
No notification channel configured (notifications)warnnotify has no channel with all of its required keys.
Notifications are configured and switched off for watch (notifications)warnA channel exists, the watch list is not empty, and watch.notify.enabled is false.
Watch notifications are on (notifications)passwatch.notify.enabled is true, a notification channel is configured and the watch list is not empty. The detail says what notify_on sends.
N target(s) on the watch list and no service installed to check them (watch)warnTargets exist and no watch install unit is installed.
N target(s) watched by an installed service (watch)passA unit is installed.
N of M incidents kept (watch)warnThe incident count reaches 80% of watch.retention.max_incidents.

The watch check looks for the unit file and doesn't ask systemd or launchd whether the service is running.

Config and Proxmoxโ€‹

FindingSeverityFires when
Config holds plaintext secrets and is readable by others (0644) (config)failThe config file has secrets and is readable by other users. homebutler refuses to load it until you run chmod 600.
Proxmox endpoint "<name>" accepts any certificate (proxmox)warnThe endpoint sets insecure: true.
Proxmox endpoint "<name>" is fully readable (proxmox)passVersion, cluster status and resources all answered.
Proxmox endpoint "<name>" is only partially readable (proxmox)warnSome collectors answered.
Proxmox endpoint "<name>" could not be configured (proxmox)failThe endpoint settings could not be loaded, such as a token_file that doesn't exist. The detail carries the error.
Proxmox endpoint "<name>" has no readable collectors (proxmox)failNone answered. The action depends on whether the failure was configuration, TLS, authentication, authorization, the response, or the network.

Both of these came from the test run:

โŒ [config] Config holds plaintext secrets and is readable by others (0644)
open.yaml can be read by any user on this machine, and homebutler will refuse to load it.
โ†’ Restrict it to your account.
$ chmod 600 open.yaml

โš ๏ธ [docker] sock mounts the Docker socket
A container with /var/run/docker.sock mounted can create containers on the host as root, so it holds host root however unprivileged it looks. Some images need it; most do not.
โ†’ Confirm this container is one that needs it, and remove the mount if it is not.
$ homebutler docker inspect sock

Incompleteโ€‹

When a collector fails, doctor adds an incomplete finding for each warning and still runs the checks it can. With DOCKER_HOST pointing at a missing socket:

โš ๏ธ [incomplete] Doctor could not check everything
docker: docker daemon is not running: exit status 1
โ†’ Fix this first so doctor can give a complete answer.
$ homebutler doctor

Use doctor from a scriptโ€‹

--json returns the status, the counts and every finding. A finding carries runner and tool when it has a command. runner is mcp when an MCP tool runs the command and tool names it, cli when only the CLI can, and shell for a command that isn't homebutler's.

{
"severity": "fail",
"category": "system",
"title": "Memory is almost full",
"detail": "Memory is 40%, threshold is 10%.",
"action": "Find memory-heavy processes now; otherwise containers may be killed unexpectedly.",
"command": "homebutler ps --sort mem",
"runner": "mcp",
"tool": "processes"
}

The top level carries schema_version, timestamp, server_name, status and summary (pass, warn and fail counts). The watch install and serve install commands are CLI-only because the unit records the path of the binary that installs it. See JSON output for the rest.