Captures design intent, gotchas, and references that aren't obvious from code alone — single-worker queue rationale, defense-in-depth security model, the not-yet-wired selfupdate package, and pointers to the design spec and implementation plan in docs/superpowers/.
package-updater
Webhook-driven Docker Compose service updater. Fills the gap between Watchtower (polling, no CI integration) and full GitOps (Argo CD, Flux) for a self-hosted, single-host environment.
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 Compose-managed container(s) on the host via Docker labels.
- Runs
docker compose pull+up -dfor the relevant service(s).
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: a valid bearer token AND the opt-in label must both be present before any container is touched.
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". |
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.
- Single host only.
- No per-repo API keys: a single shared bearer token is used.