Tiles now show the public URL and, when set, the internal URL as a second link. Label names ignore case and - versus _. Containers HODA can't fully use are listed above the tiles with the reason: labels without enable, a missing url, or an unknown label name with a suggestion (category -> group). To find labels without enable, discovery reads all containers. A "Copy labels" button copies every label as a commented template, and falls back to the copy command where the clipboard API is missing (plain http on the LAN). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HODA
HODA (HOmelab DAshboard) shows every self-hosted app as a tile with its icon, name and link. Apps opt in with Docker labels in their own compose file, so adding an app never means editing the dashboard. It runs at apps.mariosem.es behind Pangolin, which handles login; HODA itself has none.
One image, two roles: the primary serves the page, and an agent on each other Docker host reports that host's apps to it. Each instance reads only its own Docker, read-only, through a socket proxy.
Add an app
Put labels on the service in its compose file, then docker compose up -d:
services:
gitea:
image: gitea/gitea
labels:
sema.dashboard.enable: "true"
sema.dashboard.name: Gitea
sema.dashboard.url: https://git.mariosem.es
sema.dashboard.group: Dev
sema.dashboard.description: Git hosting and CI
| Label | Required | Effect |
|---|---|---|
sema.dashboard.enable |
Yes | true opts the container in |
sema.dashboard.url |
Yes | Link the tile opens (http or https) |
sema.dashboard.name |
No | Tile title; defaults to the compose service name |
sema.dashboard.icon |
No | A dashboard-icons name (jellyfin) or an image URL |
sema.dashboard.internal_url |
No | LAN address, shown on the tile as a second link; HODA also fetches the favicon from it |
sema.dashboard.description |
No | One line under the name |
sema.dashboard.group |
No | Section heading; apps without one go under "Apps", last |
sema.dashboard.order |
No | Position inside the group; ties sort by name |
Label names ignore case and - versus _, so sema.dashboard.Group and sema.dashboard.internal-url
work too. The Copy labels button at the top of the dashboard copies this whole set as a commented
template.
Anything HODA can't use is listed in a label problems panel above the tiles: a container with
sema.dashboard labels but no enable: "true", a missing or invalid url, or an unknown label name
(with a suggestion, such as category → group). Containers without a url aren't shown until it's fixed.
Icons, first match wins: the icon label, the dashboard-icons set by app name, then by image name
(generic images such as nginx or node are skipped), the app's own favicon, and finally the app's
first letter. Icons are cached in /data/icons and refreshed daily.
Deploy
On the main host (docker-compose.yml):
cp .env.example .env
docker compose pull && docker compose up -d
HODA then listens on 10.20.30.10:10040 (HODA_BIND_IP, HODA_PORT). In Pangolin, point the
apps.mariosem.es resource at http://10.20.30.10:10040 and turn SSO on. The stack brings its own
read-only socket proxy.
A second Docker host runs the agent (docker-compose.agent.yml), which publishes port 8081 on that host's LAN IP only:
cp .env.agent.example .env # set HODA_PEER_TOKEN and HODA_AGENT_BIND_IP
docker compose -f docker-compose.agent.yml pull && docker compose -f docker-compose.agent.yml up -d
Then set HODA_AGENT_URL (for example http://10.20.30.11:8081) and the same HODA_PEER_TOKEN
(openssl rand -hex 32) in the main host's .env, and run docker compose up -d there again.
Configuration
All environment variables are optional.
| Variable | Default | Purpose |
|---|---|---|
MODE |
primary |
agent serves only /api/apps and /icons to the primary, with no page |
PEERS |
Comma-separated agent URLs the primary pulls from | |
PEER_TOKEN |
Shared token between primary and agents; required with PEERS or MODE=agent |
|
PEER_INTERVAL |
5s |
How often the primary pulls each agent |
NODE_NAME |
container hostname | Host name in tile tooltips and search |
DOCKER_HOST |
unix:///var/run/docker.sock |
Docker API; point it at the socket proxy |
LABEL_PREFIX |
sema.dashboard |
Label namespace |
DASHBOARD_TITLE |
HODA |
Page header and browser tab title |
PORT |
8080 |
HTTP port |
LINK_TARGET |
_blank |
_self opens apps in the same tab |
RESYNC_INTERVAL |
60s |
Full container re-list, on top of Docker events |
ICON_REFRESH |
24h |
How often cached icons are fetched again |
ICON_CDN |
jsDelivr's dashboard-icons | Icon set base URL; empty turns the icon set off |
DATA_DIR |
/data |
Where the icon cache lives |
LOG_LEVEL |
info |
debug shows every icon lookup |
Endpoints
| Path | Primary | Agent |
|---|---|---|
/ |
The dashboard | A one-line notice |
/api/apps |
All hosts' apps as JSON, with an ETag | This host's apps; needs Authorization: Bearer <PEER_TOKEN> |
/icons/<id> |
Cached icon, or fetched from the agent that owns it | This host's icons; needs the token |
/healthz |
ok |
ok, no token |
Development
HODA is Go with only the standard library; the page is plain HTML, CSS and JavaScript embedded in the binary. Go doesn't need to be installed: everything runs in Docker.
docker build --target check . # gofmt, go vet, go test
docker build -t hoda . # the production image
Gitea Actions (.gitea/workflows/ci.yml) runs the checks on every push and pull
request. Pushes to main publish git.mariosem.es/msemes/hoda:latest and :sha-<short>; a v1.2.3 tag
publishes :1.2.3. Images are scanned with Trivy, and a CRITICAL finding blocks the push.