diff --git a/.env.example b/.env.example
index 71dd3b3..8a6bee7 100644
--- a/.env.example
+++ b/.env.example
@@ -11,6 +11,14 @@ STACKS_DIR=/opt/stacks
# Path to the host Docker socket, bind-mounted into the container.
DOCKER_SOCKET=/var/run/docker.sock
+# Optional: directory holding a Docker config.json with registry credentials
+# (what `docker login` writes). Lets DockPull check PRIVATE images and avoid
+# Docker Hub's anonymous rate limit. Mount your config and point this at its
+# directory, e.g. mount ~/.docker/config.json -> /root/.docker/config.json:ro
+# and leave this unset (the default /root/.docker is used), or set a custom dir.
+# Only static base64 `auths` are supported (not credential-helper stores).
+# DOCKER_CONFIG=/root/.docker
+
# Directory where the SQLite database is stored (should be a persistent
# volume).
DATA_DIR=/data
@@ -35,13 +43,20 @@ BASE_URL=http://localhost:5000
# true. Leave unset when exposed directly, so X-Forwarded-For can't be spoofed.
# TRUST_PROXY=1
+# Serve under a subpath (e.g. behind a reverse proxy at https://host/dockpull/)
+# when the proxy does NOT strip the prefix. Build the client with the same
+# value: BASE_PATH=/dockpull docker compose build. Leave unset for root.
+# BASE_PATH=/dockpull
+
# --- Background checks & notifications (all optional; also editable in the UI) ---
# Whether the server checks for updates on a schedule. Default: true.
# BACKGROUND_CHECK_ENABLED=true
# Daily local time (HH:MM, 24h) for the scheduled scan. Default: 09:00.
# SCHEDULED_CHECK_TIME=09:00
-# Discord (or compatible) webhook URL to notify when updates are found.
-# Leave unset to disable notifications.
+# Notifications (all also editable in the UI). NOTIFY_TYPE is one of:
+# discord | ntfy | gotify | webhook. DISCORD_WEBHOOK_URL is the target URL for
+# whichever type you pick (a self-hosted ntfy/Gotify on your LAN is fine).
+# NOTIFY_TYPE=discord
# DISCORD_WEBHOOK_URL=
# Optional GitHub token (read-only, no scopes needed) used for changelog and
diff --git a/API_CONTRACT.md b/API_CONTRACT.md
index 33fc9a3..08101ff 100644
--- a/API_CONTRACT.md
+++ b/API_CONTRACT.md
@@ -76,6 +76,20 @@ All request/response bodies are JSON unless noted otherwise.
progress via the SSE endpoint below.
- Errors: `404` if no such container; `409` if 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 as `success:false` with an actionable message
+ (and a rollback point is recorded).
+
+### `POST /api/update/:name/revert`
+
+- 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_rollback` if there's nothing to revert to; `404 not_found`
+ if no such container; `409` if an update/revert is already in progress.
### `GET /api/update/:name/stream`
@@ -243,6 +257,10 @@ Field notes:
- `pinned` — `true` if the image ref is in the `pinned` table ("Pin Version":
update indicator is suppressed and the container is grouped separately, but
a manual update is still allowed).
+- `canRevert` — `true` if 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, or
+ `null`.
- `state` — Docker container state (`running`, `exited`, etc.).
- `composeFile` / `workingDir` — derived from
`com.docker.compose.project.config_files` /
diff --git a/README.md b/README.md
index 475dacb..371dfd0 100644
--- a/README.md
+++ b/README.md
@@ -71,17 +71,28 @@ If the paths don't match you'll get `compose file not found` and broken bind mou
- **Updates tab** — containers grouped by stack, update-available ones on top.
Defaults to showing only what needs updating; flip to **All** to see everything.
Tap **Update** to pull + recreate that service (watch live logs), or **Update all**
- to run them one at a time. **Pin Version** holds a container at its current version.
+ to run them one at a time. After an update DockPull verifies the container actually
+ comes up healthy (catching crash-loops), and offers a one-click **Revert** to the
+ previous image if it doesn't. **Pin Version** holds a container at its current version.
- **History tab** — a log of past updates. **Clear history** wipes it (with a confirm).
- **Settings tab** — theme, default view, auto-check on open, the **daily background
- scan** + **Discord webhook** (with a "send test" button), and pinned-version
- management.
+ scan** + **notifications** (Discord, ntfy, Gotify, or a generic webhook — with a
+ "send test" button), and pinned-version management.
- **Install as an app (PWA)** — use your browser's "Add to Home Screen" / "Install"
to get a standalone, full-screen icon.
-The update check queries registries directly (Docker Hub, GHCR, lscr.io, quay.io, …)
-for **public** images reachable anonymously. Private images that need credentials are
-skipped.
+The update check queries registries directly (Docker Hub, GHCR, lscr.io, quay.io, …).
+Public images work anonymously. For **private** images (and to dodge Docker Hub's
+anonymous rate limit), mount your Docker credentials read-only so DockPull can
+authenticate:
+
+```yaml
+ volumes:
+ - ~/.docker/config.json:/root/.docker/config.json:ro
+```
+
+This is the file `docker login` writes; only static `auths` entries are used (not
+credential-helper stores).
---
@@ -119,6 +130,11 @@ model and hardening tips (HTTPS/`BASE_URL`, `TRUST_PROXY`, `SESSION_TTL`).
To update DockPull itself: `docker compose pull dockpull && docker compose up -d dockpull`.
+**Behind a reverse proxy?** Set `TRUST_PROXY=1`. To serve under a **subpath**
+(`https://host/dockpull/`), either have the proxy strip the prefix, or set
+`BASE_PATH=/dockpull` and build the client with the same value
+(`BASE_PATH=/dockpull docker compose build`).
+
---
## Troubleshooting
diff --git a/SECURITY.md b/SECURITY.md
index f3aae66..c2e4efc 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -22,9 +22,10 @@ DockPull is built for a **trusted LAN / homelab** behind authentication. It is
with argument arrays — never a shell string — so container/label values can't
inject commands.
- **Parameterized SQL** (better-sqlite3 prepared statements).
-- **SSRF-guarded webhooks.** The Discord webhook URL must be `https` to a public
- host; loopback/private/link-local/metadata addresses are rejected, including
- hostnames that *resolve* to a private address.
+- **Notification URL is admin-only.** The notification target (Discord / ntfy /
+ Gotify / generic webhook) is set behind the login and may deliberately point at
+ an internal service (e.g. a self-hosted ntfy/Gotify on your LAN), so internal
+ hosts are allowed by design; it is validated only as a well-formed `http(s)` URL.
- **Security headers** on every response: `Content-Security-Policy`,
`X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy`,
`Cross-Origin-Opener-Policy`, and `Strict-Transport-Security` when served over
diff --git a/client/src/Dashboard.jsx b/client/src/Dashboard.jsx
index e90c110..293c6f1 100644
--- a/client/src/Dashboard.jsx
+++ b/client/src/Dashboard.jsx
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { getContainers, checkNow, getSettings, updateSettings } from './api.js';
+import { getContainers, checkNow, getSettings, updateSettings, getStatus } from './api.js';
import UpdateCard from './components/UpdateCard.jsx';
import UpdateAllButton from './components/UpdateAllButton.jsx';
import StackGroup from './components/StackGroup.jsx';
@@ -7,6 +7,18 @@ import StackGroup from './components/StackGroup.jsx';
const AUTOCHECK_SESSION = 'dockpull.autochecked';
const UNGROUPED = 'Ungrouped';
+// Compact "x ago" for the last-checked line.
+function timeAgo(ts) {
+ if (!ts) return null;
+ const s = Math.max(0, Math.round((Date.now() - ts) / 1000));
+ if (s < 60) return 'just now';
+ const m = Math.round(s / 60);
+ if (m < 60) return `${m}m ago`;
+ const h = Math.round(m / 60);
+ if (h < 24) return `${h}h ago`;
+ return `${Math.round(h / 24)}d ago`;
+}
+
function hasUpdate(c) {
return c.updateAvailable && !c.pinned;
}
@@ -38,6 +50,7 @@ export default function Dashboard({ onPendingCountChange }) {
const [checking, setChecking] = useState(false);
const [checkMsg, setCheckMsg] = useState('');
const [filter, setFilter] = useState('updates');
+ const [lastCheckedAt, setLastCheckedAt] = useState(null);
// name -> run() function, populated by each UpdateCard so "Update all"
// can drive the same start+SSE flow the per-card button uses.
@@ -60,6 +73,7 @@ export default function Dashboard({ onPendingCountChange }) {
try {
const r = await checkNow();
await load();
+ setLastCheckedAt(Date.now());
const checked = r?.checked ?? 0;
const found = r?.updatesFound ?? 0;
const errs = r?.errors ?? 0;
@@ -75,6 +89,15 @@ export default function Dashboard({ onPendingCountChange }) {
}
}, [load]);
+ // Seed the "last checked" time from the server's persisted last check.
+ useEffect(() => {
+ getStatus()
+ .then((s) => {
+ if (s?.lastCheck?.at) setLastCheckedAt((prev) => prev || s.lastCheck.at);
+ })
+ .catch(() => {});
+ }, []);
+
// Initial load + settings + auto-check on first open this session.
useEffect(() => {
let cancelled = false;
@@ -234,6 +257,7 @@ export default function Dashboard({ onPendingCountChange }) {
Queries each image's registry for a newer version — nothing is pulled until you tap Update.
+ {lastCheckedAt ? · Last checked {timeAgo(lastCheckedAt)} : null}
diff --git a/client/src/api.js b/client/src/api.js
index ba51bd0..f65c384 100644
--- a/client/src/api.js
+++ b/client/src/api.js
@@ -2,7 +2,9 @@
// All requests are same-origin (dev proxy forwards /api to the server) and
// always send the session cookie.
-const BASE = '/api';
+// Honour a build-time subpath (Vite `base`), so `/api` is reached under the
+// same prefix the app is served from (e.g. /dockpull/api).
+const BASE = `${import.meta.env.BASE_URL.replace(/\/$/, '')}/api`;
class ApiError extends Error {
constructor(message, status, body) {
@@ -97,10 +99,19 @@ export function checkNow() {
return post('/check');
}
+// App status: { version, lastCheck: { at, total, checked, updatesFound, errors, errored } | null }.
+export function getStatus() {
+ return get('/status');
+}
+
export function startUpdate(name) {
return post(`/update/${encodeURIComponent(name)}`);
}
+export function revertUpdate(name) {
+ return post(`/update/${encodeURIComponent(name)}/revert`);
+}
+
// --- History ---
export function getHistory(params = {}) {
@@ -140,8 +151,11 @@ export function updateSettings(patch) {
return request('PUT', '/settings', patch);
}
-export function testNotify(url) {
- return post('/notify/test', url ? { url } : {});
+export function testNotify(url, type) {
+ const body = {};
+ if (url) body.url = url;
+ if (type) body.type = type;
+ return post('/notify/test', body);
}
export function getChangelog(name) {
diff --git a/client/src/components/UpdateCard.jsx b/client/src/components/UpdateCard.jsx
index 8a5df48..65772f6 100644
--- a/client/src/components/UpdateCard.jsx
+++ b/client/src/components/UpdateCard.jsx
@@ -3,6 +3,7 @@ import { pin, unpin, getChangelog } from '../api.js';
import { useUpdateRunner } from '../hooks/useUpdateRunner.js';
import StatusMessage from './StatusMessage.jsx';
import StreamLog from './StreamLog.jsx';
+import ConfirmDialog from './ConfirmDialog.jsx';
function shortDigest(digest) {
if (!digest) return '—';
@@ -137,18 +138,19 @@ function ChangelogContent({ data }) {
* - registerRunner(name, runFn) — handle for "Update all"
*/
export default function UpdateCard({ container, onSettled, onPinChange, registerRunner }) {
- const { name, project, service, image, currentDigest, availableVersion, availableDigest, updateAvailable, pinned, sourceUrl } =
+ const { name, project, service, image, currentDigest, availableVersion, availableDigest, updateAvailable, pinned, sourceUrl, canRevert, rollbackVersion, checkError } =
container;
const [pinBusy, setPinBusy] = useState(false);
const [actionError, setActionError] = useState('');
+ const [confirmRevert, setConfirmRevert] = useState(false);
const [clOpen, setClOpen] = useState(false);
const [clLoading, setClLoading] = useState(false);
const [clData, setClData] = useState(null);
const [clError, setClError] = useState('');
- const { run, busy, startError, status, lines } = useUpdateRunner(name, onSettled);
+ const { run, revert, busy, startError, status, lines } = useUpdateRunner(name, onSettled);
useEffect(() => {
if (registerRunner) registerRunner(name, run);
@@ -162,6 +164,12 @@ export default function UpdateCard({ container, onSettled, onPinChange, register
run();
}, [busy, run]);
+ const handleRevert = useCallback(() => {
+ setConfirmRevert(false);
+ if (busy) return;
+ revert();
+ }, [busy, revert]);
+
const togglePin = useCallback(async () => {
setPinBusy(true);
setActionError('');
@@ -248,6 +256,11 @@ export default function UpdateCard({ container, onSettled, onPinChange, register
)}
+ {checkError && (
+
+ ⚠ Couldn't check for updates (e.g. private registry or rate limit).
+