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.
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 — local socket or remote socket proxies.
Run
# then open http://127.0.0.1:8080
# expose it on the network (reachable from other hosts):
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:
# then open http://127.0.0.1:8080
Or directly:
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) |
--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 |
--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 — or a remote one — point --config / DOCKDOE_CONFIG
at a config.toml whose [[host]] entries each describe one Docker host:
# Global options (same names as the env vars, minus the DOCKDOE_ prefix) may go
# here too; anything omitted falls back to the flags/environment.
= "0.0.0.0:8080"
[[]]
= "local" # display name + URL slug, must be unique
= "unix:///var/run/docker.sock" # the local socket
[[]]
= "nas"
= "tcp://nas.lan:2375" # a linuxserver/tecnativa socket proxy
= "nas.lan" # where this host's published ports are reachable
[[]]
= "prod"
= "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
Each host gets its own dashboard under /host/<name>; / lists them (and
redirects straight through when there's only one). Per-host keys:
docker— the endpoint:unix:///path(local socket),tcp://host:portorhttp://host:port(a plain socket proxy, e.g.linuxserver/socket-proxy, best kept on a trusted network or behind Tailscale), orhttps://host:port(TLS — verified against the built-in roots, plustls_ca, ortls_insecure)public_host— the host the published-port pills link to (see Port links); defaults to the endpoint's host fortcp/httpstls_ca— a PEM CA certificate to trust for anhttpsendpointtls_insecure— skip TLS verification for anhttpsendpoint (handy for a self-signed reverse proxy; prefertls_cawhen you can)
Remote hosts have two limits: the compose.yml tab only works for a host whose files are on the machine DockDoe runs on, and a proxy that denies actions or exec (returns 403) makes that host read-only — its action buttons and terminal are disabled automatically.
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.
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";
}
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 directhost:portlink 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.
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;
maxis kept for the worst case. Trends have their own, longer retention and store the container name and stack alongside the id, so history survives adocker 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.