Adds services.last_auto_digest so the poller starts one automatic update per registry digest, and re-reads the service before starting it. Also records a bare image's running version as tag latest, only records well-formed digests, and shows a digest-less version unless a digest row of its tag was first seen at or after it.
package-updater
Updates self-hosted Docker Compose and Docker Swarm services from one place. A
master (web UI behind OIDC login, state in Postgres) shows every Compose stack,
Swarm service and container on every machine, and starts updates when someone clicks
Update. One agent per machine reports its inventory and runs the jobs. Both are in
the same image: updater-master and updater-agent (the default entrypoint).
Design: master/agent spec. Phase 2 plan: jobs and manual update. Phase 3 plan: webhook and auto mode. Phase 4 plan: registry polling and version history.
What an update does
Clicking Update on a service re-pulls the service's current tag (repo:tag). The
agent asks its Docker daemon for the tag's digest and deploys repo:tag@digest, so what
runs is exactly what was pulled. The log streams to the browser while it runs; job
history is kept in Postgres. A service without a tag (repo@digest), a plain container,
and the updater's own services cannot be updated, and the UI says why. A bare image
(image: postgres, no tag and no digest) counts as postgres:latest; only a reference
with a digest and no tag (repo@sha256:...) "follows no tag". Compose services with a
build: section are shown but never updated ("built from source (build:)"). One update at
a time per service, and one job at a time per agent.
Deploying the master
Use deploy/master.stack.yml as a starting point (Swarm stack;
needs Postgres and an OIDC provider). The master listens for the UI on PORT (default
8080, put it behind your reverse proxy) and for agents on AGENT_PORT (default 8090,
pinned TLS, reached directly on the internal network).
| Variable | Required | Default | Purpose |
|---|---|---|---|
UPDATER_API_KEY |
no | — | Bearer token for POST /update. Without it the webhook answers 503 and the master logs webhook_disabled. |
UPDATE_TIMEOUT |
no | 5m |
How long one POST /update waits for its auto jobs (Go duration). |
POLL_INTERVAL |
no | 6h |
Time between registry polling cycles (Go duration, must be positive). The first cycle runs one minute after start. |
DOCKER_CONFIG |
no | ~/.docker |
Directory with a config.json holding registry credentials for polling. Only inline auths entries work; see Registry polling. |
The master's other settings (DATABASE_URL, PUBLIC_URL, AGENT_URL, OIDC_*,
ALLOWED_GROUP, AGENT_IMAGE, PORT, AGENT_PORT, LOG_LEVEL) are shown in
deploy/master.stack.yml.
Webhook (CI)
CI calls the master after pushing an image. The Gitea action in
gitea-action/ does this; any HTTP client works.
POST /update
Authorization: Bearer <UPDATER_API_KEY>
Content-Type: application/json
{"image": "registry.example.com/myapp", "tag": "1.5.0"}
image is matched against the image repo of every service on every agent (tag and digest
in the request are ignored for matching; matching is case-sensitive). tag is optional.
For every match the master records the version as available (the overview shows an
"update available" badge), including services that cannot be updated automatically
(updater's own, build: and plain containers), so the badge shows for them too. Services
in manual mode stop there. Services in auto
mode also get an update job, and the request waits for those jobs to finish, at most
UPDATE_TIMEOUT.
tag semantics. The target of an auto job is <service's image repo>:<tag>, built
from the service's own repo and never from the request's image string. The tag must be
one that has been pushed. Compose services get pinned to it through
compose.override.yaml, as in any update. Omit tag to re-pull the tag the service
already follows (a bare image counts as :latest); a service that follows no tag
(repo@digest) is then not touched and is reported as refused ("no tag given and the
service follows no tag"), manual or auto.
With a tag, the "follows no tag" refusal does not apply; the other refusals (the
updater's own services, build: services, plain containers) still do.
Response (200 or 500):
{"request_id": "…", "image": "registry.example.com/myapp", "tag": "1.5.0", "matched": 2,
"results": [
{"service_id": 12, "service": "myapp/web", "kind": "compose", "mode": "auto",
"status": "succeeded", "job_id": 42, "job_url": "https://updater.example.com/jobs/42",
"to_ref": "registry.example.com/myapp:1.5.0", "duration_ms": 8210},
{"service_id": 15, "service": "myapp/worker", "kind": "compose", "mode": "manual",
"status": "marked", "to_ref": "registry.example.com/myapp:1.5.0", "duration_ms": 3}]}
status |
Meaning | Counts as failure |
|---|---|---|
succeeded |
Auto job finished successfully. | no |
failed |
Auto job failed (for example agent offline), or the version could not be recorded. error says why. |
yes |
timeout |
Job still running when UPDATE_TIMEOUT passed. The master leaves it running. |
yes |
marked |
Manual service; the version was recorded only. | no |
busy |
The service already has a queued or running job; job_id is that job, which is not waited for. |
no |
refused |
The service may not be updated, or there is no target (no tag given and the service follows no tag). error has the reason. |
no |
| Code | When |
|---|---|
200 |
No result is failed or timeout. |
400 |
Body is not JSON, image is missing, or tag is not a valid Docker tag. |
401 |
Missing or wrong bearer token. |
404 |
No service uses the image (catches typos in CI configuration). |
500 |
At least one result is failed or timeout. |
503 |
UPDATER_API_KEY is not set on the master. |
The webhook never creates services: the first deployment of a stack is done by hand. A
new service always starts in manual mode; switch it with the Make auto button on the
overview or the service page. Auto is refused for the updater's own services, build:
services and plain containers.
Warning: auto mode deploys whatever CI pushes, without anyone looking at it. Using it on critical services is your own risk.
The request is open for as long as the jobs run, so the reverse proxy in front of the
master must allow a read timeout longer than UPDATE_TIMEOUT. If the client disconnects,
marking and starting jobs still complete; only the waiting stops.
Registry polling
Besides the webhook, the master asks each registry whether the tag a service follows has
a new digest. Every POLL_INTERVAL it sends one HEAD /v2/<repo>/manifests/<tag> per
distinct followed repo:tag (a bare image counts as :latest; repo:tag@digest is polled
as repo:tag). It never downloads a manifest: Docker Hub counts a GET as a pull against
the rate limit and a HEAD as nothing
(source), and Docker Hub also has a
separate abuse limit for many pulls from one IP address, so the poller stays HEAD-only. For a
multi-arch image the digest is the index digest, the same one docker inspect shows in
RepoDigests.
- What counts as new. A digest different from the one the service runs becomes the
service's available version (the overview badge, source
poll). A digest the master already suggested is not suggested again. A service in auto mode also gets an update job torepo:tag(the agent resolves the digest itself, so agents on protocol version 2 keep working). A failed auto job is not retried every interval. - What is not polled. Services that follow no tag (
repo@digest),build:services, and services without a registry digest (locally built images). The service page says which. The updater's own services and plain containers are polled, so their badge works, but jobs for them are still refused. - Errors. A failed lookup is stored for the services that follow that tag and shown as a "poll failed" badge on the overview and in the "Polling" row of the service page; other tags continue. A later success clears it.
- Credentials. The master reads
$DOCKER_CONFIG/config.json(a file written bydocker login), mounted read-only into the container; without it registries are accessed anonymously. Only inlineauthsentries are supported. AcredsStoreorcredHelpersentry makes every lookup fail witherror getting credentials ... executable file not found in $PATH, because the container has no credential helper binaries. The master logsregistry_credentialsat startup with the directory and whetherconfig.jsonexists, never its contents.
Version history and rollback
The master records every version it sees (webhook, polling, and what agents report as
running) in image_versions. The service page lists the 10 most recently first seen
versions of the service's image repo, marks the one that is running, and has a Deploy
button on each row. A webhook announces a tag but not its digest, so such a row has no digest until polling or an
agent reports one; it is hidden once a row with the same tag and a digest exists.
Picking a version starts a manual job to exactly that reference. Picking an older one is a
rollback, including to a digest-pinned reference. A version without a tag deploys repo@digest and the
service then follows no tag, so it is no longer polled or updatable with the plain Update button until
it is moved to a tagged version again. Picks of digest-pinned or tagless versions need an agent speaking protocol
version 3 (this release); older agents get a failed job "agent too old for updates".
On a service in auto mode a rollback is only a warning in the UI: the service stays auto and is moved forward again at the next new version, announced by the webhook or found by polling. Rolling back to an older digest of the tag the service follows keeps the newer version marked as available.
Adding agents
Open the master's Agents page and add one. It shows a ready-to-run compose file with
the agent's token (shown once) and the master's key fingerprint; the same layout is in
deploy/agent.compose.yml.
| Variable | Required | Default | Purpose |
|---|---|---|---|
MASTER_URL |
yes | — | wss://host:port/agent |
MASTER_KEY |
yes | — | Master key fingerprint (sha256:...) shown in the UI. |
AGENT_TOKEN |
yes | — | Token shown once when the agent was added. |
SELF_ID |
no | hostname | Container ID or name of the agent itself; used to read its mounts. |
UPDATE_TIMEOUT |
no | 5m |
Per-job timeout (Go duration). |
DOCKER_CONFIG |
no | — | Directory with a config.json holding registry credentials. Only inline auths entries work; credsStore/credHelpers are not available in the container. |
LOG_LEVEL |
no | info |
debug / info / warn / error. |
docker compose runs inside the agent with only PATH, HOME and the DOCKER_*
variables. Variables a stack uses for interpolation must come from the stack's own .env
file or an --env-file (which the agent honours), never from the agent container's
environment.
Registry credentials
With DOCKER_CONFIG set, Compose jobs pull with the same credentials as docker compose.
For Swarm jobs the agent passes the matching registry's credentials with the service
update (same as docker service update --with-registry-auth), so Swarm stores them in the
cluster and hands them to the nodes that pull the image. Only the entry for the image's
registry host is ever sent.
Mounting stack directories
There is no STACKS_ROOT. The agent may only touch Compose stacks in directories that
are bind-mounted into its own container read-write at the same path as on the host
(Compose labels hold host paths). Add one line per directory:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /home/shcizo/self-hosted:/home/shcizo/self-hosted
Read-only mounts and mounts whose source differs from the destination are refused with a clear message. On Docker Desktop and OrbStack bind sources are translated host paths, so Compose jobs fail there; that is a development-only limitation.
How Compose updates work, and their limits
The agent never edits your files. It writes one file next to the stack's base file,
compose.override.yaml, with a header line and an image: entry per updated service
(repo:tag@digest), then runs docker compose pull and up -d for that service with
the override passed explicitly.
- The agent only writes
compose.override.yamlif it is absent or starts with its header line, and refuses the job if any other auto-loaded override exists (compose.override.yml,docker-compose.override.yml,docker-compose.override.yaml): Compose loads only one of them, so ours would silently disable yours. - Stacks you start with explicit
-ffiles do not load the override on a manualdocker compose up; the agent's own jobs always pass it. - The override is never pruned. If you delete a service from the stack file, its
image:entry stays incompose.override.yaml, and a manualdocker compose up -dwould recreate it. Remove the entry by hand.
Check before enabling. List the config files of every running stack to spot existing
override files and stacks started with explicit -f:
docker ps --format '{{.Label "com.docker.compose.project.config_files"}}' | sort -u
Swarm updates
The agent updates the service with ServiceUpdate and the pinned image. "Succeeded"
means the daemon accepted the new spec, not that the tasks converged; the inventory shows
degraded until they do. The agent doing the update must talk to a manager node (run it
on a manager, or point DOCKER_HOST at one).
Upgrading from the single-instance updater
This release removes cmd/server and changes the image's default entrypoint to
updater-agent. A running old-style updater (default entrypoint, UPDATER_API_KEY) will
crash-loop the next time its image is pulled. Anything that pulls :latest for the old
instance triggers this: its own /update webhook, Watchtower or Shepherd.
Pinning means two things: pin the old instance to the last pre-phase-2 tag and remove
its se.shcizo.auto-update label, so nothing updates it to the new image. The master
serves POST /update again (see Webhook); point the Gitea action's
endpoint there and set UPDATER_API_KEY on the master.
Master and agents must be on this release together: a phase-1 agent connects but every job sent to it fails "agent too old for updates".
Development
go test ./...
go test -race ./internal/agent/... ./internal/master/... ./internal/e2e/
go build ./cmd/master ./cmd/agent
docker build -t package-updater:dev .
DATABASE_URL=postgres://... go test -tags integration ./internal/master/store/