# package-updater — Design Spec **Date:** 2026-05-22 **Status:** Approved (brainstorm complete, ready for implementation planning) **Author:** Samuel Enocsson --- ## 1. Purpose A small HTTP service that receives webhook-style notifications from Gitea Actions (or any CI) and triggers a pull + restart of the corresponding Docker Compose service on the host. It fills the gap between Watchtower (polling, no CI integration) and full GitOps (Argo CD / Flux) for a self-hosted, single-host environment. **Concrete trigger:** Gitea workflow builds and pushes `registry.example.com/myapp:v1.2.3` to the container registry, then calls `POST /update` with the image name. The service finds the matching Compose-managed container(s) on the host, pulls the new image, and restarts the relevant service(s). Watchtower remains in place for third-party images that the user does not build themselves. ## 2. Non-Goals (v1) - Multi-host orchestration (single Docker host only) - Real rollback (relies on Compose's "keep old container if new fails to start") - Persistent state, audit history, or web UI (logs live in Loki) - Per-repo / per-image API keys (single shared bearer token) - Wildcards/regex in image matching - Notifications outside of Gitea (workflow failure is visible there already) - Rate limiting - TLS termination (handled by Nginx Proxy Manager in front of the service) ## 3. Architecture ``` ┌──────────────────┐ POST /update ┌─────────────────────┐ │ Gitea Actions │ ─────────────────────▶│ package-updater │ │ (composite │ Bearer │ (Go service, │ │ action) │ {image, tag} │ container) │ └──────────────────┘ └──────────┬──────────┘ │ docker.sock │ (mounted) ▼ ┌─────────────────────┐ │ Docker daemon │ │ + running containers│ │ + Compose stacks │ └─────────────────────┘ ``` ### 3.1 High-level flow 1. Gitea workflow builds + pushes `registry.example.com/myapp:v1.2.3`. 2. Workflow calls `POST /update` with `{image, tag}` + bearer token. 3. Service validates token (constant-time compare). 4. Service lists Docker containers (running + stopped), filters by: - Image name match (tag-agnostic) - Opt-in label `se.shcizo.auto-update=true` 5. For each match, reads Compose's built-in labels to find `working_dir`, `config_files`, `service`, `project`. 6. Path safety check: refuse jobs whose `working_dir` is not inside `STACKS_ROOT`. 7. Deduplicates `(project, service, config_files)` and enqueues one job per unique tuple. 8. Single FIFO worker executes jobs synchronously: `docker compose -f -p pull ` then `up -d `. 9. Response aggregates per-job results. ### 3.2 Design principles - **Stateless** — no database, no config file beyond environment variables. The Docker daemon is the source of truth. - **Idempotent** — re-running the same update is safe. Zero matches returns `200` with an empty result list. - **Fail-safe** — per-job failures do not stop other jobs. Compose's normal behaviour preserves the old container if the new one fails to start. - **Defense in depth** — two independent gates (bearer token AND opt-in label) must both pass before any container is touched. ## 4. API Contract ### 4.1 `POST /update` **Headers:** ``` Authorization: Bearer Content-Type: application/json ``` **Request body:** ```json { "image": "registry.example.com/myapp", "tag": "v1.2.3" } ``` - `image` — **required**. Image reference without tag. Matched against container image (tag-agnostic). - `tag` — **optional**. For logging/audit only. The actual tag pulled is governed by the `image:` line in the Compose file. **Response codes:** | Status | Meaning | |---|---| | `200 OK` | All jobs succeeded (or zero matches) | | `207 Multi-Status` | Mixed success/failure across jobs | | `400 Bad Request` | Missing or invalid `image` field | | `401 Unauthorized` | Missing or invalid token | | `500 Internal Server Error` | Docker daemon unreachable, or all jobs failed | **Success response body:** ```json { "request_id": "0d8c4b9e-7a1f-4d2c-8b1a-3c5e6f7a8b9d", "image": "registry.example.com/myapp", "tag": "v1.2.3", "matched": 2, "results": [ { "project": "myapp-prod", "service": "web", "compose_file": "/home/shcizo/self-hosted/myapp-prod/docker-compose.yml", "status": "updated", "duration_ms": 4231 }, { "project": "myapp-staging", "service": "web", "compose_file": "/home/shcizo/self-hosted/myapp-staging/docker-compose.yml", "status": "failed", "error": "pull: manifest unknown", "duration_ms": 812 } ] } ``` **`status` values:** `updated`, `failed`, `refused` (path outside `STACKS_ROOT`), `timeout`. ### 4.2 `GET /healthz` Returns `200 OK` if the process is alive AND `cli.Ping(ctx)` against the Docker socket succeeds. Used by container healthcheck and external uptime monitoring. ### 4.3 `GET /metrics` Prometheus exposition format. No auth (internal network only). ### 4.4 `GET /version` Returns build info (version, commit, build time). ## 5. Discovery & Matching ### 5.1 Container lookup ```go containers, err := cli.ContainerList(ctx, container.ListOptions{All: true}) ``` `All: true` so we also catch crashed/stopped containers we may want to restart. ### 5.2 Image matching Tag-agnostic exact match after normalisation: ``` input: "registry.example.com/myapp" container: "registry.example.com/myapp:v1.2.2@sha256:abc..." → strip tag and digest → "registry.example.com/myapp" → MATCH ``` Case-sensitive. No wildcards or regex (YAGNI). ### 5.3 Opt-in filter Container must have label `se.shcizo.auto-update=true`. Anything else (`false`, missing, other value) is silently excluded. ### 5.4 Compose label extraction | Label | Used for | |---|---| | `com.docker.compose.project` | `-p ` flag | | `com.docker.compose.project.working_dir` | Working directory for `docker compose` exec | | `com.docker.compose.project.config_files` | `-f ` flag(s) (comma-separated list) | | `com.docker.compose.service` | Which service to pull/restart | If any of these labels are missing (container not started by Compose), the container is excluded with a warning log. This should not happen in practice — the whole tool is Compose-centric — but we don't crash. ### 5.5 Path safety check Before running any `docker compose` command, verify that `working_dir` is inside `STACKS_ROOT` (default: `/home/shcizo/self-hosted`). If not, the job is marked `refused` and logged at warning level. This is a defense-in-depth measure against forged or unexpected Docker labels. ### 5.6 Deduplication Multiple containers may belong to the same `(project, service, config_files)` tuple (e.g. Compose `deploy.replicas > 1`). Collapse to a single job using: ``` dedupKey = project + "|" + service + "|" + sorted(config_files) ``` ### 5.7 Concurrency A single global FIFO queue with one worker. The HTTP handler blocks until all jobs for the request are complete, so the caller receives the result synchronously. The single-worker model gives per-stack mutual exclusion implicitly — no race conditions between concurrent updates against the same stack. ## 6. Self-Update Handling When the service receives an update whose image matches its own running container, it must finish writing the HTTP response before exec'ing `docker compose up -d` against itself (otherwise the response is lost when the container is replaced). **Implementation:** detect self-update at job dispatch time. For self-update jobs, the worker writes the HTTP response, flushes, closes the connection, then waits ~1 second before invoking the `up -d` command. All other jobs execute normally. **Acceptable residual risk:** if a buggy version is pushed that fails to start, the running version dies (Compose can't restart what won't start) and the user must SSH in and run `docker compose up -d` manually with a previous tag. This is the same risk any self-updating system carries; mitigation is a deploy discipline issue, not a code one. ## 7. Security Model ### 7.1 Authentication - Single bearer token read from env var `UPDATER_API_KEY` at startup. Service refuses to start if it is missing or empty (fail-fast). - Token compared with `crypto/subtle.ConstantTimeCompare` to defeat timing attacks. - Token is never logged. ### 7.2 Authorisation A container is eligible for update only if it has both: - An image name matching the request, AND - The opt-in label `se.shcizo.auto-update=true`. Both gates are independent. Compromising either alone does not allow an attacker to trigger an update. ### 7.3 Network exposure The service binds `0.0.0.0:8080` inside the container and is reached via Nginx Proxy Manager which terminates TLS and forwards on the internal Docker network. The service itself does not handle TLS. ### 7.4 Socket access The service mounts `/var/run/docker.sock` and therefore has effective root on the host. This is the same trust model as Watchtower. The opt-in label is the second gate that limits blast radius from API-layer abuse. ## 8. Deployment ### 8.1 Service's own Compose stack Path: `/home/shcizo/self-hosted/package-updater/docker-compose.yml` ```yaml services: package-updater: image: registry.example.com/package-updater:latest container_name: package-updater restart: unless-stopped environment: - UPDATER_API_KEY=${UPDATER_API_KEY} - STACKS_ROOT=/home/shcizo/self-hosted - LOG_LEVEL=info - PORT=8080 - UPDATE_TIMEOUT=5m volumes: - /var/run/docker.sock:/var/run/docker.sock - /home/shcizo/self-hosted:/home/shcizo/self-hosted:ro labels: - "se.shcizo.auto-update=true" networks: - proxy healthcheck: test: ["CMD", "wget", "-q", "-O-", "http://localhost:8080/healthz"] interval: 30s timeout: 5s retries: 3 networks: proxy: external: true ``` `.env` next to it (git-ignored): ``` UPDATER_API_KEY= ``` NPM is configured with a proxy host pointing at `package-updater:8080` with TLS. ### 8.2 Dockerfile Multi-stage, statically-linked Go binary: ```dockerfile FROM golang:1.23-alpine AS build WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/package-updater ./cmd/server FROM alpine:3.20 RUN apk add --no-cache docker-cli docker-cli-compose ca-certificates wget COPY --from=build /out/package-updater /usr/local/bin/ ENTRYPOINT ["/usr/local/bin/package-updater"] ``` `docker-cli` and `docker-cli-compose` are installed because we shell out to `docker compose` for pull/up. Going through the CLI handles dependent services and networks correctly out of the box; calling the Docker API directly would require us to replicate that logic. Expected image size: ~50 MB. ### 8.3 Stacks root convention All Compose stacks eligible for auto-update must live under `STACKS_ROOT` (default `/home/shcizo/self-hosted`). This is required so that the path stored in `com.docker.compose.project.working_dir` (a host path) resolves to the same path inside the service container. The mount uses identical source and target paths to avoid any path translation logic. Read-only mount is sufficient — the service only reads Compose files; it never writes them. ## 9. Gitea Action A reusable composite action lives in a separate repository, e.g. `gitea.example.com/samuel/action-deploy-update`. `action.yml`: ```yaml name: "Deploy via package-updater" description: "Notifies package-updater to pull & restart a service" inputs: endpoint: description: "Full URL to /update (e.g. https://updater.example.com/update)" required: true image: description: "Image reference without tag (e.g. registry.example.com/myapp)" required: true tag: description: "Tag that was just pushed (for logging)" required: false default: "" token: description: "Bearer token for package-updater" required: true runs: using: "composite" steps: - name: Trigger update shell: bash env: TOKEN: ${{ inputs.token }} run: | set -euo pipefail response=$(curl -sS -w "\n%{http_code}" \ -X POST "${{ inputs.endpoint }}" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d "{\"image\":\"${{ inputs.image }}\",\"tag\":\"${{ inputs.tag }}\"}") body=$(echo "$response" | head -n -1) code=$(echo "$response" | tail -n 1) echo "HTTP $code" echo "$body" | jq . if [ "$code" -ge 400 ]; then exit 1 fi ``` Consumer workflow (`.gitea/workflows/deploy.yml`): ```yaml jobs: deploy: runs-on: ubuntu-latest needs: [build-and-push] steps: - uses: gitea.example.com/samuel/action-deploy-update@v1 with: endpoint: https://updater.example.com/update image: registry.example.com/${{ gitea.repository }} tag: ${{ gitea.sha }} token: ${{ secrets.UPDATER_TOKEN }} ``` `UPDATER_TOKEN` is set as an organisation-level secret so all repos share it. ## 10. Error Handling | Phase | Possible failure | Behaviour | |---|---|---| | Auth | Missing/invalid token | `401`, warning log with client IP | | Validation | Missing/invalid `image` | `400`, no action | | Docker daemon | Socket unreachable | `500`, error log | | Discovery | Zero matches (after opt-in filter) | `200` with `matched: 0`, info log | | Path safety | `working_dir` outside `STACKS_ROOT` | Skip job with `status: refused`, warning log. Other jobs continue. | | `docker compose pull` | Manifest unknown, registry auth fail, network timeout | Per-job `status: failed`. No rollback (old container still running). | | `docker compose up -d` | Validation error, port conflict, etc. | Per-job `status: failed`. Compose retains old container if new fails. | | Per-job timeout | Job exceeds `UPDATE_TIMEOUT` (default 5m) | Cancel and mark `status: timeout`. | **Aggregate HTTP status:** - All jobs `updated` → `200` - Mixed → `207` - All jobs `failed`/`refused`/`timeout` → `500` - `matched: 0` → `200` (idempotent: "nothing to do" is not an error) No rollback in v1. If you push a buggy image, Compose keeps the old container if the new one fails to start. Real rollback (re-pin previous tag) would require tracking previous tags per service, which we don't. ## 11. Observability ### 11.1 Logs (Loki) Structured JSON to stdout. Promtail/Alloy already scrapes Docker container logs and forwards to Loki. Example records: ```json {"time":"2026-05-22T14:32:11Z","level":"info","event":"update_request","request_id":"0d8c4b9e","image":"registry.example.com/myapp","tag":"v1.2.3","client_ip":"10.0.0.5"} {"time":"2026-05-22T14:32:11Z","level":"info","event":"matched","request_id":"0d8c4b9e","image":"registry.example.com/myapp","matched":2} {"time":"2026-05-22T14:32:11Z","level":"info","event":"job_start","request_id":"0d8c4b9e","project":"myapp-prod","service":"web"} {"time":"2026-05-22T14:32:15Z","level":"info","event":"job_complete","request_id":"0d8c4b9e","project":"myapp-prod","service":"web","status":"updated","duration_ms":4231} {"time":"2026-05-22T14:32:15Z","level":"error","event":"job_complete","request_id":"0d8c4b9e","project":"myapp-staging","service":"web","status":"failed","error":"pull: manifest unknown","duration_ms":812} {"time":"2026-05-22T14:32:15Z","level":"info","event":"update_response","request_id":"0d8c4b9e","status":207,"matched":2,"updated":1,"failed":1} ``` **Rules:** - Never log the API key - Always log client IP for audit - Every request gets a `request_id` (UUID) that propagates through all related log lines - High-cardinality fields (`image`, `project`, `service`) stay at top level for LogQL filtering ### 11.2 Metrics (Prometheus) Exposed at `/metrics`, no auth (internal network). | Metric | Type | Labels | Purpose | |---|---|---|---| | `package_updater_build_info` | Gauge=1 | `version`, `commit` | Dashboard version display | | `package_updater_http_requests_total` | Counter | `endpoint`, `status_code` | Traffic + error rate | | `package_updater_update_jobs_total` | Counter | `project`, `service`, `status` | Deploy counts per stack, success/fail | | `package_updater_update_duration_seconds` | Histogram | `project`, `service` | Pull+up duration; detect regressions | | `package_updater_queue_depth` | Gauge | — | Sustained backlog indicator | | `package_updater_last_update_timestamp` | Gauge | `project`, `service` | Wall-clock of last successful update; alert on stale stacks | | `package_updater_docker_ping_up` | Gauge=0\|1 | — | Distinguishes "service up" from "socket broken" | Dependency: `github.com/prometheus/client_golang`. **Cardinality note:** `last_update_timestamp` grows with the number of stacks. Fine for <50 stacks. If it ever grows to hundreds, drop the per-service labels or move that metric to a separate exporter. ## 12. Configuration All configuration is via environment variables. | Variable | Required | Default | Description | |---|---|---|---| | `UPDATER_API_KEY` | **yes** | — | Bearer token. Service refuses to start if missing. | | `STACKS_ROOT` | no | `/home/shcizo/self-hosted` | Required parent directory for any Compose stack to be eligible. | | `PORT` | no | `8080` | HTTP listen port. | | `LOG_LEVEL` | no | `info` | `debug`/`info`/`warn`/`error`. | | `UPDATE_TIMEOUT` | no | `5m` | Per-job timeout. Go duration string. | | `OPT_IN_LABEL` | no | `se.shcizo.auto-update` | Label name to check (allows renaming without rebuild). Value must equal `"true"`. | ## 13. Repository Layout (planned) ``` package-updater/ ├── cmd/ │ └── server/ │ └── main.go # entrypoint ├── internal/ │ ├── api/ # HTTP handlers, middleware (auth, request_id, logging) │ ├── discovery/ # Docker container lookup + Compose label parsing │ ├── updater/ # Job queue + worker + docker compose exec │ ├── selfupdate/ # Self-update detection + deferred exec │ ├── config/ # Env var loading + validation │ └── metrics/ # Prometheus collectors ├── docs/ │ └── superpowers/ │ └── specs/ │ └── 2026-05-22-package-updater-design.md ├── Dockerfile ├── docker-compose.example.yml # template users can copy ├── go.mod ├── go.sum └── README.md ``` The layout follows the standard Go project structure: `cmd/` for entry points, `internal/` for non-exported packages organised by responsibility. Each `internal/` package owns one concept and exposes a small interface; this keeps files focused and individually testable. ## 14. Testing Strategy - **Unit tests** for: image-name normalisation, Compose label parsing, dedup logic, path safety check, request validation. - **Integration tests** for the discovery layer using `github.com/testcontainers/testcontainers-go` to spin up a real Docker-in-Docker environment with a known Compose stack, verify discovery + matching end-to-end. - **HTTP handler tests** with `httptest` covering auth (200/401), validation (400), happy path (200), partial failure (207), zero match (200). - **Self-update test** verifies response is fully written and connection closed before the deferred exec fires (use a fake exec'er injected via interface). - **No end-to-end test** that actually exercises `docker compose pull` against a registry — that becomes a manual verification step on first deploy. ## 15. Open Questions / Future Considerations These are deliberately out of scope for v1 but noted as plausible additions: - Per-repo / per-image API keys (`keys.json` with image-pattern scopes) - Real rollback via tag pinning (requires state) - Notification plug-ins (Discord, Slack, email) - Dry-run mode (`?dry_run=true`) - Rate limiting (token bucket per client IP or per token) - Multi-worker concurrency with per-stack mutex - Audit history UI ## 16. Acceptance Criteria for v1 - [ ] `POST /update` with a known image triggers `docker compose pull` + `up -d` for all matching opt-in services on the host. - [ ] Containers without the opt-in label are never touched, even with a valid token. - [ ] Requests without a valid bearer token receive `401` and trigger no Docker action. - [ ] Zero matches returns `200` with `matched: 0`. - [ ] Partial failure across multiple matches returns `207` with per-job results. - [ ] Service updates itself successfully (HTTP response is fully delivered before the container is replaced). - [ ] Logs in Loki are filterable by `event`, `level`, `project`, `service`, `status`, `request_id`. - [ ] Prometheus successfully scrapes `/metrics`; all listed metrics are present. - [ ] Healthcheck returns `200` when Docker socket is reachable, fails otherwise. - [ ] Gitea composite action, used from a real workflow, triggers an end-to-end deploy and surfaces failure to the workflow when something goes wrong.