From 8462d0d3385c7ee8570c739eeeefc7dd75ad7aca Mon Sep 17 00:00:00 2001 From: Brennan Neoh <497569+brennanneoh@users.noreply.github.com> Date: Mon, 10 Aug 2026 09:29:53 +0800 Subject: [PATCH 1/5] fix: clear old deployments by id, not appName folder Old code rm -rf'd a logs folder keyed on appName/serverId, which broke when appName didn't match the actual deployment records. Now looks up deployments by applicationId/composeId and removes all but the most recent successful one via removeDeployment. --- .../dokploy/server/api/routers/application.ts | 2 +- apps/dokploy/server/api/routers/compose.ts | 2 +- packages/server/src/services/deployment.ts | 24 ++++++++++--------- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts index b6e2a3ee4..835117233 100644 --- a/apps/dokploy/server/api/routers/application.ts +++ b/apps/dokploy/server/api/routers/application.ts @@ -756,7 +756,7 @@ export const applicationRouter = createTRPCRouter({ deployment: ["create"], }); const application = await findApplicationById(input.applicationId); - await clearOldDeployments(application.appName, application.serverId); + await clearOldDeployments(application.applicationId, "application"); await audit(ctx, { action: "delete", resourceType: "application", diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts index 50614c7ef..954cf1840 100644 --- a/apps/dokploy/server/api/routers/compose.ts +++ b/apps/dokploy/server/api/routers/compose.ts @@ -291,7 +291,7 @@ export const composeRouter = createTRPCRouter({ deployment: ["create"], }); const compose = await findComposeById(input.composeId); - await clearOldDeployments(compose.appName, compose.serverId); + await clearOldDeployments(compose.composeId, "compose"); await audit(ctx, { action: "update", resourceType: "compose", diff --git a/packages/server/src/services/deployment.ts b/packages/server/src/services/deployment.ts index b78e6c203..cfa2f1d92 100644 --- a/packages/server/src/services/deployment.ts +++ b/packages/server/src/services/deployment.ts @@ -1045,17 +1045,19 @@ export const findAllDeploymentsByServerId = async (serverId: string) => { }; export const clearOldDeployments = async ( - appName: string, - serverId: string | null, + id: string, + type: "application" | "compose", ) => { - const { LOGS_PATH } = paths(!!serverId); - const folder = path.join(LOGS_PATH, appName); - const command = ` - rm -rf ${folder}; - `; - if (serverId) { - await execAsyncRemote(serverId, command); - } else { - await execAsync(command); + const deploymentList = await getDeploymentsByType(id, type); + const mostRecentSuccessful = deploymentList.find( + (deployment) => deployment.status === "done", + ); + const deploymentToKeep = mostRecentSuccessful ?? deploymentList[0]; + + for (const deployment of deploymentList) { + if (deployment.deploymentId === deploymentToKeep?.deploymentId) { + continue; + } + await removeDeployment(deployment.deploymentId); } }; From 9a6644bdfbeb737d087027ebb4feda4968b5f2f6 Mon Sep 17 00:00:00 2001 From: Brennan Neoh <497569+brennanneoh@users.noreply.github.com> Date: Mon, 10 Aug 2026 09:49:53 +0800 Subject: [PATCH 2/5] test: cover clearOldDeployments keep-newest-success logic No existing coverage for the appName->id refactor. Verifies the most recent successful deployment is kept, older ones deleted, and the newest-only fallback when nothing succeeded. --- .../deploy/clear-old-deployments.test.ts | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 apps/dokploy/__test__/deploy/clear-old-deployments.test.ts diff --git a/apps/dokploy/__test__/deploy/clear-old-deployments.test.ts b/apps/dokploy/__test__/deploy/clear-old-deployments.test.ts new file mode 100644 index 000000000..acf0fbbc9 --- /dev/null +++ b/apps/dokploy/__test__/deploy/clear-old-deployments.test.ts @@ -0,0 +1,74 @@ +import { clearOldDeployments } from "@dokploy/server"; +import { db } from "@dokploy/server/db"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }), + execAsyncRemote: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }), +})); + +const makeDeployment = (overrides: Record) => ({ + deploymentId: "", + logPath: ".", + serverId: null, + status: "done", + rollbackId: null, + createdAt: new Date().toISOString(), + ...overrides, +}); + +describe("clearOldDeployments", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(db.delete).mockReturnValue({ + where: () => ({ + returning: () => Promise.resolve([]), + }), + } as any); + }); + + it("keeps the most recent successful deployment and deletes the rest", async () => { + const deploymentList = [ + makeDeployment({ deploymentId: "failed-1", status: "error" }), + makeDeployment({ deploymentId: "success-1", status: "done" }), + makeDeployment({ deploymentId: "success-2", status: "done" }), + ]; + vi.mocked(db.query.deployments.findMany).mockResolvedValue( + deploymentList as any, + ); + + await clearOldDeployments("app-1", "application"); + + // deploymentList order is newest-first (query orders by desc createdAt); + // first "done" entry (success-1) is kept, failed-1 and success-2 are deleted. + expect(db.delete).toHaveBeenCalledTimes(2); + }); + + it("falls back to the newest deployment when none succeeded", async () => { + const deploymentList = [ + makeDeployment({ deploymentId: "failed-1", status: "error" }), + makeDeployment({ deploymentId: "failed-2", status: "error" }), + ]; + vi.mocked(db.query.deployments.findMany).mockResolvedValue( + deploymentList as any, + ); + + await clearOldDeployments("app-1", "application"); + + // failed-1 (newest) is kept as fallback, only failed-2 is deleted. + expect(db.delete).toHaveBeenCalledTimes(1); + }); + + it("removes nothing when there is a single deployment", async () => { + const deploymentList = [ + makeDeployment({ deploymentId: "only-1", status: "done" }), + ]; + vi.mocked(db.query.deployments.findMany).mockResolvedValue( + deploymentList as any, + ); + + await clearOldDeployments("compose-1", "compose"); + + expect(db.delete).not.toHaveBeenCalled(); + }); +}); From 69113a7655d14bde27e89b19acaf9b27e89b7e05 Mon Sep 17 00:00:00 2001 From: Brennan Neoh <497569+brennanneoh@users.noreply.github.com> Date: Mon, 10 Aug 2026 09:53:32 +0800 Subject: [PATCH 3/5] fix: never delete a running deployment in clearOldDeployments Kept-vs-delete logic only anchored on the newest "done" deployment, so a build actively running when the user clicked Clear Deployments could get its DB row and log file deleted mid-build. Exclude "running" deployments from the deletion candidates entirely. --- .../deploy/clear-old-deployments.test.ts | 32 +++++++++++++++++++ packages/server/src/services/deployment.ts | 9 ++++-- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/apps/dokploy/__test__/deploy/clear-old-deployments.test.ts b/apps/dokploy/__test__/deploy/clear-old-deployments.test.ts index acf0fbbc9..11fe5ae4a 100644 --- a/apps/dokploy/__test__/deploy/clear-old-deployments.test.ts +++ b/apps/dokploy/__test__/deploy/clear-old-deployments.test.ts @@ -71,4 +71,36 @@ describe("clearOldDeployments", () => { expect(db.delete).not.toHaveBeenCalled(); }); + + it("never deletes the currently-running deployment, even if newer than the last success", async () => { + const deploymentList = [ + makeDeployment({ deploymentId: "running-1", status: "running" }), + makeDeployment({ deploymentId: "success-1", status: "done" }), + ]; + vi.mocked(db.query.deployments.findMany).mockResolvedValue( + deploymentList as any, + ); + + await clearOldDeployments("app-1", "application"); + + // running-1 is excluded from deletion candidates entirely, and + // success-1 is kept as the most recent successful deployment. + expect(db.delete).not.toHaveBeenCalled(); + }); + + it("deletes older non-running deployments while a build is in progress", async () => { + const deploymentList = [ + makeDeployment({ deploymentId: "running-1", status: "running" }), + makeDeployment({ deploymentId: "success-1", status: "done" }), + makeDeployment({ deploymentId: "failed-1", status: "error" }), + ]; + vi.mocked(db.query.deployments.findMany).mockResolvedValue( + deploymentList as any, + ); + + await clearOldDeployments("app-1", "application"); + + // running-1 is protected; only failed-1 (older than the kept success) is removed. + expect(db.delete).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/server/src/services/deployment.ts b/packages/server/src/services/deployment.ts index cfa2f1d92..df927bd3b 100644 --- a/packages/server/src/services/deployment.ts +++ b/packages/server/src/services/deployment.ts @@ -1049,12 +1049,15 @@ export const clearOldDeployments = async ( type: "application" | "compose", ) => { const deploymentList = await getDeploymentsByType(id, type); - const mostRecentSuccessful = deploymentList.find( + const deletable = deploymentList.filter( + (deployment) => deployment.status !== "running", + ); + const mostRecentSuccessful = deletable.find( (deployment) => deployment.status === "done", ); - const deploymentToKeep = mostRecentSuccessful ?? deploymentList[0]; + const deploymentToKeep = mostRecentSuccessful ?? deletable[0]; - for (const deployment of deploymentList) { + for (const deployment of deletable) { if (deployment.deploymentId === deploymentToKeep?.deploymentId) { continue; } From e9f8da4d4d27824842021e09d8c27b2c4d0c1551 Mon Sep 17 00:00:00 2001 From: Brennan Neoh <497569+brennanneoh@users.noreply.github.com> Date: Mon, 10 Aug 2026 09:55:05 +0800 Subject: [PATCH 4/5] fix: isolate per-item failures and drop unused join in clearOldDeployments - Wrap each removeDeployment call in try/catch (matching removeLastTenDeployments' pattern) so one bad row doesn't abort cleanup of the rest. - Query deployments directly instead of via getDeploymentsByType, which eager-loads a rollback relation clearOldDeployments never uses. --- .../deploy/clear-old-deployments.test.ts | 27 +++++++++++++++++++ packages/server/src/services/deployment.ts | 14 ++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/apps/dokploy/__test__/deploy/clear-old-deployments.test.ts b/apps/dokploy/__test__/deploy/clear-old-deployments.test.ts index 11fe5ae4a..977870116 100644 --- a/apps/dokploy/__test__/deploy/clear-old-deployments.test.ts +++ b/apps/dokploy/__test__/deploy/clear-old-deployments.test.ts @@ -103,4 +103,31 @@ describe("clearOldDeployments", () => { // running-1 is protected; only failed-1 (older than the kept success) is removed. expect(db.delete).toHaveBeenCalledTimes(1); }); + + it("continues cleaning up remaining deployments after one removal fails", async () => { + const deploymentList = [ + makeDeployment({ deploymentId: "success-1", status: "done" }), + makeDeployment({ deploymentId: "failed-1", status: "error" }), + makeDeployment({ deploymentId: "failed-2", status: "error" }), + ]; + vi.mocked(db.query.deployments.findMany).mockResolvedValue( + deploymentList as any, + ); + vi.mocked(db.delete) + .mockImplementationOnce(() => { + throw new Error("db unavailable"); + }) + .mockReturnValue({ + where: () => ({ + returning: () => Promise.resolve([]), + }), + } as any); + + await expect( + clearOldDeployments("app-1", "application"), + ).resolves.not.toThrow(); + + // failed-1's removal throws but doesn't stop failed-2 from being attempted. + expect(db.delete).toHaveBeenCalledTimes(2); + }); }); diff --git a/packages/server/src/services/deployment.ts b/packages/server/src/services/deployment.ts index df927bd3b..dd85a2a0b 100644 --- a/packages/server/src/services/deployment.ts +++ b/packages/server/src/services/deployment.ts @@ -1048,7 +1048,10 @@ export const clearOldDeployments = async ( id: string, type: "application" | "compose", ) => { - const deploymentList = await getDeploymentsByType(id, type); + const deploymentList = await db.query.deployments.findMany({ + where: eq(deployments[`${type}Id`], id), + orderBy: desc(deployments.createdAt), + }); const deletable = deploymentList.filter( (deployment) => deployment.status !== "running", ); @@ -1061,6 +1064,13 @@ export const clearOldDeployments = async ( if (deployment.deploymentId === deploymentToKeep?.deploymentId) { continue; } - await removeDeployment(deployment.deploymentId); + try { + await removeDeployment(deployment.deploymentId); + } catch (err) { + console.error( + `Failed to remove deployment ${deployment.deploymentId} during cleanup:`, + err, + ); + } } }; From 6485d54cff80592b0c2a34a2706766a13ae49bb0 Mon Sep 17 00:00:00 2001 From: Brennan Neoh <497569+brennanneoh@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:08:47 +0800 Subject: [PATCH 5/5] fix: clean up rollback images and use correct host for remote logs Greptile flagged two issues in clearOldDeployments: - removeDeployment() alone leaves a deleted deployment's rollback Docker image orphaned (cascade only drops the DB row). Call removeRollbackById() first, mirroring removeLastTenDeployments. - deployment.serverId is rarely populated on the row itself (only buildServerId is set at creation time), so removeDeployment's host check silently no-ops for logs that actually live on a remote server. Resolve the real host once via the owning application/compose and batch the remote rm through it, same pattern already used by removeLastTenDeployments. --- .../deploy/clear-old-deployments.test.ts | 69 +++++++++++++++++++ packages/server/src/services/deployment.ts | 57 ++++++++++++--- 2 files changed, 116 insertions(+), 10 deletions(-) diff --git a/apps/dokploy/__test__/deploy/clear-old-deployments.test.ts b/apps/dokploy/__test__/deploy/clear-old-deployments.test.ts index 977870116..51aafe8a0 100644 --- a/apps/dokploy/__test__/deploy/clear-old-deployments.test.ts +++ b/apps/dokploy/__test__/deploy/clear-old-deployments.test.ts @@ -1,5 +1,6 @@ import { clearOldDeployments } from "@dokploy/server"; import { db } from "@dokploy/server/db"; +import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("@dokploy/server/utils/process/execAsync", () => ({ @@ -7,6 +8,22 @@ vi.mock("@dokploy/server/utils/process/execAsync", () => ({ execAsyncRemote: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }), })); +vi.mock("@dokploy/server/services/application", () => ({ + findApplicationById: vi.fn(), +})); + +vi.mock("@dokploy/server/services/compose", () => ({ + findComposeById: vi.fn(), +})); + +vi.mock("@dokploy/server/services/rollbacks", () => ({ + removeRollbackById: vi.fn().mockResolvedValue(undefined), +})); + +import { findApplicationById } from "@dokploy/server/services/application"; +import { findComposeById } from "@dokploy/server/services/compose"; +import { removeRollbackById } from "@dokploy/server/services/rollbacks"; + const makeDeployment = (overrides: Record) => ({ deploymentId: "", logPath: ".", @@ -25,6 +42,12 @@ describe("clearOldDeployments", () => { returning: () => Promise.resolve([]), }), } as any); + vi.mocked(findApplicationById).mockResolvedValue({ + serverId: null, + } as any); + vi.mocked(findComposeById).mockResolvedValue({ + serverId: null, + } as any); }); it("keeps the most recent successful deployment and deletes the rest", async () => { @@ -130,4 +153,50 @@ describe("clearOldDeployments", () => { // failed-1's removal throws but doesn't stop failed-2 from being attempted. expect(db.delete).toHaveBeenCalledTimes(2); }); + + it("removes the rollback (and its image) before deleting a deployment that has one", async () => { + const deploymentList = [ + makeDeployment({ deploymentId: "success-1", status: "done" }), + makeDeployment({ + deploymentId: "failed-1", + status: "error", + rollbackId: "rollback-1", + }), + ]; + vi.mocked(db.query.deployments.findMany).mockResolvedValue( + deploymentList as any, + ); + + await clearOldDeployments("app-1", "application"); + + expect(removeRollbackById).toHaveBeenCalledWith("rollback-1"); + expect(db.delete).toHaveBeenCalledTimes(1); + }); + + it("removes remote logs via execAsyncRemote using the application's server, not local execAsync", async () => { + vi.mocked(findApplicationById).mockResolvedValue({ + serverId: "server-1", + } as any); + const deploymentList = [ + makeDeployment({ deploymentId: "success-1", status: "done" }), + makeDeployment({ + deploymentId: "failed-1", + status: "error", + logPath: "/logs/failed-1.log", + serverId: null, + }), + ]; + vi.mocked(db.query.deployments.findMany).mockResolvedValue( + deploymentList as any, + ); + + await clearOldDeployments("app-1", "application"); + + // the batched remote cleanup uses the application's server, independent + // of the (often-unset) per-deployment serverId column. + expect(execAsyncRemote).toHaveBeenCalledWith( + "server-1", + expect.stringContaining("rm -rf /logs/failed-1.log;"), + ); + }); }); diff --git a/packages/server/src/services/deployment.ts b/packages/server/src/services/deployment.ts index dd85a2a0b..9716c2001 100644 --- a/packages/server/src/services/deployment.ts +++ b/packages/server/src/services/deployment.ts @@ -1048,6 +1048,11 @@ export const clearOldDeployments = async ( id: string, type: "application" | "compose", ) => { + const serverId = + type === "application" + ? (await findApplicationById(id)).serverId + : (await findComposeById(id)).serverId; + const deploymentList = await db.query.deployments.findMany({ where: eq(deployments[`${type}Id`], id), orderBy: desc(deployments.createdAt), @@ -1060,17 +1065,49 @@ export const clearOldDeployments = async ( ); const deploymentToKeep = mostRecentSuccessful ?? deletable[0]; - for (const deployment of deletable) { - if (deployment.deploymentId === deploymentToKeep?.deploymentId) { - continue; + const toRemove = deletable.filter( + (deployment) => deployment.deploymentId !== deploymentToKeep?.deploymentId, + ); + + if (serverId) { + let command = ""; + for (const deployment of toRemove) { + try { + if (deployment.rollbackId) { + await removeRollbackById(deployment.rollbackId); + } + const logPath = path.join(deployment.logPath); + if (logPath && logPath !== ".") { + command += `rm -rf ${logPath};`; + } + await removeDeployment(deployment.deploymentId); + } catch (err) { + console.error( + `Failed to remove deployment ${deployment.deploymentId} during cleanup:`, + err, + ); + } } - try { - await removeDeployment(deployment.deploymentId); - } catch (err) { - console.error( - `Failed to remove deployment ${deployment.deploymentId} during cleanup:`, - err, - ); + if (command) { + await execAsyncRemote(serverId, command); + } + } else { + for (const deployment of toRemove) { + try { + if (deployment.rollbackId) { + await removeRollbackById(deployment.rollbackId); + } + const logPath = path.join(deployment.logPath); + if (logPath && logPath !== "." && existsSync(logPath)) { + await fsPromises.unlink(logPath); + } + await removeDeployment(deployment.deploymentId); + } catch (err) { + console.error( + `Failed to remove deployment ${deployment.deploymentId} during cleanup:`, + err, + ); + } } } };