This document is the shared contract between the server and the client (and between work packages). Any change here should be coordinated across both.
All request/response bodies are JSON unless noted otherwise.
- Auth is a single shared password (
ADMIN_PASSWORD), compared in constant time, no user accounts/database. - On successful login, the server sets a signed, httpOnly cookie named
dockpull_session(SameSite=Lax,Securewhen served over HTTPS,Max-Age=SESSION_TTLseconds). - Protected routes (everything except
/api/auth/loginand/api/health) require a validdockpull_sessioncookie. If it is missing, invalid, or expired, the server responds401 Unauthorizedwith{ "error": "unauthorized" }. - The cookie is bound to the current
ADMIN_PASSWORD: changing the password signs out every existing session. - CSRF: every state-changing
/api/*request (anything but GET/HEAD/OPTIONS — login included) must send the headerX-DockPull: 1, or it's rejected with403 { "error": "csrf_header_missing" }. - Routes with a
:nameparam reject anything that isn't a valid container name with400 { "error": "invalid_container_name" }. - Error bodies may include a human-readable
messagealongsideerror.
- Auth: none.
- Body:
{ "password": "string" } - Response:
200 { "ok": true }+Set-Cookie: dockpull_session=...on success.401 { "error": "invalid_password" }on bad password.429 { "error": "too_many_attempts" }after too many failed attempts from one client IP (temporary lockout).
- Auth: cookie.
- Body: none.
- Response:
200 { "ok": true }, clears thedockpull_sessioncookie.
- Auth: cookie (optional — never errors, reports status).
- Response:
200 { "authenticated": boolean }
- Auth: cookie.
- App version + background info for the About panel and nav badges.
- Response:
200—{ "version": string, "lastCheck": object|null, "danglingImages": { "count": number, "totalSize": number, "checkedAt": number }|null, "serverTime": string, "timeZone": string, "serverLocalTime": string }lastCheckis the summary of the most recent update check (manual or scheduled), ornullbefore the first one runs.danglingImagesis set once a day by the scheduled background check (seePOST /api/checkscheduling in Settings) —nulluntil the first scheduled run has happened.checkedAtis aDate.now()epoch ms. Best-effort: a failed lookup (e.g. Docker unavailable) just leaves the previous value in place rather than clearing it.
- Auth: cookie.
- Response:
200— array of container items (shape below). Each item carries acomposeFileMissingflag the dashboard uses to warn when a stack's compose file isn't reachable inside the container (mount misconfigured).
- Auth: cookie.
- Body: none.
- Actively queries the registry for each running image's current digest and records/clears update events accordingly.
- Response:
200 { "total": n, "checked": n, "updatesFound": n, "errors": n }503 { "error": "docker_unavailable" }if the Docker daemon is unreachable.
- Auth: cookie.
- Response:
text/event-stream(SSE). Emitsdata: {"type":"containers-changed"}whenever server state changes (a check ran, an update finished, or a pin changed) so dashboards can refresh without a manual reload. Comment lines (: ...) are sent as keepalives.
- Auth: cookie.
- Path param:
name— container name. - Body: none.
- Response:
200 { "streamId": "string" }— starts a pull + recreate operation for that container; use the returnedstreamIdto subscribe to progress via the SSE endpoint below. - Errors:
404if no such container;409if an update is already in progress for that container. - Note: after
up -d, the container is health-checked; if it doesn't come up healthy the result is reported assuccess:falsewith an actionable message (and a rollback point is recorded).
- Auth: cookie.
- Path param:
name— container name. - Recreates the container from the image it ran before its last update (the
rollback point), then starts it. Same SSE streaming + result shape as an
update; subscribe via
GET /api/update/:name/stream. - Response:
200 { "streamId": "string" }. - Errors:
404 no_rollbackif there's nothing to revert to;404 not_foundif no such container;409if an update/revert is already in progress;410 rollback_image_goneif the saved image no longer exists (e.g. it was pruned) — the rollback point is dropped and the container is not touched. - The recreated container runs a bare image ID, so DockPull labels it with
io.dockpull.image-ref/io.dockpull.image-digestto keep tracking its image (checks, updates and pins keep working, and the newer version is offered again).
- Auth: cookie. Body:
{ "tag": "string" }— must be a newer tag the last check offered for this container's image (newerTag/newerMajorTag). - Compose-managed: rewrites that service's
image:line (the last-ffile that sets it;.dockpull.bakkept), then updates asPOST /api/update/:name. If the pull/up fails before the container changed, the file is restored. Standalone: pulls the new tag and recreates the container on it. The rollback point records the compose edit, so a revert puts the old tag back in the file. - Response:
200 { "streamId": "string" }(stream as for updates). - Errors:
400 invalid_tag;409 tag_not_offered;409 update_in_progress;404 not_found.
- Auth: cookie.
- Path param:
name— container name (same as used to start the update). - Response:
text/event-stream(SSE). Events:data: {"type":"log","line":"..."}— zero or more, streamed as the update runs (docker compose pull/up -doutput).data: {"type":"result","success":boolean,"message":"..."}— exactly one, final event; the stream closes after this.
- Auth: cookie.
- Query params:
container(optional, filter by container name),limit(default50),offset(default0). - Response:
200— array of update history rows, newest first:[ { "id": 1, "container_name": "nginx", "image": "nginx:latest", "old_digest": "sha256:...", "new_digest": "sha256:...", "old_version": "1.27.3", "new_version": "1.27.4", "status": "success", "message": "Updated successfully", "created_at": "2026-06-22 12:00:00" } ] old_version/new_versionare human-readable versions resolved from the per-digest version store (learned during update checks), best-effort —nullwhen a digest's version was never learned; clients should fall back to showing the digest.
- Auth: cookie.
- Path param:
name— container name. - Query params:
limit(default50),offset(default0). - Response: same shape as
GET /api/history, filtered to that container.
- Auth: cookie.
- Deletes all update-history rows.
- Response:
200—{ "ok": true }.
- Auth: cookie.
- Dry-run preview of what
POST /api/images/prunewould remove — lists dangling images (untagged images no container — running or stopped — uses) without deleting anything, for a confirmation dialog to summarize before the user commits to pruning. - Response:
200—{ "count": number, "totalSize": number, "exact": boolean, "images": [{ "id": string, "size": number, "fullSize": number, "created": number|null, "fromContainer": string|null }] }wheresizeis what removing that image should free — its whole size (fullSize) minus the layers it shares with other images, which stay — andtotalSizeis their sum, in bytes (exact: falsewhen the daemon didn't report shared sizes, sosizefalls back to the whole image).idis a short (12-char) image ID, andfromContaineris the name of the container this image was replaced on (via its remembered rollback point), ornullwhen that's unknown — images left over from before the container's most recent update, or pulled outside DockPull, aren't attributed. 503 { "error": "docker_unavailable" }when the Docker daemon is unreachable.
- Auth: cookie.
- Removes dangling images (untagged layers no container references) — the leftovers that accumulate after image updates. Tagged images and anything in use are never touched.
- Body (optional):
{ "ids": [string, …] }— short (12-char) image IDs fromGET /api/images/dangling. When present, only those images are removed (the confirmation dialog uses this to let the user exclude individual layers); each ID is re-checked against the current dangling set before removal, so a stale or non-dangling ID is silently skipped. With no body (or noids), every dangling layer is pruned. - Response:
200—{ "ok": true, "deleted": number, "spaceReclaimed": number, "revertsRemoved": [string] }wheredeletedis the number of images removed,spaceReclaimedis the bytes actually freed — measured as image-layer disk usage before minus after (falls back to the per-image estimate if the daemon can't report it) — andrevertsRemovednames containers whose revert point was among the removed images (their rollback points are dropped). 503 { "error": "docker_unavailable" }when the Docker daemon is unreachable.
- Auth: cookie.
- Response:
200— array of pinned refs, e.g.["nginx:latest", "redis:7"].
- Auth: cookie.
- Body:
{ "ref": "string" } - Response:
200 { "ok": true }. Idempotent.
- Auth: cookie.
- Path param:
ref— the image ref to unpin (URL-encoded). - Response:
200 { "ok": true }. Idempotent.
Note: refs passed to POST /api/pin and DELETE /api/pin/:ref are
normalized server-side (via the same normalizeRef used elsewhere) before
being stored/looked up, so e.g. raw nginx and
docker.io/library/nginx:latest are equivalent and GET /api/pinned
always returns normalized refs. Pinning ("Pin Version") holds a container at
its current version: it's never flagged for updates and is grouped into a
separate section, but can still be updated by hand.
- Auth: cookie.
- Body:
{ "ref": "string" }(normalized like/api/pin). - Skips the currently offered update for that image ("not this build"): the
item reports
updateAvailable: false, skipped: trueand it's left out of notifications, until a newer build is found (which is offered as usual). - Response:
200 { "ok": true };404 { "error": "no_pending_update" }if there's no pending update for the ref.
- Auth: cookie.
- Un-skips the pending update for
ref(URL-encoded). Same responses asPOST /api/skip.
- Auth: cookie. Body:
{ "ref": "string", "tag": "string"|null }. - Hides one offered newer tag until an even newer one appears (
tag: nullclears). - Response:
200 { "ok": true };404 no_pending_update.
- Auth: cookie. Invalidates every session, then sets a fresh cookie for the
caller so only this device stays signed in. Response:
200 { "ok": true }.
- Auth: cookie. Whether a newer DockPull release exists (from GitHub releases, cached 30 min). Read-only — DockPull never updates itself.
- Response:
200 { "current": string, "available": boolean, "latest"?: string, "releaseUrl"?: string, "releases"?: [{ "tag", "url", "publishedAt", "body" }] }.
- Auth: cookie. Downloads
{ "format": "dockpull-backup", "version": 1, "appVersion", "exportedAt", "settings", "pinned": [ref], "history": [row] }(up to 5000 history rows). Contains the notification URL.
- Auth: cookie. Body: a backup as above (up to 5 MB). Settings are validated all-or-nothing; invalid pins/history rows are skipped; history is only imported into an empty history.
- Response:
200 { "ok": true, "settings", "pinned", "history", "skipped", "historySkippedBecauseNotEmpty" };400 invalid_backup.
- Auth: cookie.
- Response:
200— current settings, fully populated with defaults:{ "defaultFilter": "updates", "autoCheckOnOpen": true, "backgroundCheckEnabled": true, "scheduledCheckTime": "09:00", "discordEnabled": false, "discordWebhookUrl": "" }defaultFilter—"updates"or"all"; the view the dashboard opens in.autoCheckOnOpen— whether the dashboard runs a check automatically on first open.backgroundCheckEnabled— whether the server runs a scheduled check.scheduledCheckTime— daily local time (HH:MM) for the scheduled scan.discordEnabled— whether to send Discord notifications on new updates.discordWebhookUrl— Discord (or compatible) webhook URL, or"".
- Auth: cookie.
- Body: a partial patch of the settings object, e.g.
{ "defaultFilter": "all" }. Unknown keys are ignored; invalid values for known keys return400 { "error": "invalid_value" }. Changing the time/enable re-arms the background scheduler immediately. - Response:
200— the full, updated settings object.
- Auth: cookie.
- Body:
{ "url": "string" }(optional) — a webhook URL to test; falls back to the configureddiscordWebhookUrl. - Sends a one-off test message to the webhook.
- Response:
200 { "ok": true }on success;400 { "error": "no_webhook" }if no URL is configured;502 { "error": "webhook_failed" }if the webhook rejected the message.
- Auth: none.
- Response:
200 { "ok": true }.
{
"name": "nginx",
"project": "web",
"service": "nginx",
"image": "nginx:latest",
"tag": "latest",
"currentVersion": "1.27.3",
"sourceUrl": "https://github.com/nginx/nginx",
"currentDigest": "sha256:...",
"updateAvailable": true,
"availableDigest": "sha256:...",
"availableVersion": "1.27.4",
"pinned": false,
"state": "running",
"composeFile": "/opt/stacks/web/compose.yaml",
"composeFileMissing": false,
"workingDir": "/opt/stacks/web"
}Field notes:
name— Docker container name.project/service— derived from thecom.docker.compose.project/com.docker.compose.servicelabels.image— image ref as configured (tag, not digest).tag— the tag portion ofimage(e.g.latest,1.27), ornullif the ref is digest-pinned.currentVersion— human-readable version from the running image'sorg.opencontainers.image.versionlabel, if it sets one (elsenull).sourceUrl— source/project URL from the image'sorg.opencontainers.image.source(or.url) label, normalized to an https web URL (elsenull); used for the per-card changelog/source link.currentDigest— digest of the image the running container was created from.updateAvailable—trueif the most recent unresolved update event (from the registry check) for this image's normalized ref reports a digest different fromcurrentDigest.availableDigest— the digest from that unresolved event, if any (elsenull).availableVersion— a human version for the AVAILABLE (remote) image, resolved when the update was found (best-effort, elsenull). Prefers the image'sorg.opencontainers.image.versionlabel; when that isn't a usable version (e.g.main/latest/a sha) but the image declares a GitHub source, falls back to that repo's latest release tag.breakingRisk—truewhenupdateAvailableand the release notes between the running and available versions mention breaking changes (best-effort, GitHub-sourced images only; scanned when the update event is recorded).falseotherwise, including when no update is available.newerTag— a newer version TAG in the same major version (e.g. runningpostgres:16.3, registry has16.4), ornull.newerMajorTag— the newest tag of a higher major version, only when thetagUpdatessetting ismajor. Both respect a skipped tag (POST /api/skip-tag) and arenullwhentagUpdatesisoff.skipped—truewhen an update exists but the user skipped that exact build (POST /api/skip);updateAvailableis thenfalse, whileavailableDigest/availableVersionstill describe the skipped build.pinned—trueif the image ref is in thepinnedtable ("Pin Version": update indicator is suppressed and the container is grouped separately, but a manual update is still allowed).canRevert—trueif a rollback point exists (the container was updated and its previous image is remembered), so the UI can offer a one-click revert.rollbackVersion— the previous version label for that rollback point, ornull.state— Docker container state (running,exited, etc.).composeFile/workingDir— derived fromcom.docker.compose.project.config_files/com.docker.compose.project.working_dirlabels; used to rundocker composecommands for that container.composeFileMissing—truewhencomposeFileis set but not present inside the updater container (the same-path stacks mount is missing/wrong), so a compose update would fail; the dashboard surfaces a warning banner.
update_events rows are produced solely by the active registry check
(POST /api/check and the background scheduler). Each records the
normalized_ref and the registry-reported digest; a row is resolved once
the running container's digest matches it (the update was applied). There is
no external notifier — the app queries registries directly.