dockdoe 0.12.1

A single-binary Docker host monitor with an embedded web UI
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
# 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]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](#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](#multiple-hosts) — preferably by running one DockDoe per host and
federating them into a hub, or via remote socket proxies.

## Run

```sh
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](#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`](compose.yml):

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

Or directly:

```sh
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](#authentication)). Then make one instance the hub by
listing the others as `dockdoe` hosts:

```toml
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](#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](#notifications) 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`](https://docs.linuxserver.io/images/docker-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:

```toml
[[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]#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-scale`**`log` (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-scale`**`log` (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](#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:

```nginx
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](#multiple-hosts) 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](#multiple-hosts) — 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](https://github.com/caronc/apprise-api) 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](#multiple-hosts) 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 *recreated* — `docker 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](LICENSE) for the
full text.

Copyright © 2026 Stefan Schönberger.