dockdoe 0.12.1

A single-binary Docker host monitor with an embedded web UI
dockdoe-0.12.1 is not a library.

DockDoe

[!WARNING] Early version (0.x). DockDoe is young and under active development. Expect rough edges, bugs, and breaking changes between releases (see CHANGELOG.md). It is not yet battle-tested — use it at your own risk, and don't rely on it as your only monitoring just yet. Feedback and bug reports are very welcome.

A single-binary Docker host monitor with an embedded web UI. Shows the vital metrics of your containers — state, CPU, memory, network and disk I/O — grouped by compose stack. The dashboard updates live (no full reload): HTMX swaps server-rendered fragments over SSE, and uPlot draws live charts seeded from history.

Two radial overview discs at the top of the dashboard show the whole fleet at a glance — one sector per container, with a phosphor-style trail that makes runaway CPU or a memory leak stand out before you read a single row.

Drill into a container (live CPU/memory charts, facts, logs) or a whole stack (aggregate charts, the compose.yml, start/stop/restart-all), and start, stop or restart containers right from the UI. Point it at one Docker host or several — preferably by running one DockDoe per host and federating them into a hub, or via remote socket proxies.

Run

cargo run
# then open http://127.0.0.1:8080

# expose it on the network (reachable from other hosts):
dockdoe --bind 0.0.0.0:8080

Requires access to the Docker socket (/var/run/docker.sock, or DOCKER_HOST). To watch several hosts, or a remote one, see Multiple hosts.

With Docker

A prebuilt image is published to GHCR (ghcr.io/sniner/dockdoe), multi-arch for linux/amd64 and linux/arm64 — so it runs on a 64-bit Raspberry Pi (3/4/5, Zero 2) just as well as on a regular server; Docker picks the right variant automatically. The simplest way to run it is the example compose.yml:

docker compose up -d
# then open http://127.0.0.1:8080

Or directly:

docker run -d --name dockdoe \
  -p 127.0.0.1:8080:8080 \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v dockdoe-data:/data \
  ghcr.io/sniner/dockdoe:latest

Mounting the Docker socket grants full control of the daemon (effectively root on the host), which DockDoe needs for the start/stop/restart actions.

To show a stack's compose.yml in the UI, DockDoe reads the file from the absolute path the daemon records (the com.docker.compose.project.config_files label, fetched over the socket). That path is a host path, so mount the directory holding your compose projects at the same path inside the container (read-only), e.g. -v /opt/stacks:/opt/stacks:ro. Without it the compose tab just reports that the file can't be read; everything else works.

Configuration

Run dockdoe --help for the full list. Every option is a command-line flag and also reads from an environment variable; the flag wins when both are set.

Flag Env var Default Meaning
--config DOCKDOE_CONFIG (unset) Path to a multi-host config.toml, see below
--bind DOCKDOE_BIND 127.0.0.1:8080 Web UI bind address (0.0.0.0:8080 to expose)
--interval-secs DOCKDOE_INTERVAL_SECS 3 Seconds between metric samples
--db-path DOCKDOE_DB_PATH dockdoe.sqlite SQLite database file
--raw-retention-secs DOCKDOE_RAW_RETENTION_SECS 3600 How long raw samples are kept ("point A")
--trend-bucket-secs DOCKDOE_TREND_BUCKET_SECS 60 Trend rollup window (min/max/median per bucket)
--trend-retention-secs DOCKDOE_TREND_RETENTION_SECS 2592000 (30 d) How long trend rollups are kept
--prune-interval-secs DOCKDOE_PRUNE_INTERVAL_SECS 3600 (1 h) How often retention pruning runs
--allowed-hosts DOCKDOE_ALLOWED_HOSTS (unset) Host-header allowlist, see below
--auth-user DOCKDOE_AUTH_USER (unset) Web UI login username, see below
--auth-password DOCKDOE_AUTH_PASSWORD (unset) Web UI login password, see below
--cookie-secure DOCKDOE_COOKIE_SECURE false Mark the session cookie Secure (HTTPS only)
--api-token DOCKDOE_API_TOKEN (unset) Bearer token for API clients, see below
--port-host DOCKDOE_PORT_HOST (unset) Host the port pills link to, see below
--apprise-url DOCKDOE_APPRISE_URL (unset) Apprise endpoint for notifications, see below
--notify-delay-secs DOCKDOE_NOTIFY_DELAY 30 Seconds a state must persist before notifying
--overview-cpu-scale DOCKDOE_OVERVIEW_CPU_SCALE log Overview CPU disc scale, see below
--overview-mem-scale DOCKDOE_OVERVIEW_MEM_SCALE log Overview memory disc scale, see below
--overview-mem-cap DOCKDOE_OVERVIEW_MEM_CAP 64G Memory value at the overview disc's rim
--log DOCKDOE_LOG info Tracing filter (e.g. dockdoe=debug)

