Zero-dependency system monitoring in a single C binary. Collects CPU, memory, disk, temperature, and network metrics into SQLite and serves an interactive canvas dashboard.
Designed for resource-constrained Linux systems (small VPS, single-board computers, and homelab servers) where every MB counts.
- Single static binary (~1.2 MB), zero runtime dependencies, no package manager
- CPU load and usage, memory, disk, temperature, network throughput, and uptime
- Interactive canvas dashboard, responsive, accessible, dark/light theme, live updates via SSE
- SQLite storage with configurable retention
- Webhook and command alerts with per-alert cooldown
- TOML configuration, sensible defaults, works with zero config
- Runs comfortably on small arm64 boards (Raspberry Pi 3B, Zero 2 W, 512 MB RAM)
minimoni serve --> collect metrics --> SQLite --> HTTP server --> dashboard :8080
minimoni collect --> collect metrics --> SQLite (oneshot, for systemd timer / cron)
Metrics are read from /proc/ and /sys/. The dashboard HTML is embedded in the binary
at build time, so there are no files to deploy alongside the binary.
| minimoni | Beszel | Netdata | |
|---|---|---|---|
| RAM (daemon) | ~1.6 MB [1] | ~5-10 MB + ~75 MB hub | ~150-200 MB |
| Architecture | single binary | agent + hub | agent (complex) |
| Runtime deps | none | none | many |
| Dashboard | yes (canvas) | yes (web UI) | yes (web UI) |
| Persistent history | yes (SQLite) | yes (SQLite) | yes |
| Alerts | yes (webhook + cmd) | yes | yes |
| License | GPLv3+ | MIT | GPLv3+ / NCUL1 [2] |
[1] Measured on a Raspberry Pi 3B (Raspberry Pi OS, kernel 6.18, arm64): ~1.6 MB PSS at idle and under sustained query load. The static musl build keeps a flat memory profile; it self-trims under query pressure rather than accumulating, and never touches swap. [2] Netdata agent is GPLv3+; the v2 dashboard is under NCUL1, a proprietary licence.
RAM sources: Beszel - HowToGeek (2026), instapods (2026). Netdata - official docs, instapods (2026).
Measured on a Raspberry Pi 3B (Cortex-A53, 1 GB RAM, Raspberry Pi OS, kernel 6.18, arm64)
with the CI-built static musl binary (-Os -flto), against a continuous ~50-day database
(~72k rows at a 1-minute interval):
| Metric | Value |
|---|---|
| Binary size | 1.24 MB |
| PSS (idle and under query load) | ~1.6 MB, flat |
| CPU per collect cycle | ~11 ms (excl. the 250 ms intentional sleep for the CPU delta) |
| Disk writes per 1-min cycle | 24 KiB (SQLite WAL) |
/api/metrics?range=1d |
~100 ms |
/api/metrics?range=7d |
~460 ms |
/api/metrics?range=30d |
~1.7 s |
/api/metrics?range=90d |
~3.0 s |
/api/current, /api/health |
~2 ms, ~1 ms |
PSS does not grow under query load; the daemon self-trims rather than accumulating, and Swap stays at 0 throughout. Long-range query time scales with the number of rows in range.
Prebuilt static binaries for linux-amd64 and linux-arm64 are available on the
releases page.
ARCH=$(uname -m)
case $ARCH in x86_64) ARCH=amd64 ;; aarch64) ARCH=arm64 ;; esac
BASE=https://github.com/javierbeaumont/minimoni/releases/latest/download
curl -fsSL $BASE/minimoni-linux-$ARCH -o /usr/local/bin/minimoni
chmod +x /usr/local/bin/minimoniSupported platforms: linux-amd64 (x86_64), linux-arm64 (Raspberry Pi 3/4/5 and other
64-bit AArch64 boards). A 64-bit OS is required; 32-bit (armv7) builds are not provided.
The release binaries carry SLSA build provenance (SLSA Build Level 2): each is built on GitHub-hosted runners with a signed, verifiable attestation. Verify a download with:
gh attestation verify minimoni-linux-$ARCH --repo javierbeaumont/minimoni# Debian / Ubuntu / Raspberry Pi OS
sudo apt-get install gcc make xxd git
# Alpine (already included in the make release-linux Docker image)
apk add gcc musl-dev make xxd git
# Fedora / RHEL
sudo dnf install gcc make vim-common git # xxd is in vim-common
# Arch
sudo pacman -S gcc make vim gitmake embed # bundle dashboard into build/embed.h (once, or after editing the dashboard)
make # compile with -O2 (development)
make release # compile with -Os -flto, strip, matches the prebuilt binariesbuild/embed.h is generated by xxd -i and is not tracked in git; run make embed before
your first build or after editing any file under dashboard/.
To produce a release binary identical to the prebuilt ones (Alpine musl, static):
make release-linux # builds inside an Alpine Docker containerminimoni serve # start HTTP server + background collector
minimoni collect # collect once and exit (for systemd timer or cron)
minimoni db info <db_path> # inspect a database file (read-only)
minimoni --version
minimoni --help # usage summary (also -h)serve binds to 0.0.0.0:8080 by default. Open http://<host>:8080 in a browser.
To use a config file:
minimoni serve --config /etc/minimoni/config.tomldb info takes the path to a database file (not the config file) and prints a
read-only report: the file size (plus any WAL/SHM sidecars), the format
identifier (minimoni's schema version, or a hex id when the file is not a
minimoni database), the row count with the oldest and newest timestamps and the
time span they cover, the per-tier row distribution (or a note when the database
uses an older schema without tiered consolidation), and a summary of the alert
log. It opens the file read-only, so it is safe to run against the database of a
live daemon.
| Endpoint | Response | Purpose |
|---|---|---|
GET / |
Embedded HTML dashboard | Browser |
GET /api/current |
JSON: latest collected values + config | Snapshot |
GET /api/metrics |
JSON: history grouped into ~points buckets |
Charts |
GET /api/health |
{"status":"ok","version":"..."} |
Liveness probe |
GET /stream |
SSE: live push every refresh seconds |
Live updates |
/api/metrics takes two query parameters, e.g. GET /api/metrics?range=1d&points=480:
range- one of[dashboard].ranges(default1d,7d,30d,90d).points- optional; how many buckets to group the history into. The server caps it at1440(one point per minute over a 24h window, the design point of the tiered consolidation ladder; see ADR-0005) and defaults to240when omitted. The bundled dashboard computes it from the canvas width (1 point per 4 backing pixels, clamped to[120, 1440]).
Create /etc/systemd/system/minimoni.service:
[Unit]
Description=minimoni system monitor
After=network.target
[Service]
Type=exec
ExecStart=/usr/local/bin/minimoni serve
DynamicUser=yes
Restart=on-failure
RestartSec=5
StateDirectory=minimoni
# Hardening: defence in depth on top of DynamicUser.
LockPersonality=true
MemoryDenyWriteExecute=true
NoNewPrivileges=true
PrivateDevices=true
PrivateTmp=true
ProtectClock=true
ProtectControlGroups=true
ProtectHome=true
ProtectHostname=true
ProtectKernelLogs=true
ProtectKernelModules=true
ProtectKernelTunables=true
ProtectProc=invisible
ProtectSystem=strict
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=true
RestrictRealtime=true
RestrictSUIDSGID=true
SystemCallArchitectures=native
SystemCallFilter=@system-service
[Install]
WantedBy=multi-user.targetsystemctl daemon-reload
systemctl enable --now minimoniDynamicUser=yes runs minimoni as an unprivileged user. StateDirectory=minimoni creates
/var/lib/minimoni/ automatically; set collect.db = "/var/lib/minimoni/metrics.db".
For scheduled collection without a persistent process:
# /etc/systemd/system/minimoni-collect.timer
[Unit]
Description=Collect system metrics every minute
[Timer]
OnCalendar=*:0/1
Persistent=true
[Install]
WantedBy=timers.target# /etc/systemd/system/minimoni-collect.service
[Unit]
Description=minimoni metric collection
[Service]
Type=oneshot
ExecStart=/usr/local/bin/minimoni collect
DynamicUser=yes
StateDirectory=minimonisystemctl daemon-reload
systemctl enable --now minimoni-collect.timerminimoni works with zero config. To customize, create config.toml in the working directory
(or pass --config /path/to/config.toml). Search order: --config flag -> ./config.toml ->
/etc/minimoni/config.toml -> built-in defaults.
[collect]
db = "/var/lib/minimoni/metrics.db"
interval = 60
disk_path = "/"db: path to the SQLite database. Default: ./metrics.db. For a persistent installation,
use /var/lib/minimoni/metrics.db (create the directory first).
interval: how often to collect, in seconds (integer). Range: 1 to 3600. Default: 60.
Values below 1 abort with an error; values above 3600 emit a warning and clamp to 3600.
Lower intervals give finer granularity; higher intervals reduce database growth:
| Interval (s) | 90-day database |
|---|---|
30 |
~75 MB |
60 |
~25 MB |
300 |
~5 MB |
disk_path: filesystem path passed to statvfs(). Default: /. To monitor a volume
mounted at /data, set disk_path = "/data".
[server]
listen = "0.0.0.0:8080"
threads = 8
sse_keepalive = 1listen: address and port to bind. Use 0.0.0.0:8080 to accept from any interface, or
127.0.0.1:8080 to restrict to localhost (e.g. when running minimoni behind a reverse proxy).
threads: number of HTTP worker threads. Each open dashboard tab holds one thread for its
SSE connection. Default 8 handles up to 8 simultaneous users; raise if you have more. Values
below 2 are rejected with an error (the SSE connection would occupy the only thread, making the
server non-functional). Values above 256 fall back to the default with a warning. Range: 2-256.
sse_keepalive: how often (in seconds) a keepalive comment is sent over each SSE connection
between data pushes. Allows the server to detect a closed browser tab and free its thread without
waiting up to refresh seconds. Default: 1. Valid range: 1 to refresh - 1. If set to a
value outside this range, keepalive is inactive (logged as a warning at startup) and thread
recovery falls back to the next data push.
[dashboard]
title = "My Server" # browser tab and header (default: "minimoni")
theme = "auto" # "auto" | "light" | "dark"; "auto" follows OS preference
show_footer = true # show version footer (default: true)
refresh = 30 # SSE push interval in seconds (default: 30)
ranges = ["1d", "7d", "30d", "90d"] # time range tabs; largest sets retention
charts = ["cpu_load", "cpu_usage", "memory", "disk", "temp", "net"]
cards = ["cpu_load", "cpu_usage", "memory", "disk", "temp", "net", "uptime"]
cpu_load_card_unit = "abs" # status card: "%" | "abs" (% = normalized by core count)
cpu_load_chart_unit = "abs" # chart Y-axis: "%" | "abs"
memory_card_unit = "%" # status card: "%" | "mb" | "gb"
memory_chart_unit = "mb" # chart Y-axis: "%" | "mb" | "gb"
disk_card_unit = "%" # status card: "%" | "gb" | "tb"
disk_chart_unit = "gb" # chart Y-axis: "%" | "gb" | "tb"
temp_card_unit = "c" # status card: "%" | "c" | "f"
temp_chart_unit = "c" # chart Y-axis: "%" | "c" | "f"
# temp_critical_fallback = 95 # temp % 100% ref when sysfs has no critical trip (default: 85)
net_card_unit = "mb" # status card: "mb" | "gb" | "mbps" | "gbps"
net_chart_unit = "mb" # chart Y-axis: "mb" | "gb" | "mbps" | "gbps"
uptime_unit = "auto" # uptime display: "auto" | "h" | "d"All keys are optional. The values shown above are the defaults.
title: browser tab text, dashboard header, and the hostname field in webhook alert
payloads. If omitted, the dashboard shows "minimoni" and webhook payloads use the system
hostname from gethostname(). Set this when running multiple instances so alert notifications
identify the source host.
theme: when set to "light" or "dark", the theme is fixed and the toggle button is
hidden. "auto" (default) follows the OS preference and shows the toggle.
charts and cards control visibility and order in the dashboard UI. When not set,
all metrics are shown in the default order. Set to [] to hide everything. Set to a list to
show only those metrics in the listed order, e.g. charts = ["memory", "disk"] shows only
those two charts, with Memory first. API endpoints always return all collected metrics; these
lists only control what the dashboard renders. Temperature is the exception (see below).
Temperature visibility: temp is special: it depends on hardware. Two rules apply:
- If
tempis omitted fromchartsorcards, the sensor is not read and no temperature data is sent in API responses. This is the only metric where dashboard config affects the API. - If
tempis included (or the list is not set), but no sensor is present on the host,nullis sent and the card/chart is hidden. A missing sensor never produces a visible empty card.
refresh: how often the dashboard receives a live data push over SSE, in seconds. Must not
exceed collect.interval: a push more frequent than collection would send stale data and waste
bandwidth. If refresh is set higher than interval, minimoni clamps it to interval and logs
a warning. Default: 30.
ranges: time range tabs shown in the dashboard, in the listed order. The largest value
sets the retention period (regardless of position): rows older than that are deleted after
each collect cycle. Units and per-unit caps: m (max 120), h (max 72), d (max 10 years).
Values shorter than collect.interval are skipped with a warning; if every value is invalid
or skipped, the daemon aborts at config load (instead of silently falling back to defaults).
Repeats and custom ordering are valid (e.g. ["4h", "2d", "45d", "2d"] shows four tabs in
that order with 45-day retention). Sub-day ranges round up to 1 day for retention purposes
(prune granularity is days). Default: ["1d", "7d", "30d", "90d"].
The number of data points per chart is no longer a per-install setting - the dashboard
JS asks for what it can render, via the points query parameter on /api/metrics
(see HTTP endpoints). The server caps it at 1440 - one point per
minute over a 24h window, which is also the design point of the tiered consolidation
ladder (see ADR-0005) - and defaults to 240
if the parameter is missing.
Alerts are evaluated after every collect cycle. Each [[alert]] block is independent.
An alert requires webhook, command, or both.
[[alert]]
name = "disk-full" # identifier shown in logs
metric = "disk_percent" # see metric table below
operator = ">" # supported: > < >= <= ==
threshold = 90 # in the metric's own unit (see below)
webhook = "https://ntfy.sh/my-server" # POST JSON to this URL on fire
command = "/usr/local/bin/notify.sh" # execute this command on fire
cooldown = "1h" # minimum time between repeated firingsAvailable metrics:
| Metric | Unit | Description |
|---|---|---|
load_1m |
load avg | 1-minute load average |
load_5m |
load avg | 5-minute load average |
load_15m |
load avg | 15-minute load average |
cpu_user_percent |
% | User-space CPU usage |
cpu_system_percent |
% | Kernel CPU usage |
cpu_idle_percent |
% | Idle CPU |
mem_total_mb |
MB | Total memory |
mem_used_mb |
MB | Used memory |
mem_available_mb |
MB | Available memory |
mem_percent |
% | Used memory as percent of total |
disk_total_gb |
GB | Total disk space |
disk_used_gb |
GB | Used disk space |
disk_free_gb |
GB | Free disk space |
disk_percent |
% | Used disk as percent of total |
temp_celsius |
C | CPU temperature (skipped if no sensor is present) |
net_rx_bps |
bytes/s | Receive throughput |
net_tx_bps |
bytes/s | Transmit throughput |
uptime_seconds |
s | Seconds since boot |
Thresholds are compared against the raw metric value, in the unit named by its suffix:
_mb is megabytes, _gb is gigabytes, _percent is a percentage, _celsius is degrees
Celsius, _bps is bytes per second. load_* are raw load averages; uptime_seconds is
in seconds. Dashboard unit settings do not affect alert thresholds; alerts read the
stored database values directly.
When webhook is set, minimoni sends a POST request (Content-Type: application/json).
Requires outbound HTTP connectivity from the server.
{
"alert": "disk-full",
"metric": "disk_percent",
"value": 91.3,
"threshold": 90,
"operator": ">",
"timestamp": "2026-06-08T14:30:00Z",
"hostname": "my-server"
}When command is set, it is executed via system() with the same user and privileges
as the minimoni process. Keep commands short and non-blocking; long-running commands
delay the next collect cycle.
cooldown prevents repeated firings: the alert will not fire again until the cooldown
period has elapsed. Accepts 30s, 1m, 1h, 1d. Cooldown state is stored in the database
(alert_log table) and survives restarts.
minimoni has no built-in authentication or TLS. Do not expose it directly to the internet.
The recommended setup is to bind minimoni to localhost and front it with a reverse proxy that handles TLS and authentication:
# config.toml
[server]
listen = "127.0.0.1:8080"Caddy (automatic HTTPS + basic auth):
monitor.example.com {
basicauth {
admin JDJhJDE0JHh4eHh4eHh4eHh4eHh4eHh4eHh4eA==
}
reverse_proxy localhost:8080
}
Caddy's reverse_proxy streams SSE without buffering by default; no additional
configuration needed.
nginx:
server {
listen 443 ssl;
server_name monitor.example.com;
auth_basic "minimoni";
auth_basic_user_file /etc/nginx/.htpasswd;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Connection ""; # required for SSE
proxy_buffering off;
}
}The proxy_buffering off directive is required for the SSE live-update stream to reach
the browser without being held in nginx's buffer.
Minimal (Raspberry Pi or homelab):
[collect]
db = "/var/lib/minimoni/metrics.db"Omitting all other keys uses: port 8080, 1-minute interval, / filesystem, 90-day retention.
VPS bound to localhost (behind nginx or Caddy):
[server]
listen = "127.0.0.1:8080"Multiple alerts (disk, CPU load, and temperature):
[[alert]]
name = "disk-full"
metric = "disk_percent"
operator = ">"
threshold = 85
webhook = "https://ntfy.sh/my-server"
cooldown = "6h"
[[alert]]
name = "high-load"
metric = "load_5m"
operator = ">="
threshold = 4
webhook = "https://ntfy.sh/my-server"
cooldown = "30m"
[[alert]]
name = "overheating"
metric = "temp_celsius"
operator = ">"
threshold = 80
command = "/usr/local/bin/thermal-alert.sh"
cooldown = "15m"See config.example.toml for a fully annotated reference.
All four compile directly into the binary; no runtime dependencies, no package manager.
| Library | Version | Purpose | License |
|---|---|---|---|
| SQLite | 3.53.1 | Single-file metric database | Public domain |
| civetweb | 1.16 | Embedded HTTP server, JSON API | MIT |
| tomlc17 | R260517 | TOML configuration parser | MIT |
| BearSSL | 0.6 | TLS client for HTTPS webhook delivery | MIT |
Significant technology choices are documented as ADRs in docs/adr/. Each
record captures the context, the alternatives considered, the decision made, and its
consequences, so future contributors understand not just what was chosen but why.
| ADR | Decision |
|---|---|
| 0001 | SQLite as the metric store |
| 0002 | civetweb as the HTTP server |
| 0003 | tomlc17 as the TOML parser |
| 0004 | BearSSL for HTTPS webhook delivery |
| 0005 | Tiered write-time consolidation |
v0.2.0 (Eddystone): tiered write-time consolidation to flatten long-range (30d / 90d) query latency.
Contributions are welcome. See CONTRIBUTING.md for build, style, and commit conventions, and the Code of Conduct.
To report a security issue privately, see SECURITY.md (please do not open a public issue for vulnerabilities).
GPL-3.0-or-later. See LICENSE for the full text.

