A homelab-scale Flask dashboard that tracks Docker services across multiple hosts, runs background URL health checks, and renders a small set of dashboard views (full, tiled, compact). Service entries are populated and kept current by a sidecar process — typically docker-api-notifier — posting to STD's register endpoint, but you can also add entries by hand through the web UI or directly via API.
v0.6.0 removes the legacy
/api/registerendpoint. All registers must use/api/v1/registerwith canonical key names. If you're runningdocker-api-notifier, v0.3.0 or later is required for basic registration; v0.4.0 is the paired release that populates the new network / port / exposure-observation fields.
Group by axis selector
(group / stack / host) and a Show URL-less filter render
above the service grid on all three views. State is URL-driven, so
dashboards stay bookmarkable.widgets/).internalurl / externalurl
without requiring operator-written dockernotifier.std.* labels.
Operator UI edits and explicit labels always win over synthesized
values.| Main Dashboard | Tiled View | Compact View |
|---|---|---|
| Mobile View | Widgets | Settings |
|---|---|---|
STD reads config from environment variables and an optional
/config/settings.yml. Environment variables take priority. If
settings.yml is missing, defaults apply and a settings.example.yml
is dropped into /config for reference.
Default login: admin / changeme123. Change it on first run.
| Setting | Type | ENV | Default | What it does |
|---|---|---|---|---|
api_token |
string | API_TOKEN |
— | Bearer token required by /api/v1/register. |
std_dozzle_url |
string | STD_DOZZLE_URL |
— | Optional link to a Dozzle instance; enables a Tools section in the UI. |
backup_path |
string | BACKUP_PATH |
/config/backups |
Where YAML backups are written. |
backup_days_to_keep |
int | BACKUP_DAYS_TO_KEEP |
7 |
Backup retention. |
url_healthcheck_interval |
int | URL_HEALTHCHECK_INTERVAL |
300 |
Seconds between health check passes. |
widget_background_reload |
int | WIDGET_BACKGROUND_RELOAD |
900 |
Seconds between widget data refreshes. |
widget_value_retention_days |
int | WIDGET_VALUE_RETENTION_DAYS |
30 |
Days of widget_value history to retain. A daily 00:15 background job prunes older rows. |
register_field_ownership |
string | REGISTER_FIELD_OWNERSHIP |
user_wins |
How register calls handle conflicts with UI edits on group_name and sort_priority. user_wins (default) preserves non-NULL UI values on update; notifier_wins always overwrites. Invalid values fall back to user_wins with a startup warning. |
user_session_length |
int | USER_SESSION_LENGTH |
120 |
User session length in minutes. |
flask_secret_key |
string | FLASK_SECRET_KEY |
— | Required for production. Used to sign session cookies. |
settings.ymlapi_token: supersecrettoken
std_dozzle_url: http://dozzle.local
backup_path: /config/backups
backup_days_to_keep: 7
url_healthcheck_interval: 300
widget_background_reload: 900
widget_value_retention_days: 30
register_field_ownership: user_wins
user_session_length: 120
services:
service-tracker-dashboard:
image: crzykidd/service-tracker-dashboard:latest
container_name: service-tracker-dashboard
ports:
- 8815:8815
environment:
- API_TOKEN=supersecrettoken
- STD_DOZZLE_URL=http://dozzle.local
- FLASK_DEBUG=0
- FLASK_SECRET_KEY=changeme-in-prod
volumes:
- ./config:/config
restart: unless-stopped
Schema changes are tracked with Alembic.
Migrations live in alembic/versions/ and alembic upgrade head runs
automatically on container start (see entrypoint.sh).
To create a new migration after changing models, exec into a running container:
docker compose exec <service-name> \
alembic revision --autogenerate -m "describe the change"
The new revision file lands under alembic/versions/. Review it before
committing — autogenerate doesn't catch every kind of change
(constraint renames, default-only changes, SQLite-specific quirks).
To check whether models and the live database have diverged without writing a migration:
docker compose exec <service-name> alembic check
/api/v1/register is the only register endpoint. The legacy
/api/register shim that bridged v0.4.x producers through v0.5.0
was removed in v0.6.0; producers must run docker-api-notifier
v0.3.0+ or send canonical-key payloads directly.
POST /api/v1/register
Authorization: Bearer <API_TOKEN>
Content-Type: application/json
{
"host": "docker01",
"container_name": "nginx",
"container_id": "abc123...",
"image_name": "ghcr.io/user/nginx:latest",
"image_icon": "nginx.svg",
"docker_status": "running",
"stack_name": "frontend",
"started_at": "2026-05-10T12:34:56Z",
"timestamp": "2026-05-10T12:35:00Z",
"internalurl": "http://nginx:80",
"externalurl": "https://my.domain.com",
"internal_health_check_enabled": true,
"external_health_check_enabled": true,
"group_name": "web",
"sort_priority": 1,
"networks": [
{"name": "proxy", "aliases": ["frontend"]},
{"name": "ammoledger_default", "aliases": ["frontend"]}
],
"exposed_ports": ["5173/tcp"],
"published_ports": [
{"container_port": 5173, "protocol": "tcp",
"host_ip": "0.0.0.0", "host_port": 8080}
],
"exposure_observations": [
{"layer": "traefik", "hostname": "nginx.internal.example",
"tls": true, "path_prefix": null, "auth": null, "details": null},
{"layer": "dockflare", "hostname": "nginx.example.com",
"tls": true, "path_prefix": null,
"auth": "cloudflare_access:authenticate", "details": null}
]
}
host — Docker host name. Composite key with container_name.container_name — name of the container.Everything else is optional. STD applies sensible defaults for any field that's missing.
/api/v1/register is strict — unknown keys are rejected with a
400 and the list of offending keys in the response body. Nested
structures (networks, published_ports) are also validated via
pydantic; malformed entries are rejected at the schema boundary.
group_name and sort_priority can be edited in the web UI. When a
notifier register arrives for a row whose UI value differs, the
register_field_ownership setting decides who wins:
user_wins (default) — UI edits stick. The notifier may
populate these fields on a new row or one where they're still
NULL, but it won't overwrite a value the operator has set.notifier_wins — every register call overwrites everything,
including UI edits. Choose this if your container labels are the
source of truth.Regardless of mode, STD records what the notifier sent in
notifier_reported_group_name and notifier_reported_sort_priority
columns. These columns aren't surfaced in the UI yet — they support
a planned overridden-labels export.
The three optional networks / exposed_ports / published_ports
fields are pure observation — STD overwrites them on every register
call with no ownership semantics. They're populated by
docker-api-notifier v0.4.0+; pre-v0.4.0 notifiers continue to work
and just leave the columns NULL.
The read-only "Reported by notifier" block at the bottom of
/edit/<id> surfaces this data. Empty / NULL columns render as "Not
reported." Static entries (created manually via the UI) won't have
this data either, and that's expected.
The optional exposure_observations field carries structured output
from the notifier's YAML interpreters (Traefik, Dockflare, …).
Notifier v0.4.0+ runs these interpreters and emits one entry per
interpreter that recognizes the container. STD writes them into the
new service_exposure table and runs a synthesizer that may
populate internalurl / externalurl automatically.
Operators tell STD what each layer means at /settings →
Exposure tab. Each discovered layer (only layers that have been
observed appear) gets a direction dropdown:
internalurl.externalurl.Per-host overrides let you say "Traefik on the home host is internal, but Traefik on the edge VPS is external" without writing different YAML per host.
The two internalurl / externalurl columns now track who last
wrote them via two new _source columns. Ordering, strongest last:
synthesized — value came from the synthesizer. Re-runs on
every register and on settings save may change it.explicit_label — value came from a
dockernotifier.std.internalurl / .externalurl label on the
container. Synthesizer won't overwrite. A new explicit label
updates it; a UI edit overrides.ui_edit — operator typed a value in the web UI. Nothing
overrides until another UI edit. Clearing the field resets the
source to NULL so synthesis can resume.The edit page (/edit/<id>) shows a small badge next to each URL
indicating the current source.
exposure_observations absent or null — "no update."
Existing exposure rows for the service are preserved. This is the
state for pre-v0.4.0 notifiers and any producer that doesn't emit
interpreter output.exposure_observations: [] — "this container has no interpreter
matches." All existing exposure rows for the service are
cleared. The synthesizer may then clear any URL whose source is
synthesized.exposure_observations: [ ... ] — wholesale replacement. All
prior rows for this service are dropped, the new rows are
inserted, and the $ claude mcp add service-tracker-dashboard \
-- python -m otcore.mcp_server <graph>