A single Go container that detects when :latest Docker images have been re-pushed for the containers running on Centroid's elevator HMI boxes, and gives Centroid field engineers per-container Update and Rollback buttons via a small Svelte web UI on the HMI LAN. Replaces a fragile patched WUD 8.2.2 setup and a heavier Komodo-based alternative with a tool that has rollback built in, ships as one image, and persists everything in a single JSON file alongside the compose stack.
Web UI: http://<hmi>:8080/
As of vX.Y.Z, the binary, compose service, and env-var prefix unify on
docker-update. The watched-container labels stay on the
hmi-update.* namespace for backwards compatibility.
# OLD
services:
hmi-update:
image: ghcr.io/centroid-is/docker-update:latest
container_name: hmi-update
environment:
HMI_UPDATE_STATE_PATH: /state/hmi_update_state.json
HMI_UPDATE_COMPOSE_PATH: /host/docker-compose.yml
# ... etc
volumes:
- /opt/centroid/hmi_update_state.json:/state/hmi_update_state.json
# NEW
services:
docker-update:
image: ghcr.io/centroid-is/docker-update:latest
container_name: docker-update
environment:
DOCKER_UPDATE_STATE_PATH: /state/docker_update_state.json
DOCKER_UPDATE_COMPOSE_PATH: /host/docker-compose.yml
# ... etc
volumes:
- /opt/centroid/docker_update_state.json:/state/docker_update_state.jsonsudo mv /opt/centroid/hmi_update_state.json /opt/centroid/docker_update_state.json
| Old | New |
|---|---|
| HMI_UPDATE_STATE_PATH | DOCKER_UPDATE_STATE_PATH |
| HMI_UPDATE_COMPOSE_PATH | DOCKER_UPDATE_COMPOSE_PATH |
| HMI_UPDATE_CRON | DOCKER_UPDATE_CRON |
| HMI_UPDATE_LOG_LEVEL | DOCKER_UPDATE_LOG_LEVEL |
| HMI_UPDATE_REGISTRY_TIMEOUT_S | DOCKER_UPDATE_REGISTRY_TIMEOUT_S |
| HMI_UPDATE_POLL_CONCURRENCY | DOCKER_UPDATE_POLL_CONCURRENCY |
| HMI_UPDATE_REGISTRY_INSECURE | DOCKER_UPDATE_REGISTRY_INSECURE |
| HMI_UPDATE_DOCKER_HOST | DOCKER_UPDATE_DOCKER_HOST |
| HMI_UPDATE_SELF_SERVICE | DOCKER_UPDATE_SELF_SERVICE |
| HMI_UPDATE_VERIFY_WINDOW_S | DOCKER_UPDATE_VERIFY_WINDOW_S |
| HMI_UPDATE_HEALTHCHECK_WINDOW_S | DOCKER_UPDATE_HEALTHCHECK_WINDOW_S |
The watched-container labels are intentionally kept on the
hmi-update.* prefix for backwards compatibility across the HMI
fleet:
hmi-update.watch=truehmi-update.tag-pattern=<regex>hmi-update.allow-update=falsehmi-update.allow-rollback=falsehmi-update.wait-for-healthy=true
These labels are a stable public contract. Do not edit them when upgrading.
Phase 9 removes the docker CLI bind-mount requirement because
docker-update now drives container recreate via the daemon socket
(socket-only recreate via internal/recreate.Service) instead of
shelling out to docker compose. The old bind-mounts are now unused
and SHOULD be removed from your installed docker-compose.yml — they
serve no purpose post-Phase-9 and clutter the service block.
Edit your installed /opt/centroid/docker-compose.yml (or wherever you
keep it) and delete the two :ro bind-mounts from the docker-update
service block:
volumes:
# DELETE these two lines:
- /usr/bin/docker:/usr/bin/docker:ro
- /usr/libexec/docker/cli-plugins/docker-compose:/usr/libexec/docker/cli-plugins/docker-compose:roAfter saving, run:
docker compose up -d --force-recreate docker-updateonce to apply the change. From this point onward, all future updates
flow through the in-app Update button (including docker-update's
own self-update via the new POST /api/self-update endpoint, which
spawns a one-shot helper container that drives the recreate). This is
the LAST host-shell docker compose command you should need on this
HMI.
See docker-compose.example.yml in the repo for the canonical
post-Phase-9 service block shape.
Drop the docker-update service block into your existing docker-compose.yml and run:
docker compose up -d docker-updateThe full install runbook (with the id -g docker step required for the
distroless nonroot user to reach the docker socket) is the next section.
Tested on Debian 12 with Docker Engine v29+ and the docker-compose-plugin.
The published image lives at ghcr.io/centroid-is/docker-update:latest.
The container runs as the distroless nonroot UID (65532) and needs the host
docker group GID as a supplementary group to read /var/run/docker.sock.
Run id -g docker on the HMI host and note the integer it prints:
id -g docker # prints e.g. 998sudo mkdir -p /opt/centroid
sudo cp docker-compose.example.yml /opt/centroid/docker-compose.yml
sudo touch /opt/centroid/docker_update_state.json
sudo chown 65532:65532 /opt/centroid/docker_update_state.jsonThen edit /opt/centroid/docker-compose.yml and replace the literal
placeholder <docker-gid> in the user: line with the integer from step 1
(NOT a ${HOST_DOCKER_GID} shell variable — compose does not re-resolve
env vars from the operator's shell on systemd restart, so a literal integer
is the only safe form):
user: "65532:998" # replace 998 with the value of `id -g docker` from step 1The state-file chown 65532:65532 is the same Pitfall 9 remediation
documented in PROJECT.md — see
PROJECT.md §Installation prerequisites
for the underlying rationale (do NOT duplicate the chown step elsewhere; this
runbook is the single operator-facing reference).
cd /opt/centroid
docker compose up -d docker-updatecurl -s http://localhost:8080/healthz # → {"status":"ok"}, HTTP 200
xdg-open http://localhost:8080 # table view in the browserThe table is empty until watched containers boot (hmi-update.watch=true
label on the services you want managed). See
PROJECT.md §Container labels reference
for the five labels you can set on watched containers.
docker-update cannot recreate itself via its own API (it is the process being
recreated — it would commit suicide mid-recreate, see PITFALLS.md Pitfall 6
and ACT-09). The documented host-shell upgrade procedure lives in
PROJECT.md §Manual self-upgrade procedure.
docker-update is configured via environment variables in the compose service
block. The minimum production set is the three in docker-compose.example.yml
(DOCKER_UPDATE_CRON, DOCKER_UPDATE_COMPOSE_PATH, DOCKER_UPDATE_STATE_PATH). The
full list (registry timeout, log level, verify window, etc.) lives in
PROJECT.md §Configuration knobs (env vars).
Container labels controlling per-service behaviour (watch / tag-pattern /
allow-update / allow-rollback / wait-for-healthy) are documented in
PROJECT.md §Container labels reference.
The label namespace remains hmi-update.* for backwards compatibility — see
"Upgrading from hmi-update" above for the
rationale and the "Labels — DO NOT rename" callout.
The flutter and weston containers draw the operator's elevator display. Recreating either of them blanks the screen for 5–30 seconds while the new container starts and reaches first paint. The exact duration depends on whether the new image's layers are already extracted locally (faster) or need a cold pull (slower), and on the application's own cold-start time (a Flutter app typically takes 2–10s to draw its first frame on HMI hardware).
Recreating weston is worse than recreating flutter — weston is the Wayland compositor, and tearing it down disconnects every Wayland client (flutter and any others), so all of them restart together.
The web UI will show a "display may flicker" confirmation toast before the recreate fires when the targeted service name contains flutter or weston (case-insensitive substring match). The operator can cancel from the toast — nothing happens until the toast is confirmed.
If the operator confirms and the blackout is unwanted, Rollback returns the container to the previous digest in under 15 seconds, entirely from the local image cache — it works even with no network connection to the registry. Rollback is the safety net for "I clicked Update and got a blackout I didn't want."
If the new image's local cache was accidentally removed (a docker image prune mishap), Force Pull re-pulls the :latest image without recreating the container — it's the recovery path if Update fails to find the local image.
Full failure-mode analysis: .planning/research/PITFALLS.md Pitfall 5.
Backwards-compat note: the label keys below are intentionally namespaced
hmi-update.*even though the binary, image, and service are nowdocker-update. Operators across the Centroid HMI fleet already have these labels on dozens of compose service blocks; renaming would force a coordinated edit on every HMI's docker-compose.yml. Treathmi-update.*as a stable public contract.
| Label | Purpose | Default if absent |
|---|---|---|
hmi-update.watch=true |
Mark a container as watched | Not watched |
hmi-update.tag-pattern=<regex> |
Constrain upstream tag candidacy (e.g. ^latest-pg17$ on timescaledb) |
Any tag matches |
hmi-update.allow-update=false |
Server refuses Update for this container (SAFE-01) | Update allowed |
hmi-update.allow-rollback=false |
Server refuses Rollback for this container | Rollback allowed |
hmi-update.wait-for-healthy=true |
Extend verify-after-recreate to wait for State.Health.Status == "healthy" (60s window) |
15s consecutive-Running window |
See PROJECT.md §Container labels reference for the canonical table.
make # build UI + Go binary into ./bin/docker-update
make test # Go unit tests with -race
make e2e # Playwright e2e against the test compose stack
make image-prod # production-hardened container image (Phase 7 packaging)The full developer pointers (architecture notes, pitfalls, research) live in
.planning/.
- Full requirements + decisions:
.planning/PROJECT.md - Roadmap + phase plans:
.planning/ROADMAP.md - HTTP API:
API.md(Phase 4 —/api/state,/api/containers/{service}/update,/rollback,/force-pull) - Research (pitfalls, registry mechanics, distroless GID, atomic writes):
.planning/research/
MIT — see LICENSE (Phase 8 publish flow lands the file alongside the GHCR
release).