Documentation fixes from the final branch review, plus small curl/jq
hardening in the gitea-action script:
- tag was documented as cosmetic ("for logging") but is load-bearing in
swarm mode: handlers.go folds it into the requested image, compose
discovery strips it via NormaliseImage, but SwarmExecutor assigns it
directly to ContainerSpec.Image. Omitting it deploys :latest, silently
diverging from what CI just built. Fixed in action.yml, gitea-action's
README, and added to CLAUDE.md's Gotchas since it's invisible from
either mode's code alone.
- gitea-action/README.md's opening line and root README.md's intro/trigger
flow described compose-only behavior even though both docs' bodies now
cover swarm mode too.
- README.md's defense-in-depth section described a two-factor gate; compose
mode is actually three factors (token, label, STACKS_ROOT prefix), and
swarm mode is genuinely two (no local compose file to path-check against).
- action.yml: curl now has --connect-timeout 10 --max-time 900 so a host
that accepts TCP but never answers can't block the fan-out loop forever;
the jq payload build now fails loudly instead of silently sending an
empty payload to every endpoint.
- CLAUDE.md References section now lists this branch's spec and plan.
Claude-Session: https://claude.ai/code/session_01S3aqJ4tvaPezQhsGNCybut
package-updater
Webhook-driven Docker service updater — Compose stacks or Swarm services. Fills the gap between Watchtower (polling, no CI integration) and full GitOps (Argo CD, Flux) for self-hosted environments, one instance per server.
Trigger flow:
- Gitea workflow builds and pushes a new image to your registry.
- Workflow calls
POST /updateon this service with the image name. - Service finds the matching container(s) or Swarm service(s) via Docker labels.
- Runs
docker compose pull+up -d, ordocker service update, depending onMODE.
See design spec and implementation plan for full design and rationale.
How it finds the right stack
The service queries the Docker socket and reads the labels Compose itself attaches to every container:
com.docker.compose.projectcom.docker.compose.servicecom.docker.compose.project.working_dircom.docker.compose.project.config_files
A container is eligible for update only if it has both:
- An image name matching the request (tag-agnostic), AND
- The opt-in label
se.shcizo.auto-update=true.
Defense in depth, compose mode: a valid bearer token AND the opt-in label AND a working
directory inside STACKS_ROOT must all hold before a container is touched. A stack outside
STACKS_ROOT comes back as refused rather than being updated.
Swarm mode's gate is the token and the opt-in label only — a Swarm service has no local compose file to anchor a path check against.
Swarm mode
Set MODE=swarm to update Docker Swarm services instead of Compose stacks. The
service runs docker service update --image via the Docker API instead of
shelling out to docker compose.
Two things to get right when running in Swarm mode:
- The opt-in label goes on the service, not the container/task. Swarm mode
reads
Service.Spec.Labels, so add it withdocker service update --label-add se.shcizo.auto-update=true <service>or set it underdeploy.labelsin the stack file — a plainlabels:entry on the service (container-level) is not visible to Swarm mode's discovery. - The updater must talk to a Swarm manager.
docker service updaterequires manager API access, so either pointDOCKER_HOSTat a manager node or schedule the updater container itself on a manager with the socket mounted. This is a deployment concern the service cannot detect or work around.
Deploying across multiple servers
An instance can only reach the Docker daemon it is configured against. It cannot update stacks on other machines. The deployment model follows from that:
One instance per server. Each server runs its own package-updater against its
own local Docker socket, with MODE set to whatever that machine runs:
| Server | MODE |
Updates |
|---|---|---|
| Swarm manager node | swarm |
All Swarm services in the cluster |
| Standalone Compose host | compose |
Compose stacks on that host |
CI fans out. The Gitea composite action takes a list of
endpoints and posts /update to every instance, so one workflow run reaches the
whole fleet. Each instance answers for its own machine; there is no aggregated
cross-server response and no instance coordinates any other.
This is why MODE is exclusive rather than a mode that handles both at once: an
instance that could do both would still only reach one daemon, so the extra
generality buys nothing.
Quick start
- Build and push the image (e.g. via your own CI).
- Copy
docker-compose.example.ymlto/home/shcizo/self-hosted/package-updater/docker-compose.yml. - Create
.envnext to it:UPDATER_API_KEY=$(openssl rand -hex 32). - Point your reverse proxy (NPM/Traefik/Caddy) at
package-updater:8080. NPM should handle TLS. docker compose up -d.- Add the opt-in label
se.shcizo.auto-update: "true"to each service you want auto-updated. - Use the Gitea composite action in your repos to call
/updateafter a build.
Configuration
All via environment variables.
| Variable | Required | Default | Purpose |
|---|---|---|---|
UPDATER_API_KEY |
yes | — | Bearer token. Service refuses to start without it. |
STACKS_ROOT |
no | /home/shcizo/self-hosted |
Required parent for any stack eligible to update. |
PORT |
no | 8080 |
HTTP listen port. |
LOG_LEVEL |
no | info |
debug / info / warn / error. |
UPDATE_TIMEOUT |
no | 5m |
Per-job timeout (Go duration). |
OPT_IN_LABEL |
no | se.shcizo.auto-update |
Label name to check; value must equal "true". |
MODE |
no | compose |
compose or swarm. Which paradigm this instance's Docker daemon runs. See Deploying across multiple servers. |
Endpoints
| Endpoint | Auth | Purpose |
|---|---|---|
POST /update |
Bearer token | Trigger pull + restart for matching services |
GET /healthz |
none | Liveness + Docker socket reachability |
GET /version |
none | Build info |
GET /metrics |
none | Prometheus exposition |
/healthz, /version, and /metrics are intentionally unauthenticated — they're internal-network only behind the reverse proxy.
Observability
- Logs: JSON to stdout, picked up by Promtail/Alloy → Loki.
- Metrics: Prometheus exposition on
/metrics. Notable:package_updater_update_jobs_total{project,service,status},package_updater_last_update_timestamp{project,service},package_updater_docker_ping_up.
Development
go test ./...
go build ./cmd/server
docker build -t package-updater:dev .
Known v1 gaps
These are tracked in the spec's section 2 and section 15 as deliberate out-of-scope:
- Self-update wiring:
internal/selfupdate.Wrappedexists and is unit-tested but is not wired into the live queue. The HTTP response flush ordering for self-replacement is a future enhancement; for now, expect to manually rerundocker compose up -don the host if pushing a new image ofpackage-updateritself causes a mid-response interruption. - No rollback: Compose's "keep old container if new fails to start" is the only safety net.
- One instance reaches one daemon. An instance never updates another server; fleets run one instance per server with CI fanning out to all of them (see "Deploying across multiple servers"). Swarm mode is the exception in that a single manager-node instance covers the whole cluster.
- No per-repo API keys: a single shared bearer token is used.