# 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.
| `--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.