From 135b95cd6ec7e6250ef11403cf283a6643d2d011 Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Wed, 7 Oct 2026 06:39:49 -0400 Subject: [PATCH 1/3] feat: optionally remove old Dokploy images on web server update The update dialog gets a switch "Remove old Dokploy images, keep the last N" (N = 3, 4 or 5). The choice is stored on the dokploy service as DOKPLOY_KEEP_IMAGES, so it survives restarts and later manual updates. Cleanup runs when the new Dokploy container starts, because the update replaces the container that started it (stop-first). It removes exited dokploy task containers that pin old images, then the images beyond the newest N of the fork and official repositories. Nothing is forced: images used by running or foreign containers are left alone. Co-Authored-By: Claude Opus 5.5 --- .../settings/docker-server-boundary.test.ts | 59 ++++++++++ .../settings/dokploy-image-cleanup.test.ts | 111 ++++++++++++++++++ .../request-log-authorization.test.ts | 4 + .../web-server/toggle-image-cleanup.tsx | 64 ++++++++++ .../settings/web-server/update-server.tsx | 19 ++- .../settings/web-server/update-webserver.tsx | 4 +- apps/dokploy/server/api/routers/settings.ts | 89 +++++++++----- apps/dokploy/server/server.ts | 2 + packages/server/src/services/settings.ts | 86 ++++++++++++++ 9 files changed, 403 insertions(+), 35 deletions(-) create mode 100644 apps/dokploy/__test__/settings/dokploy-image-cleanup.test.ts create mode 100644 apps/dokploy/components/dashboard/settings/web-server/toggle-image-cleanup.tsx diff --git a/apps/dokploy/__test__/settings/docker-server-boundary.test.ts b/apps/dokploy/__test__/settings/docker-server-boundary.test.ts index f7633d654f9..9e378185270 100644 --- a/apps/dokploy/__test__/settings/docker-server-boundary.test.ts +++ b/apps/dokploy/__test__/settings/docker-server-boundary.test.ts @@ -67,6 +67,10 @@ const redactWebServerSettings = (settings: T) => settings; vi.mock("@dokploy/server", () => ({ CLEANUP_CRON_JOB: "0 0 * * *", DEFAULT_UPDATE_DATA: {}, + DOKPLOY_KEEP_IMAGES_ENV: "DOKPLOY_KEEP_IMAGES", + DOKPLOY_KEEP_IMAGES_MAX: 5, + DOKPLOY_KEEP_IMAGES_MIN: 3, + getDokployImageKeepCount: vi.fn(), IS_CLOUD: false, checkGPUStatus: mocks.checkGPUStatus, checkPortInUse: mocks.checkPortInUse, @@ -419,6 +423,7 @@ describe("settings Docker server boundary", () => { "v0.30.6", "off_v0.29.8/Fork_159+next", "v0.30.0", + undefined, ); expect(mocks.spawnAsync).toHaveBeenCalledWith("sh", [ "-c", @@ -426,6 +431,37 @@ describe("settings Docker server boundary", () => { ]); }); + it("passes the image keep count to the AgentHits update command", async () => { + mocks.getUpdateData.mockResolvedValue({ + latestVersion: "off_v0.29.8/Fork_159+next", + updateAvailable: true, + updateSource: "agenthits", + latestOfficialVersion: "v0.30.0", + }); + mocks.getAgentHitsUpdateCommand.mockReturnValue("agenthits update command"); + + await expect(createCaller().updateServer({ keepImages: 4 })).resolves.toBe( + true, + ); + + expect(mocks.getAgentHitsUpdateCommand).toHaveBeenCalledWith( + "v0.30.6", + "off_v0.29.8/Fork_159+next", + "v0.30.0", + 4, + ); + }); + + it("rejects image keep counts outside 3 to 5", async () => { + await expect( + createCaller().updateServer({ keepImages: 2 }), + ).rejects.toThrow(); + await expect( + createCaller().updateServer({ keepImages: 10 }), + ).rejects.toThrow(); + expect(mocks.spawnAsync).not.toHaveBeenCalled(); + }); + it("keeps official updates on the official Dokploy image path", async () => { mocks.getUpdateData.mockResolvedValue({ latestVersion: "v0.30.0", @@ -446,6 +482,29 @@ describe("settings Docker server boundary", () => { expect(mocks.getAgentHitsUpdateCommand).not.toHaveBeenCalled(); }); + it("turns the image cleanup off on official updates", async () => { + mocks.getUpdateData.mockResolvedValue({ + latestVersion: "v0.30.0", + updateAvailable: true, + updateSource: "official", + }); + + await expect( + createCaller().updateServer({ keepImages: null }), + ).resolves.toBe(true); + + expect(mocks.spawnAsync).toHaveBeenCalledWith("docker", [ + "service", + "update", + "--force", + "--image", + "dokploy/dokploy:v0.30.0", + "--env-add", + "DOKPLOY_KEEP_IMAGES=0", + "dokploy", + ]); + }); + it("requires api.read before generating the OpenAPI document", async () => { const result = await createCaller().getOpenApiDocument(); diff --git a/apps/dokploy/__test__/settings/dokploy-image-cleanup.test.ts b/apps/dokploy/__test__/settings/dokploy-image-cleanup.test.ts new file mode 100644 index 00000000000..973331a3303 --- /dev/null +++ b/apps/dokploy/__test__/settings/dokploy-image-cleanup.test.ts @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { execAsync } = vi.hoisted(() => ({ execAsync: vi.fn() })); + +vi.mock("@dokploy/server/db", () => ({ + db: {}, +})); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync, + execAsyncRemote: vi.fn(), +})); + +const { + cleanupOldDokployImages, + getAgentHitsUpdateCommand, + getDokployImageCleanupCommand, + getDokployImageKeepCount, + getDokployImageRepositories, +} = await import("@dokploy/server/services/settings"); + +describe("Dokploy image cleanup", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("DOKPLOY_KEEP_IMAGES", undefined); + vi.stubEnv("DOKPLOY_AGENTHITS_UPDATE_IMAGE", undefined); + vi.stubEnv("DOKPLOY_AGENTHITS_UPDATE_TAG", undefined); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it.each([ + [undefined, null], + ["0", null], + ["2", null], + ["3", 3], + ["5", 5], + ["6", null], + ["3.5", null], + ["abc", null], + ])("reads keep count %s as %s", (value, expected) => { + vi.stubEnv("DOKPLOY_KEEP_IMAGES", value); + expect(getDokployImageKeepCount()).toBe(expected); + }); + + it("targets the fork and official image repositories without tags", () => { + expect(getDokployImageRepositories()).toEqual([ + "ghcr.io/agenthits/dokploy", + "dokploy/dokploy", + ]); + + vi.stubEnv( + "DOKPLOY_AGENTHITS_UPDATE_IMAGE", + "registry.local:5000/team/dokploy:dev@sha256:abc", + ); + expect(getDokployImageRepositories()).toEqual([ + "registry.local:5000/team/dokploy", + "dokploy/dokploy", + ]); + }); + + it("keeps the newest images and only removes exited dokploy task containers", () => { + const command = getDokployImageCleanupCommand(4); + + expect(command).toContain( + "--filter reference\\=ghcr.io/agenthits/dokploy --filter reference\\=dokploy/dokploy", + ); + expect(command).toContain("tail -n +5"); + expect(command).toContain("--filter status=exited"); + expect(command).toContain( + "--filter label=com.docker.swarm.service.name=dokploy", + ); + expect(command).not.toMatch(/\brm (-f|--force)\b/); + }); + + it("does nothing at startup when the cleanup is off", async () => { + await cleanupOldDokployImages(); + expect(execAsync).not.toHaveBeenCalled(); + }); + + it("runs the cleanup at startup when a keep count is set", async () => { + vi.stubEnv("DOKPLOY_KEEP_IMAGES", "3"); + execAsync.mockResolvedValue({ stdout: "", stderr: "" }); + + await cleanupOldDokployImages(); + + expect(execAsync).toHaveBeenCalledWith(getDokployImageCleanupCommand(3)); + }); + + it("does not throw when the cleanup fails", async () => { + vi.stubEnv("DOKPLOY_KEEP_IMAGES", "3"); + execAsync.mockRejectedValue(new Error("docker unavailable")); + vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect(cleanupOldDokployImages()).resolves.toBeUndefined(); + }); + + it("stores the keep count on the service only when it is given", () => { + expect(getAgentHitsUpdateCommand("v0.30.6")).not.toContain( + "DOKPLOY_KEEP_IMAGES", + ); + expect(getAgentHitsUpdateCommand("v0.30.6", null, null, 3)).toContain( + "--env-add DOKPLOY_KEEP_IMAGES\\=3 \\\n\tdokploy", + ); + expect(getAgentHitsUpdateCommand("v0.30.6", null, null, null)).toContain( + "--env-add DOKPLOY_KEEP_IMAGES\\=0", + ); + }); +}); diff --git a/apps/dokploy/__test__/settings/request-log-authorization.test.ts b/apps/dokploy/__test__/settings/request-log-authorization.test.ts index 5495e47f152..f9455d4b206 100644 --- a/apps/dokploy/__test__/settings/request-log-authorization.test.ts +++ b/apps/dokploy/__test__/settings/request-log-authorization.test.ts @@ -90,6 +90,10 @@ const mocks = vi.hoisted(() => ({ vi.mock("@dokploy/server", () => ({ CLEANUP_CRON_JOB: "0 0 * * *", DEFAULT_UPDATE_DATA: {}, + DOKPLOY_KEEP_IMAGES_ENV: "DOKPLOY_KEEP_IMAGES", + DOKPLOY_KEEP_IMAGES_MAX: 5, + DOKPLOY_KEEP_IMAGES_MIN: 3, + getDokployImageKeepCount: vi.fn(), IS_CLOUD: false, checkGPUStatus: mocks.checkGPUStatus, checkPortInUse: mocks.checkPortInUse, diff --git a/apps/dokploy/components/dashboard/settings/web-server/toggle-image-cleanup.tsx b/apps/dokploy/components/dashboard/settings/web-server/toggle-image-cleanup.tsx new file mode 100644 index 00000000000..8a20794bb66 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/web-server/toggle-image-cleanup.tsx @@ -0,0 +1,64 @@ +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; + +const IMAGE_KEEP_OPTIONS = [3, 4, 5]; +const DEFAULT_IMAGE_KEEP_COUNT = 3; + +interface Props { + keepImages: number | null; + onChange: (keepImages: number | null) => void; + disabled?: boolean; +} + +export const ToggleImageCleanup = ({ + keepImages, + onChange, + disabled, +}: Props) => { + const enabled = keepImages !== null; + + return ( +
+
+ + onChange(checked ? DEFAULT_IMAGE_KEEP_COUNT : null) + } + id="imageCleanupToggle" + disabled={disabled} + /> + + +
+

+ Saved with the next update. After restarting, Dokploy deletes older + images and the stopped containers that keep them. +

+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/web-server/update-server.tsx b/apps/dokploy/components/dashboard/settings/web-server/update-server.tsx index fe9ebb207b3..9cce472ebba 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/update-server.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/update-server.tsx @@ -28,6 +28,7 @@ import { } from "@/components/ui/tooltip"; import { api } from "@/utils/api"; import { ToggleAutoCheckUpdates } from "./toggle-auto-check-updates"; +import { ToggleImageCleanup } from "./toggle-image-cleanup"; import { UpdateWebServer } from "./update-webserver"; interface Props { @@ -58,6 +59,12 @@ export const UpdateServer = ({ updateData?.updateSource ?? "official", ); const [isOpenInternal, setIsOpenInternal] = useState(false); + const { data: savedKeepImages } = + api.settings.getDokployImageKeepCount.useQuery(); + const [keepImagesChoice, setKeepImagesChoice] = useState(); + // Stays undefined until the saved value loads, so the update leaves it as is. + const keepImages = + keepImagesChoice !== undefined ? keepImagesChoice : savedKeepImages; const isAgentHitsUpdate = updateSource === "agenthits" || dokployVersionData?.isFork; @@ -291,8 +298,13 @@ export const UpdateServer = ({ )} -
+
+
@@ -301,7 +313,10 @@ export const UpdateServer = ({ Cancel {isUpdateAvailable ? ( - + ) : ( + + +
+

Dokploy images on this server

+

+ {keepImages === null + ? "Cleanup is off: every image stays." + : `The newest ${keepImages} stay${pendingVersion ? ", counting the new build" : ""}. Red ones are deleted after the update.`} +

+
+ + {isLoading && ( +
+ + Reading Docker images... +
+ )} + + {plan && plan.rows.length === 0 && ( +

No Dokploy images found.

+ )} + + {plan && plan.rows.length > 0 && ( +
    + {plan.rows.map((row, index) => { + const badge = actionBadges[row.action]; + const image = row.image; + return ( +
  • + + {index + 1} + +
    +
    + + {row.version} + + {badge.label} + {image?.isCurrent && ( + Running now + )} +
    +
    + {image ? ( + <> + {format( + new Date(image.createdAt), + "dd.MM.yyyy HH:mm", + )}{" "} + ( + {formatDistanceToNow(new Date(image.createdAt), { + addSuffix: true, + })} + ) + {image.officialVersion && + ` · ${image.officialVersion}`} + + ) : ( + "Downloaded during the update" + )} +
    + {row.action === "remove" && + !!image?.dokployTaskContainers && ( +
    + + {image.dokployTaskContainers} stopped Dokploy{" "} + {image.dokployTaskContainers === 1 + ? "container" + : "containers"} +
    + )} + {row.action === "blocked" && ( +
    + Used by a container, so it is not deleted +
    + )} +
    + {image && ( +
    +
    + {formatImageSize(image.sizeBytes)} +
    + {image.uniqueSizeBytes !== null && ( +
    + frees {formatImageSize(image.uniqueSizeBytes)} +
    + )} +
    + )} +
  • + ); + })} +
+ )} + + {plan && keepImages !== null && ( +
+ + {plan.removeCount > 0 + ? `Deletes ${plan.removeCount} ${plan.removeCount === 1 ? "image" : "images"}, frees about ${formatImageSize(plan.freedBytes)}` + : "Nothing to delete"} +
+ )} +
+ + ); +}; diff --git a/apps/dokploy/components/dashboard/settings/web-server/toggle-image-cleanup.tsx b/apps/dokploy/components/dashboard/settings/web-server/toggle-image-cleanup.tsx index 8a20794bb66..3edf1a3b6f2 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/toggle-image-cleanup.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/toggle-image-cleanup.tsx @@ -7,6 +7,7 @@ import { SelectValue, } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; +import { DokployImagesInfo } from "./dokploy-images-info"; const IMAGE_KEEP_OPTIONS = [3, 4, 5]; const DEFAULT_IMAGE_KEEP_COUNT = 3; @@ -15,12 +16,16 @@ interface Props { keepImages: number | null; onChange: (keepImages: number | null) => void; disabled?: boolean; + pendingVersion: string | null; + loadImages: boolean; } export const ToggleImageCleanup = ({ keepImages, onChange, disabled, + pendingVersion, + loadImages, }: Props) => { const enabled = keepImages !== null; @@ -36,7 +41,7 @@ export const ToggleImageCleanup = ({ disabled={disabled} />
-

- Saved with the next update. After restarting, Dokploy deletes older - images and the stopped containers that keep them. -

+
+

+ Saved with the next update. After restarting, Dokploy deletes older + images and the stopped containers that keep them. +

+ +
); }; diff --git a/apps/dokploy/components/dashboard/settings/web-server/update-server.tsx b/apps/dokploy/components/dashboard/settings/web-server/update-server.tsx index 9cce472ebba..be1bb5012a1 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/update-server.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/update-server.tsx @@ -304,6 +304,10 @@ export const UpdateServer = ({ keepImages={keepImages ?? null} onChange={setKeepImagesChoice} disabled={isPending || savedKeepImages === undefined} + pendingVersion={ + isUpdateAvailable && latestVersion ? latestVersion : null + } + loadImages={!!isOpen} /> diff --git a/apps/dokploy/server/api/routers/settings.ts b/apps/dokploy/server/api/routers/settings.ts index a89bb45300f..b7f957e5768 100644 --- a/apps/dokploy/server/api/routers/settings.ts +++ b/apps/dokploy/server/api/routers/settings.ts @@ -21,6 +21,7 @@ import { getAgentHitsUpdateCommand, getDockerDiskUsage, getDokployImageKeepCount, + getDokployImages, getDokployImageTag, getDokployVersionData, getLogCleanupStatus, @@ -702,6 +703,12 @@ export const settingsRouter = createTRPCRouter({ getDokployImageKeepCount: adminProcedure.query(() => { return getDokployImageKeepCount(); }), + getDokployImages: adminProcedure.query(async () => { + if (IS_CLOUD) { + return []; + } + return await getDokployImages(); + }), readDirectories: protectedProcedure .input(apiServerSchema) .query(async ({ ctx, input }) => { diff --git a/packages/server/src/services/settings.ts b/packages/server/src/services/settings.ts index 578dbe0cfc8..d90df5e1870 100644 --- a/packages/server/src/services/settings.ts +++ b/packages/server/src/services/settings.ts @@ -341,6 +341,140 @@ export const cleanupOldDokployImages = async () => { } }; +export interface DokployImageInfo { + id: string; + tags: string[]; + createdAt: string; + sizeBytes: number; + /** Bytes not shared with other images, i.e. what deleting it frees. */ + uniqueSizeBytes: number | null; + forkVersion: string | null; + officialVersion: string | null; + isCurrent: boolean; + /** Exited task containers of the dokploy service; the cleanup removes them. */ + dokployTaskContainers: number; + /** Containers of anything else; they keep the image from being removed. */ + otherContainers: number; +} + +// `docker system df` prints sizes with decimal units (go-units HumanSize). +const parseDockerHumanSize = (size: string | undefined) => { + const match = size?.trim().match(/^([\d.]+)\s*([kKMGTP]?B)$/); + if (!match) { + return null; + } + const units: Record = { + B: 1, + KB: 1e3, + MB: 1e6, + GB: 1e9, + TB: 1e12, + PB: 1e15, + }; + return Math.round( + Number.parseFloat(match[1] as string) * + (units[(match[2] as string).toUpperCase()] ?? 0), + ); +}; + +const getDockerUniqueImageSizes = async () => { + const sizes = new Map(); + try { + const { stdout } = await execAsync( + "docker system df -v --format '{{json .}}'", + ); + const data = JSON.parse(stdout) as { + Images?: { ID?: string; UniqueSize?: string }[]; + }; + for (const image of data.Images ?? []) { + const size = parseDockerHumanSize(image.UniqueSize); + if (image.ID && size !== null) { + sizes.set(image.ID, size); + } + } + } catch (error) { + console.error("Could not read Docker image unique sizes", error); + } + return sizes; +}; + +/** Lists Dokploy web server images on this host, newest first. */ +export const getDokployImages = async (): Promise => { + const filters = getDokployImageRepositories() + .map((repository) => `--filter ${quoteShellArg(`reference=${repository}`)}`) + .join(" "); + const { stdout: idsOutput } = await execAsync( + `docker image ls -q --no-trunc ${filters} | sort -u`, + ); + const ids = idsOutput.split("\n").filter(Boolean); + if (ids.length === 0) { + return []; + } + + const [{ stdout: imagesOutput }, { stdout: containersOutput }, uniqueSizes] = + await Promise.all([ + execAsync( + `docker image inspect --format '{"id":{{json .Id}},"createdAt":{{json .Created}},"size":{{json .Size}},"tags":{{json .RepoTags}},"config":{{json .Config}}}' ${ids.join(" ")}`, + ), + execAsync( + `ids=$(docker ps -aq); if [ -n "$ids" ]; then docker inspect --format '{"image":{{json .Image}},"running":{{json .State.Running}},"service":{{json (index .Config.Labels "com.docker.swarm.service.name")}}}' $ids; fi`, + ), + getDockerUniqueImageSizes(), + ]); + + const containers = containersOutput + .split("\n") + .filter(Boolean) + .map((line) => { + const container = JSON.parse(line) as { + image: string; + running: boolean; + service: string | null; + }; + return { + image: container.image, + running: container.running, + isDokployTask: container.service === "dokploy", + }; + }); + + return imagesOutput + .split("\n") + .filter(Boolean) + .map((line) => { + const { id, createdAt, size, tags, config } = JSON.parse(line) as { + id: string; + createdAt: string; + size: number; + tags: string[] | null; + config: { Env?: string[] | null } | null; + }; + const env = config?.Env ?? []; + const imageContainers = containers.filter( + (container) => container.image === id, + ); + return { + id, + tags: tags ?? [], + createdAt, + sizeBytes: size, + uniqueSizeBytes: uniqueSizes.get(id) ?? null, + forkVersion: findEnvValue(env, "DOKPLOY_FORK_VERSION") ?? null, + officialVersion: findEnvValue(env, "DOKPLOY_OFFICIAL_VERSION") ?? null, + isCurrent: imageContainers.some( + (container) => container.isDokployTask && container.running, + ), + dokployTaskContainers: imageContainers.filter( + (container) => container.isDokployTask && !container.running, + ).length, + otherContainers: imageContainers.filter( + (container) => !container.isDokployTask, + ).length, + }; + }) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); +}; + export const getAgentHitsUpdateCommand = ( currentVersion: string, forkVersion?: string | null, From 006c54a7fb35a2e7605f96f832a6be19e868e853 Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Wed, 7 Oct 2026 06:58:52 -0400 Subject: [PATCH 3/3] fix: classify the Dokploy image queries for super sessions settings.getDokployImageKeepCount and settings.getDokployImages only read Docker state, so they are "read" procedures. Co-Authored-By: Claude Opus 5.5 --- apps/dokploy/server/api/utils/procedure-access.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/dokploy/server/api/utils/procedure-access.ts b/apps/dokploy/server/api/utils/procedure-access.ts index cb73482212e..8a945508333 100644 --- a/apps/dokploy/server/api/utils/procedure-access.ts +++ b/apps/dokploy/server/api/utils/procedure-access.ts @@ -591,6 +591,8 @@ export const PROCEDURE_ACCESS: Record = { "settings.cleanUnusedVolumes": "dangerous", "settings.getDockerDiskUsage": "read", "settings.getDokployCloudIps": "read", + "settings.getDokployImageKeepCount": "read", + "settings.getDokployImages": "read", "settings.getDokployVersion": "read", "settings.getDokployVersionData": "read", "settings.getIp": "read",