Multiple hosts

Without a config file DockDoe monitors a single local host (the socket above). To watch several hosts, point --config / DOCKDOE_CONFIG at a config.toml whose [[host]] entries each describe one Docker host. Each host gets its own dashboard under /host/<name>; / lists them (and redirects straight through when there's only one). Global options (same names as the env vars, minus the DOCKDOE_ prefix) may go in the file too; anything omitted falls back to the flags/environment.

There are two ways to reach a remote host, and they mix freely in one config: federation — run DockDoe on the host itself and aggregate it here (recommended) — or a Docker socket proxy.

Federation: one DockDoe per host (recommended)

Run a DockDoe on every Docker host — each with its local socket, its own database and its own web UI — and give each an api_token (see Authentication). Then make one instance the hub by listing the others as dockdoe hosts:

bind = "0.0.0.0:8080"

[[host]]
name   = "local"                       # display name + URL slug, must be unique
docker = "unix:///var/run/docker.sock" # the hub's own local socket

[[host]]
name    = "nas"
dockdoe = "https://nas.example:8080"   # the node's DockDoe
token   = ""                          # the node's api_token
# node_host = "local"                  # which of the node's hosts to mirror;
#                                      # only needed when the node has several
# interval_secs = 10                   # reconnect/fallback-poll pacing (default 10)
# tls_ca / tls_insecure                # as for https docker endpoints

The hub subscribes to the node's snapshot event stream — one hub-initiated, long-lived connection over which the node pushes every sample as it collects it — and serves the host like any local one: dashboard, overview discs, live updates (as live as on the node itself), logs, the compose tab, start/stop/restart and the container terminal (bridged through the hub) all work. Chart history comes straight from the node's database, so it spans the node's full retention — with no gaps from hub downtime. A node too old for the stream (0.10/0.11) is polled every interval_secs instead.

Why this is the recommended way:

  • The Docker socket never crosses the network. All that's exposed is DockDoe's own small, token-authenticated HTTP API — not the Docker API, which is effectively root on the host.
  • History is decentralised. Every node keeps collecting into its own database whether the hub is running or not; the hub stores nothing for it, so there is nothing to sync, back-fill or lose.
  • Every host stays usable on its own. Each node is a full DockDoe with its own UI — the hub going down takes nothing else with it.
  • The compose tab just works — the node reads the file from its own filesystem, no mounts needed on the hub.

The tradeoffs, honestly: you run and update one DockDoe per host instead of one proxy container, and each node holds the full Docker socket locally — a strictly filtered, read-only socket proxy exposes fewer capabilities (though it exposes them over the network). Notifications for a federated host come from the node itself — whoever collects, notifies — so configure Apprise on the node, not on the hub.

Nodes need DockDoe ≥ 0.10 (the hub side needs ≥ 0.11). The API between them only ever grows, so hub and nodes don't have to be updated in lockstep — but keep them reasonably close; the hub logs a warning on version skew. Plain http:// endpoints work too, but the token then travels readable on the wire: fine on a trusted LAN or over Tailscale, not elsewhere.

Docker socket proxy

A socket proxy (e.g. linuxserver/socket-proxy) is a small container that republishes the host's /var/run/docker.sock over TCP, usually filtered to a subset of the Docker API. DockDoe then talks to the Docker daemon through it:

[[host]]
name          = "nas"
docker        = "tcp://nas.lan:2375"   # a linuxserver/tecnativa socket proxy
public_host   = "nas.lan"              # where this host's published ports are reachable
# interval_secs = 10                   # sample interval; remote hosts default to 10s

[[host]]
name   = "prod"
docker = "https://dockerproxy.example:2376"  # TLS-fronted proxy
# tls_ca       = "/certs/ca.pem"        # trust a private CA (self-signed proxy)
# tls_insecure = true                   # or skip verification entirely

Pick this when you'd rather not run DockDoe on the monitored host: the proxy is a single generic container, and it can be locked down hard — one that denies writes (e.g. POST=0) makes the host read-only in DockDoe, whose action buttons and terminal disable automatically the moment the proxy returns a 403.

The tradeoffs: the Docker API travels the network — root-equivalent even when filtered, so keep it on a trusted network, behind TLS or Tailscale. All data for the host is collected and stored by the hub, so hub downtime leaves gaps in its history. And the compose.yml tab only works if the host's compose files are mounted on the hub's machine at the same path.

Per-host keys

  • docker — a Docker endpoint: unix:///path (local socket), tcp://host:port or http://host:port (a plain socket proxy), or https://host:port (TLS — verified against the built-in roots, plus tls_ca, or tls_insecure)
  • dockdoe — alternatively, a federated node's base URL (http:// or https://); exactly one of docker/dockdoe per host
  • token — the bearer token for a dockdoe node (its api_token)
  • node_host — which of the node's hosts this entry mirrors; defaults to its only host, mandatory when the node monitors several
  • public_host — the host the published-port pills link to (see Port links); defaults to the endpoint's host for tcp/https/dockdoe
  • interval_secs — seconds between samples for this host, overriding the global --interval-secs for it alone. Unset, a local (unix) endpoint uses the global interval (default 3) while a remote one (proxy or node) defaults to 10 — polling over the network is costlier than reading the local socket. For a dockdoe node the live data streams anyway; this only paces reconnects (and the polling fallback for pre-0.12 nodes)
  • tls_ca — a PEM CA certificate to trust for an https endpoint
  • tls_insecure — skip TLS verification for an https endpoint (handy for a self-signed reverse proxy; prefer tls_ca when you can)

Overview discs

The dashboard opens with two radial charts — one for CPU, one for memory. Every container owns a fixed sector (ordered like the table: stacks alphabetically, standalone last), the radius shows its current value, and the last ~24 samples fade out like a phosphor trace, so a climbing container drags a visible trail. Hover names the container, a click opens its detail page.

Both scales can be tuned:

  • --overview-cpu-scalelog (default), sqrt or linear. The rim is 100 % of one core. The log scale spreads the low end so even light activity shows; linear keeps an idle fleet visually quiet — an empty disc means all quiet — at the cost of barely registering small loads.
  • --overview-mem-scalelog (default), sqrt or linear. On the log scale equal relative growth moves a sector equally far, so a leak looks the same whether it doubles 100 MiB or 4 GiB.
  • --overview-mem-cap — the memory value at the rim (default 64G). Accepts binary sizes like 512M, 64G, 1T, or plain bytes.

Request hardening

The start/stop/restart endpoints only accept requests carrying the HX-Request header that htmx sends with every request. A cross-site HTML form can't set custom headers, so drive-by POSTs from other websites are rejected.

That check can't help against DNS rebinding, where the attacker's page ends up same-origin. For that, set --allowed-hosts (comma-separated, e.g. dockhost.lan): requests whose Host header matches neither the list nor a localhost form (localhost, 127.0.0.1, ::1) are rejected. Recommended whenever the UI is exposed beyond localhost.

Authentication

Set both --auth-user / DOCKDOE_AUTH_USER and --auth-password / DOCKDOE_AUTH_PASSWORD to put the web UI behind a login. There is one credential pair — no user database, no sign-up. Leave both unset to keep the UI open; setting only one is treated as a misconfiguration and refuses to start.

Logging in sets a session cookie, so the credentials are sent only once (not on every request like HTTP Basic Auth), and you can log out again from the header. The cookie is a signed token — nothing is stored server-side — valid for 30 days, and it survives restarts and upgrades, so you stay logged in. The signature uses a random secret generated once and kept in the database; delete the database (or its meta row) to invalidate all sessions.

Behind a TLS-terminating reverse proxy, also set --cookie-secure / DOCKDOE_COOKIE_SECURE=true so the cookie is only ever sent over HTTPS. Leave it off for plain-http access on a trusted LAN, where it would otherwise stop the cookie from being sent at all.

Machine clients — scripts, or another DockDoe instance acting as a central hub — can't use the login form. Set --api-token / DOCKDOE_API_TOKEN to a shared secret and such clients authenticate each request with an Authorization: Bearer <token> header instead, getting the same access as a logged-in session. Token requests are also exempt from the htmx CSRF check on the action endpoints, which only makes sense for cookies a browser attaches on its own. Unset (the default), token access is disabled and Authorization headers are ignored as before.

Terminal

Each running container's detail page has a Terminal panel: click Connect to open an interactive shell (docker exec) inside the container. The command defaults to /bin/sh; change it to e.g. bash for images that ship it. The button toggles fullscreen. The session opens only on demand and ends when you disconnect or leave the page. Put the UI behind authentication before exposing it — this is a real shell into your containers.

The terminal uses a WebSocket. Direct access needs nothing extra, but behind a reverse proxy you must allow the WebSocket upgrade for it to work. For example, in Nginx Proxy Manager tick Websockets Support on the proxy host; in a plain nginx config, forward the upgrade headers:

location / {
    proxy_pass http://dockdoe:8080;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
}

On a federated host the terminal works too: the hub dials the node's exec WebSocket itself — authenticated with the host's token, which a browser couldn't attach — and relays the frames, so your browser only ever talks to the hub.

Port links

Each published container port shows as a pill; the published ones are links that open http://<host>:<port> in a new tab. By default <host> is whatever host you're browsing DockDoe at — exactly right when you browse it directly on the Docker host.

Behind a reverse proxy that breaks down: the browsing host is the proxy (on :443), where the container ports aren't open, so the links would point nowhere. Set the host the ports are really reachable at — --port-host / DOCKDOE_PORT_HOST for the single local host, or a host's public_host in the config — to:

  • an IP or hostname (e.g. 192.168.1.50) — the links point there instead, so they still work when the Docker host is reachable directly even though the UI is served through a proxy.
  • off — render the ports as plain pills with no links, for setups reachable only through the proxy where no direct host:port link would work from your browser.

For a remote host, public_host defaults to the endpoint URL's host, so a tcp://nas.lan:2375 host already links its ports to nas.lan without setting it. Leave it unset for the direct-access local case.

Notifications

Set --apprise-url / DOCKDOE_APPRISE_URL to an Apprise endpoint and DockDoe sends a message whenever a container's state settles into a change: down (failure), unhealthy (warning), or recovered (success). DockDoe only ever POSTs {title, body, type} to that one URL — which services it fans out to (Discord, e-mail, Telegram, …) is configured in Apprise, so no per-service setup or secrets live in DockDoe. Leave it unset to disable notifications entirely.

Point it at a stateful config key, e.g. https://apprise.example/notify/<key>; the target services then live under that key in Apprise.

To avoid alert storms from flapping, a new state must persist for --notify-delay-secs / DOCKDOE_NOTIFY_DELAY (default 30) before it is reported — a container that restarts and recovers within that window stays quiet. The state seen at startup is adopted as the baseline, so DockDoe doesn't fire a burst when it boots.

Whoever collects, notifies: a federated host's containers are watched by its node, so configure Apprise there — the hub only notifies for the hosts it polls directly (local socket or socket proxy), and doesn't double up on the node's alerts.

One known gap: state is tracked per container id, and the first sighting of an id is adopted silently (that's what keeps startups and deployments quiet). A container that is recreateddocker compose up after a down, or an image update — comes back as a new id, so if the new instance is down from the start, no alert fires: to DockDoe it is a first sighting, not a change. A container that merely restarts (same id) is covered.

Data model

Two layers, mirroring a Zabbix-style approach:

  • Raw samples — every collected value, kept until "point A" (DOCKDOE_RAW_RETENTION_SECS), then pruned.
  • Trends — min/max/median rollups per time bucket, computed the moment a bucket completes (not lazily as raw data ages out). Median is preferred over mean for robustness against spikes; max is kept for the worst case. Trends have their own, longer retention and store the container name and stack alongside the id, so history survives a docker compose down && up.

How CPU% is computed

The Docker stats API reports raw cumulative CPU counters, not a percentage. DockDoe computes it from the delta between two samples:

cpu% = (cpu_delta / system_delta) * online_cpus * 100

using its own previous sample (not the API's zeroed precpu_stats on a one-shot read), so the delta spans exactly one collection interval. Verified against docker stats: a CPU-bound container reads 99.9% vs Docker's 99.96%.

License

DockDoe is free software, licensed under the GNU General Public License, version 3 or (at your option) any later version. See LICENSE for the full text.

Copyright © 2026 Stefan Schönberger.