From 0ff6768039d496e0c3ecc9327a4ea42d282291e3 Mon Sep 17 00:00:00 2001 From: nielsklumper Date: Wed, 5 Aug 2026 13:26:35 +0200 Subject: [PATCH 1/8] fix(previews): use configured build server and registry --- packages/server/src/services/application.ts | 23 +++++++++++-------- packages/server/src/services/deployment.ts | 20 ++++++++++------ .../server/src/services/preview-deployment.ts | 13 +++++++---- 3 files changed, 34 insertions(+), 22 deletions(-) diff --git a/packages/server/src/services/application.ts b/packages/server/src/services/application.ts index 0f2e5a4fc..4ff5a8286 100644 --- a/packages/server/src/services/application.ts +++ b/packages/server/src/services/application.ts @@ -429,22 +429,27 @@ export const deployPreviewApplication = async ({ application.buildArgs = `${application.previewBuildArgs}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`; application.buildSecrets = `${application.previewBuildSecrets}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`; application.rollbackActive = false; - application.buildRegistry = null; application.rollbackRegistry = null; - application.registry = null; + + const buildServerId = application.buildServerId || application.serverId; + + const buildApplication = { + ...application, + serverId: buildServerId, + }; let command = "set -e;"; if (application.sourceType === "github") { command += await cloneGithubRepository({ - ...application, + ...buildApplication, appName: previewDeployment.appName, branch: previewDeployment.branch, }); command += await getBuildCommand(application); const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`; - if (application.serverId) { - await execAsyncRemote(application.serverId, commandWithLog); + if (buildServerId) { + await execAsyncRemote(buildServerId, commandWithLog); } else { await execAsync(commandWithLog); } @@ -548,17 +553,15 @@ export const rebuildPreviewApplication = async ({ application.buildArgs = `${application.previewBuildArgs}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`; application.buildSecrets = `${application.previewBuildSecrets}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`; application.rollbackActive = false; - application.buildRegistry = null; application.rollbackRegistry = null; - application.registry = null; - const serverId = application.serverId; + const buildServerId = application.buildServerId || application.serverId; let command = "set -e;"; // Only rebuild, don't clone repository command += await getBuildCommand(application); const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`; - if (serverId) { - await execAsyncRemote(serverId, commandWithLog); + if (buildServerId) { + await execAsyncRemote(buildServerId, commandWithLog); } else { await execAsync(commandWithLog); } diff --git a/packages/server/src/services/deployment.ts b/packages/server/src/services/deployment.ts index b78e6c203..5df11afca 100644 --- a/packages/server/src/services/deployment.ts +++ b/packages/server/src/services/deployment.ts @@ -210,26 +210,29 @@ export const createDeploymentPreview = async ( const previewDeployment = await findPreviewDeploymentById( deployment.previewDeploymentId, ); + + const buildServerId = previewDeployment.application?.buildServerId; + + const serverId = buildServerId || previewDeployment.application?.serverId; + await removeLastTenDeployments( deployment.previewDeploymentId, "previewDeployment", - previewDeployment?.application?.serverId, + serverId, ); try { const appName = `${previewDeployment.appName}`; - const { LOGS_PATH } = paths(!!previewDeployment?.application?.serverId); + const { LOGS_PATH } = paths(!!serverId); const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss"); const fileName = `${appName}-${formattedDateTime}.log`; const logFilePath = path.join(LOGS_PATH, appName, fileName); - if (previewDeployment?.application?.serverId) { - const server = await findServerById( - previewDeployment?.application?.serverId, - ); + if (serverId) { + const server = await findServerById(serverId); const command = ` mkdir -p ${LOGS_PATH}/${appName}; - echo "Initializing deployment" >> ${logFilePath}; + echo "Initializing deployment" >> ${logFilePath}; `; await execAsyncRemote(server.serverId, command); @@ -249,6 +252,9 @@ export const createDeploymentPreview = async ( description: deployment.description || "", previewDeploymentId: deployment.previewDeploymentId, startedAt: new Date().toISOString(), + ...(buildServerId && { + buildServerId, + }), }) .returning(); if (deploymentCreate.length === 0 || !deploymentCreate[0]) { diff --git a/packages/server/src/services/preview-deployment.ts b/packages/server/src/services/preview-deployment.ts index cc3d3dc02..76374bf5b 100644 --- a/packages/server/src/services/preview-deployment.ts +++ b/packages/server/src/services/preview-deployment.ts @@ -33,6 +33,7 @@ export const findPreviewDeploymentById = async ( columns: { applicationId: true, serverId: true, + buildServerId: true, }, }, }, @@ -55,18 +56,20 @@ export const removePreviewDeployment = async (previewDeploymentId: string) => { ); application.appName = previewDeployment.appName; + + const buildServerId = application.buildServerId || application.serverId; + const cleanupOperations = [ async () => - await removeService(application?.appName, application?.serverId), + await removeService(application.appName, application.serverId), async () => await removeDeploymentsByPreviewDeploymentId( previewDeployment, - application?.serverId, + buildServerId, ), + async () => await removeDirectoryCode(application.appName, buildServerId), async () => - await removeDirectoryCode(application?.appName, application?.serverId), - async () => - await removeTraefikConfig(application?.appName, application?.serverId), + await removeTraefikConfig(application.appName, application.serverId), async () => await db .delete(previewDeployments) From 2545d4a4dc24e95a9217ea6a796e097372efdb60 Mon Sep 17 00:00:00 2001 From: nielsklumper Date: Wed, 5 Aug 2026 13:32:09 +0200 Subject: [PATCH 2/8] test(previews): cover build server and registry inheritance --- .../deploy/preview-build-server.test.ts | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 apps/dokploy/__test__/deploy/preview-build-server.test.ts diff --git a/apps/dokploy/__test__/deploy/preview-build-server.test.ts b/apps/dokploy/__test__/deploy/preview-build-server.test.ts new file mode 100644 index 000000000..8fdbfd98c --- /dev/null +++ b/apps/dokploy/__test__/deploy/preview-build-server.test.ts @@ -0,0 +1,217 @@ +import { db } from "@dokploy/server/db"; +import { + deployPreviewApplication, + rebuildPreviewApplication, +} from "@dokploy/server/services/application"; +import * as deploymentService from "@dokploy/server/services/deployment"; +import * as githubService from "@dokploy/server/services/github"; +import * as previewService from "@dokploy/server/services/preview-deployment"; +import * as builders from "@dokploy/server/utils/builders"; +import * as execProcess from "@dokploy/server/utils/process/execAsync"; +import * as githubProvider from "@dokploy/server/utils/providers/github"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + applications: { + findFirst: vi.fn(), + }, + }, + }, +})); + +vi.mock("@dokploy/server/services/deployment", () => ({ + createDeployment: vi.fn(), + createDeploymentPreview: vi.fn(), + updateDeployment: vi.fn(), + updateDeploymentStatus: vi.fn(), +})); + +vi.mock("@dokploy/server/services/preview-deployment", () => ({ + findPreviewDeploymentById: vi.fn(), + updatePreviewDeployment: vi.fn(), +})); + +vi.mock("@dokploy/server/services/github", () => ({ + createPreviewDeploymentComment: vi.fn(), + getIssueComment: vi.fn(), + issueCommentExists: vi.fn(), + updateIssueComment: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/builders", () => ({ + getBuildCommand: vi.fn(), + mechanizeDockerContainer: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: vi.fn(), + execAsyncRemote: vi.fn(), + ExecError: class ExecError extends Error {}, +})); + +vi.mock("@dokploy/server/utils/providers/github", () => ({ + cloneGithubRepository: vi.fn(), +})); + +const createMockApplication = () => ({ + applicationId: "application-id", + name: "Test application", + appName: "test-application", + sourceType: "github" as const, + owner: "Dokploy", + repository: "dokploy", + branch: "canary", + githubId: "github-id", + enableSubmodules: false, + serverId: "deployment-server-id", + buildServerId: "build-server-id", + buildRegistry: { + registryId: "build-registry-id", + }, + registry: null, + rollbackRegistry: null, + rollbackActive: true, + previewEnv: "", + previewBuildArgs: "", + previewBuildSecrets: "", + environment: { + env: "", + projectId: "project-id", + project: { + env: "", + name: "Test project", + organizationId: "organization-id", + }, + }, + domains: [], + mounts: [], + ports: [], + security: [], + redirects: [], +}); + +const createMockPreview = () => ({ + previewDeploymentId: "preview-id", + applicationId: "application-id", + appName: "preview-test-application", + branch: "feature/test-preview", + pullRequestNumber: "123", + pullRequestCommentId: "456", + domain: { + host: "preview.example.com", + https: true, + }, +}); + +describe("preview deployment build server and registry", () => { + beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(db.query.applications.findFirst).mockResolvedValue( + createMockApplication() as any, + ); + + vi.mocked(previewService.findPreviewDeploymentById).mockResolvedValue( + createMockPreview() as any, + ); + + vi.mocked(deploymentService.createDeploymentPreview).mockResolvedValue({ + deploymentId: "deployment-id", + logPath: "/tmp/preview.log", + } as any); + + vi.mocked(githubService.issueCommentExists).mockResolvedValue(true); + vi.mocked(githubService.getIssueComment).mockReturnValue("comment"); + vi.mocked(githubService.updateIssueComment).mockResolvedValue( + undefined as any, + ); + + vi.mocked(githubProvider.cloneGithubRepository).mockResolvedValue( + "git clone command;", + ); + + vi.mocked(builders.getBuildCommand).mockResolvedValue( + "docker build and push command;", + ); + + vi.mocked(builders.mechanizeDockerContainer).mockResolvedValue( + undefined as any, + ); + + vi.mocked(execProcess.execAsyncRemote).mockResolvedValue({ + stdout: "", + stderr: "", + } as any); + }); + + it("builds a new preview on the configured build server and preserves its registry", async () => { + await deployPreviewApplication({ + applicationId: "application-id", + previewDeploymentId: "preview-id", + titleLog: "Preview deployment", + descriptionLog: "", + }); + + expect(githubProvider.cloneGithubRepository).toHaveBeenCalledWith( + expect.objectContaining({ + appName: "preview-test-application", + branch: "feature/test-preview", + serverId: "build-server-id", + }), + ); + + expect(builders.getBuildCommand).toHaveBeenCalledWith( + expect.objectContaining({ + buildServerId: "build-server-id", + buildRegistry: expect.objectContaining({ + registryId: "build-registry-id", + }), + }), + ); + + expect(execProcess.execAsyncRemote).toHaveBeenCalledWith( + "build-server-id", + expect.stringContaining("docker build and push command"), + ); + + expect(builders.mechanizeDockerContainer).toHaveBeenCalledWith( + expect.objectContaining({ + serverId: "deployment-server-id", + buildRegistry: expect.objectContaining({ + registryId: "build-registry-id", + }), + }), + ); + }); + + it("rebuilds a preview on the configured build server and preserves its registry", async () => { + await rebuildPreviewApplication({ + applicationId: "application-id", + previewDeploymentId: "preview-id", + titleLog: "Rebuild preview deployment", + descriptionLog: "", + }); + + expect(builders.getBuildCommand).toHaveBeenCalledWith( + expect.objectContaining({ + buildServerId: "build-server-id", + buildRegistry: expect.objectContaining({ + registryId: "build-registry-id", + }), + }), + ); + + expect(execProcess.execAsyncRemote).toHaveBeenCalledWith( + "build-server-id", + expect.stringContaining("docker build and push command"), + ); + + expect(builders.mechanizeDockerContainer).toHaveBeenCalledWith( + expect.objectContaining({ + serverId: "deployment-server-id", + }), + ); + }); +}); From 2fd1294c1506787eeeebfd80530825719aa72485 Mon Sep 17 00:00:00 2001 From: nielsklumper Date: Wed, 5 Aug 2026 15:28:24 +0200 Subject: [PATCH 3/8] fix(previews): handle build server changes --- packages/server/src/services/application.ts | 36 +++++++++++++++---- packages/server/src/services/deployment.ts | 30 +++++++++++----- .../server/src/services/preview-deployment.ts | 24 +++++++++++-- 3 files changed, 71 insertions(+), 19 deletions(-) diff --git a/packages/server/src/services/application.ts b/packages/server/src/services/application.ts index 4ff5a8286..00bd64c13 100644 --- a/packages/server/src/services/application.ts +++ b/packages/server/src/services/application.ts @@ -445,7 +445,7 @@ export const deployPreviewApplication = async ({ appName: previewDeployment.appName, branch: previewDeployment.branch, }); - command += await getBuildCommand(application); + command += await getBuildCommand(buildApplication); const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`; if (buildServerId) { @@ -499,6 +499,17 @@ export const rebuildPreviewApplication = async ({ const previewDeployment = await findPreviewDeploymentById(previewDeploymentId); + const previousDeployment = previewDeployment.deployments[0]; + + const previousBuildServerId = previousDeployment + ? previousDeployment.buildServerId || application.serverId + : null; + + const buildServerId = application.buildServerId || application.serverId; + + const shouldCloneRepository = + !previousDeployment || previousBuildServerId !== buildServerId; + const deployment = await createDeploymentPreview({ title: titleLog, description: descriptionLog, @@ -555,10 +566,22 @@ export const rebuildPreviewApplication = async ({ application.rollbackActive = false; application.rollbackRegistry = null; - const buildServerId = application.buildServerId || application.serverId; + const buildApplication = { + ...application, + serverId: buildServerId, + }; + let command = "set -e;"; - // Only rebuild, don't clone repository - command += await getBuildCommand(application); + + if (shouldCloneRepository && application.sourceType === "github") { + command += await cloneGithubRepository({ + ...buildApplication, + appName: previewDeployment.appName, + branch: previewDeployment.branch, + }); + } + + command += await getBuildCommand(buildApplication); const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`; if (buildServerId) { await execAsyncRemote(buildServerId, commandWithLog); @@ -591,9 +614,8 @@ export const rebuildPreviewApplication = async ({ } command += `echo "\nError occurred ❌, check the logs for details." >> ${deployment.logPath};`; - const serverId = application.buildServerId || application.serverId; - if (serverId) { - await execAsyncRemote(serverId, command); + if (buildServerId) { + await execAsyncRemote(buildServerId, command); } else { await execAsync(command); } diff --git a/packages/server/src/services/deployment.ts b/packages/server/src/services/deployment.ts index 5df11afca..c5c45b975 100644 --- a/packages/server/src/services/deployment.ts +++ b/packages/server/src/services/deployment.ts @@ -252,8 +252,8 @@ export const createDeploymentPreview = async ( description: deployment.description || "", previewDeploymentId: deployment.previewDeploymentId, startedAt: new Date().toISOString(), - ...(buildServerId && { - buildServerId, + ...(serverId && { + buildServerId: serverId, }), }) .returning(); @@ -750,15 +750,27 @@ const removeLastTenDeployments = async ( export const removeDeploymentsByPreviewDeploymentId = async ( previewDeployment: PreviewDeployment, - serverId: string | null, + serverIds: Array, ) => { const { appName } = previewDeployment; - const { LOGS_PATH } = paths(!!serverId); - const logsPath = path.join(LOGS_PATH, appName); - if (serverId) { - await execAsyncRemote(serverId, `rm -rf ${logsPath}`); - } else { - await removeDirectoryIfExistsContent(logsPath); + const uniqueServerIds = [...new Set(serverIds)]; + + for (const serverId of uniqueServerIds) { + try { + const { LOGS_PATH } = paths(!!serverId); + const logsPath = path.join(LOGS_PATH, appName); + + if (serverId) { + await execAsyncRemote(serverId, `rm -rf ${logsPath}`); + } else { + await removeDirectoryIfExistsContent(logsPath); + } + } catch (error) { + console.error( + `Failed to remove preview deployment logs from ${serverId || "the local server"}:`, + error, + ); + } } await db diff --git a/packages/server/src/services/preview-deployment.ts b/packages/server/src/services/preview-deployment.ts index 76374bf5b..7fbf6bed1 100644 --- a/packages/server/src/services/preview-deployment.ts +++ b/packages/server/src/services/preview-deployment.ts @@ -28,6 +28,12 @@ export const findPreviewDeploymentById = async ( const application = await db.query.previewDeployments.findFirst({ where: eq(previewDeployments.previewDeploymentId, previewDeploymentId), with: { + deployments: { + columns: { + buildServerId: true, + }, + orderBy: desc(deployments.createdAt), + }, domain: true, application: { columns: { @@ -57,7 +63,16 @@ export const removePreviewDeployment = async (previewDeploymentId: string) => { application.appName = previewDeployment.appName; - const buildServerId = application.buildServerId || application.serverId; + const cleanupServerIds = [ + null, + application.serverId, + application.buildServerId, + ...previewDeployment.deployments.map( + (deployment) => deployment.buildServerId, + ), + ]; + + const uniqueCleanupServerIds = [...new Set(cleanupServerIds)]; const cleanupOperations = [ async () => @@ -65,9 +80,12 @@ export const removePreviewDeployment = async (previewDeploymentId: string) => { async () => await removeDeploymentsByPreviewDeploymentId( previewDeployment, - buildServerId, + uniqueCleanupServerIds, ), - async () => await removeDirectoryCode(application.appName, buildServerId), + ...uniqueCleanupServerIds.map( + (serverId) => async () => + await removeDirectoryCode(application.appName, serverId), + ), async () => await removeTraefikConfig(application.appName, application.serverId), async () => From 2fde7668909e09fdd19826869e341f482efc4dd5 Mon Sep 17 00:00:00 2001 From: nielsklumper Date: Wed, 5 Aug 2026 15:39:29 +0200 Subject: [PATCH 4/8] test(previews): cover build server changes and cleanup --- .../deploy/preview-build-server.test.ts | 80 +++++++++++ .../preview-cleanup-build-servers.test.ts | 129 ++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 apps/dokploy/__test__/deploy/preview-cleanup-build-servers.test.ts diff --git a/apps/dokploy/__test__/deploy/preview-build-server.test.ts b/apps/dokploy/__test__/deploy/preview-build-server.test.ts index 8fdbfd98c..4ab4a63d4 100644 --- a/apps/dokploy/__test__/deploy/preview-build-server.test.ts +++ b/apps/dokploy/__test__/deploy/preview-build-server.test.ts @@ -99,6 +99,11 @@ const createMockPreview = () => ({ branch: "feature/test-preview", pullRequestNumber: "123", pullRequestCommentId: "456", + deployments: [ + { + buildServerId: "build-server-id", + }, + ], domain: { host: "preview.example.com", https: true, @@ -164,6 +169,7 @@ describe("preview deployment build server and registry", () => { expect(builders.getBuildCommand).toHaveBeenCalledWith( expect.objectContaining({ + serverId: "build-server-id", buildServerId: "build-server-id", buildRegistry: expect.objectContaining({ registryId: "build-registry-id", @@ -194,8 +200,11 @@ describe("preview deployment build server and registry", () => { descriptionLog: "", }); + expect(githubProvider.cloneGithubRepository).not.toHaveBeenCalled(); + expect(builders.getBuildCommand).toHaveBeenCalledWith( expect.objectContaining({ + serverId: "build-server-id", buildServerId: "build-server-id", buildRegistry: expect.objectContaining({ registryId: "build-registry-id", @@ -214,4 +223,75 @@ describe("preview deployment build server and registry", () => { }), ); }); + + it("reclones a preview on the current build server when its build server changed", async () => { + vi.mocked(previewService.findPreviewDeploymentById).mockResolvedValue({ + ...createMockPreview(), + deployments: [ + { + buildServerId: "previous-build-server-id", + }, + ], + } as any); + + await rebuildPreviewApplication({ + applicationId: "application-id", + previewDeploymentId: "preview-id", + titleLog: "Rebuild preview deployment", + descriptionLog: "", + }); + + expect(githubProvider.cloneGithubRepository).toHaveBeenCalledWith( + expect.objectContaining({ + serverId: "build-server-id", + appName: "preview-test-application", + branch: "feature/test-preview", + }), + ); + + expect(builders.getBuildCommand).toHaveBeenCalledWith( + expect.objectContaining({ + serverId: "build-server-id", + buildRegistry: expect.objectContaining({ + registryId: "build-registry-id", + }), + }), + ); + + expect(execProcess.execAsyncRemote).toHaveBeenCalledWith( + "build-server-id", + expect.stringContaining("git clone command;"), + ); + }); + + it("reclones a legacy preview on the configured build server", async () => { + vi.mocked(previewService.findPreviewDeploymentById).mockResolvedValue({ + ...createMockPreview(), + deployments: [ + { + buildServerId: null, + }, + ], + } as any); + + await rebuildPreviewApplication({ + applicationId: "application-id", + previewDeploymentId: "preview-id", + titleLog: "Rebuild preview deployment", + descriptionLog: "", + }); + + expect(githubProvider.cloneGithubRepository).toHaveBeenCalledWith( + expect.objectContaining({ + serverId: "build-server-id", + appName: "preview-test-application", + branch: "feature/test-preview", + }), + ); + + expect(execProcess.execAsyncRemote).toHaveBeenCalledWith( + "build-server-id", + expect.stringContaining("git clone command;"), + ); + }); }); diff --git a/apps/dokploy/__test__/deploy/preview-cleanup-build-servers.test.ts b/apps/dokploy/__test__/deploy/preview-cleanup-build-servers.test.ts new file mode 100644 index 000000000..273b651d1 --- /dev/null +++ b/apps/dokploy/__test__/deploy/preview-cleanup-build-servers.test.ts @@ -0,0 +1,129 @@ +import { db } from "@dokploy/server/db"; +import * as applicationService from "@dokploy/server/services/application"; +import { removePreviewDeployment } from "@dokploy/server/services/preview-deployment"; +import * as dockerUtils from "@dokploy/server/utils/docker/utils"; +import * as directoryUtils from "@dokploy/server/utils/filesystem/directory"; +import * as execProcess from "@dokploy/server/utils/process/execAsync"; +import * as traefikApplication from "@dokploy/server/utils/traefik/application"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + previewDeployments: { + findFirst: vi.fn(), + }, + }, + delete: vi.fn(() => ({ + where: vi.fn(() => ({ + returning: vi.fn().mockResolvedValue([]), + })), + })), + }, +})); + +vi.mock("@dokploy/server/services/application", () => ({ + findApplicationById: vi.fn(), + updateApplicationStatus: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/docker/utils", () => ({ + encodeBase64: vi.fn((value: string) => value), + removeService: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/filesystem/directory", () => ({ + removeDirectoryCode: vi.fn(), + removeDirectoryIfExistsContent: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: vi.fn(), + execAsyncRemote: vi.fn(), + ExecError: class ExecError extends Error {}, +})); + +vi.mock("@dokploy/server/utils/traefik/application", () => ({ + removeTraefikConfig: vi.fn(), +})); + +describe("preview deployment cleanup", () => { + beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(db.query.previewDeployments.findFirst).mockResolvedValue({ + previewDeploymentId: "preview-id", + applicationId: "application-id", + appName: "preview-test-application", + deployments: [ + { + buildServerId: "previous-build-server-id", + }, + { + buildServerId: "build-server-id", + }, + { + buildServerId: null, + }, + ], + application: { + applicationId: "application-id", + serverId: "deployment-server-id", + buildServerId: "build-server-id", + }, + domain: null, + } as any); + + vi.mocked(applicationService.findApplicationById).mockResolvedValue({ + applicationId: "application-id", + appName: "application-name", + serverId: "deployment-server-id", + buildServerId: "build-server-id", + } as any); + }); + + it("removes preview artifacts from every current and historical build host", async () => { + await removePreviewDeployment("preview-id"); + + expect(execProcess.execAsyncRemote).toHaveBeenCalledTimes(3); + + for (const serverId of [ + "deployment-server-id", + "build-server-id", + "previous-build-server-id", + ]) { + expect(execProcess.execAsyncRemote).toHaveBeenCalledWith( + serverId, + expect.stringContaining("preview-test-application"), + ); + } + + expect(directoryUtils.removeDirectoryIfExistsContent).toHaveBeenCalledWith( + expect.stringContaining("preview-test-application"), + ); + + expect(directoryUtils.removeDirectoryCode).toHaveBeenCalledTimes(4); + + for (const serverId of [ + null, + "deployment-server-id", + "build-server-id", + "previous-build-server-id", + ]) { + expect(directoryUtils.removeDirectoryCode).toHaveBeenCalledWith( + "preview-test-application", + serverId, + ); + } + + expect(dockerUtils.removeService).toHaveBeenCalledWith( + "preview-test-application", + "deployment-server-id", + ); + + expect(traefikApplication.removeTraefikConfig).toHaveBeenCalledWith( + "preview-test-application", + "deployment-server-id", + ); + }); +}); From fcf144bba1b0e0e3aadb0898c5a56ffd0e6a009d Mon Sep 17 00:00:00 2001 From: nielsklumper Date: Wed, 5 Aug 2026 15:58:41 +0200 Subject: [PATCH 5/8] fix(previews): prune artifacts on historical build hosts --- packages/server/src/services/deployment.ts | 109 +++++++++++---------- 1 file changed, 59 insertions(+), 50 deletions(-) diff --git a/packages/server/src/services/deployment.ts b/packages/server/src/services/deployment.ts index c5c45b975..666c94b78 100644 --- a/packages/server/src/services/deployment.ts +++ b/packages/server/src/services/deployment.ts @@ -16,7 +16,10 @@ import { environments, projects, } from "@dokploy/server/db/schema"; -import { removeDirectoryIfExistsContent } from "@dokploy/server/utils/filesystem/directory"; +import { + removeDirectoryCode, + removeDirectoryIfExistsContent, +} from "@dokploy/server/utils/filesystem/directory"; import { execAsync, execAsyncRemote, @@ -218,7 +221,8 @@ export const createDeploymentPreview = async ( await removeLastTenDeployments( deployment.previewDeploymentId, "previewDeployment", - serverId, + previewDeployment.application?.serverId, + previewDeployment.appName, ); try { const appName = `${previewDeployment.appName}`; @@ -607,7 +611,10 @@ export const createDeploymentVolumeBackup = async ( } }; -export const removeDeployment = async (deploymentId: string) => { +export const removeDeployment = async ( + deploymentId: string, + fallbackServerId?: string | null, +) => { try { const deployment = await db .delete(deployments) @@ -622,8 +629,11 @@ export const removeDeployment = async (deploymentId: string) => { const logPath = path.join(deployment.logPath); if (logPath && logPath !== ".") { const command = `rm -f ${logPath};`; - if (deployment.serverId) { - await execAsyncRemote(deployment.serverId, command); + const deploymentServerId = + deployment.buildServerId || deployment.serverId || fallbackServerId; + + if (deploymentServerId) { + await execAsyncRemote(deploymentServerId, command); } else { await execAsync(command); } @@ -692,57 +702,56 @@ const removeLastTenDeployments = async ( | "previewDeployment" | "backup" | "volumeBackup", - serverId?: string | null, + fallbackServerId?: string | null, + previewAppName?: string, ) => { const deploymentList = await getDeploymentsByType(id, type); - if (deploymentList.length > 10) { - const deploymentsToDelete = deploymentList.slice(10); - if (serverId) { - let command = ""; - for (const oldDeployment of deploymentsToDelete) { - try { - const logPath = path.join(oldDeployment.logPath); - if (oldDeployment.rollbackId) { - await removeRollbackById(oldDeployment.rollbackId); - } - if (logPath && logPath !== ".") { - command += `rm -rf ${logPath};`; - } - await removeDeployment(oldDeployment.deploymentId); - } catch (err) { - console.error( - `Failed to remove deployment ${oldDeployment.deploymentId} during cleanup:`, - err, - ); - } + if (deploymentList.length <= 10) { + return; + } + + const retainedDeployments = deploymentList.slice(0, 10); + const deploymentsToDelete = deploymentList.slice(10); + + const getDeploymentServerId = (deployment: (typeof deploymentList)[number]) => + deployment.buildServerId || deployment.serverId || fallbackServerId || null; + + const retainedServerIds = new Set( + retainedDeployments.map(getDeploymentServerId), + ); + const removedServerIds = new Set( + deploymentsToDelete.map(getDeploymentServerId), + ); + + for (const oldDeployment of deploymentsToDelete) { + try { + if (oldDeployment.rollbackId) { + await removeRollbackById(oldDeployment.rollbackId); } - if (command) { - await execAsyncRemote(serverId, command); + await removeDeployment(oldDeployment.deploymentId, fallbackServerId); + } catch (error) { + console.error( + `Failed to remove deployment ${oldDeployment.deploymentId} during cleanup:`, + error, + ); + } + } + + if (type === "previewDeployment" && previewAppName) { + for (const serverId of removedServerIds) { + if (retainedServerIds.has(serverId)) { + continue; } - } else { - for (const oldDeployment of deploymentsToDelete) { - try { - if (oldDeployment.rollbackId) { - await removeRollbackById(oldDeployment.rollbackId); - } - const logPath = path.join(oldDeployment.logPath); - if ( - logPath && - logPath !== "." && - existsSync(logPath) && - !oldDeployment.errorMessage - ) { - await fsPromises.unlink(logPath); - } - await removeDeployment(oldDeployment.deploymentId); - } catch (err) { - console.error( - `Failed to remove deployment ${oldDeployment.deploymentId} during cleanup:`, - err, - ); - } + + try { + await removeDirectoryCode(previewAppName, serverId); + } catch (error) { + console.error( + `Failed to remove preview source from ${serverId || "the local server"} during deployment cleanup:`, + error, + ); } } } From 382648df166be1c8c5640ae17b90028fbb0a63af Mon Sep 17 00:00:00 2001 From: nielsklumper Date: Wed, 5 Aug 2026 16:01:36 +0200 Subject: [PATCH 6/8] test(previews): cover historical build host pruning --- .../preview-pruning-build-servers.test.ts | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 apps/dokploy/__test__/deploy/preview-pruning-build-servers.test.ts diff --git a/apps/dokploy/__test__/deploy/preview-pruning-build-servers.test.ts b/apps/dokploy/__test__/deploy/preview-pruning-build-servers.test.ts new file mode 100644 index 000000000..88300277d --- /dev/null +++ b/apps/dokploy/__test__/deploy/preview-pruning-build-servers.test.ts @@ -0,0 +1,140 @@ +import { db } from "@dokploy/server/db"; +import { createDeploymentPreview } from "@dokploy/server/services/deployment"; +import * as previewService from "@dokploy/server/services/preview-deployment"; +import * as serverService from "@dokploy/server/services/server"; +import * as directoryUtils from "@dokploy/server/utils/filesystem/directory"; +import * as execProcess from "@dokploy/server/utils/process/execAsync"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const dbMocks = vi.hoisted(() => ({ + deleteReturning: vi.fn(), + insertReturning: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + deployments: { + findMany: vi.fn(), + }, + }, + delete: vi.fn(() => ({ + where: vi.fn(() => ({ + returning: dbMocks.deleteReturning, + })), + })), + insert: vi.fn(() => ({ + values: vi.fn(() => ({ + returning: dbMocks.insertReturning, + })), + })), + }, +})); + +vi.mock("@dokploy/server/services/application", () => ({ + findApplicationById: vi.fn(), + updateApplicationStatus: vi.fn(), +})); + +vi.mock("@dokploy/server/services/preview-deployment", () => ({ + findPreviewDeploymentById: vi.fn(), + updatePreviewDeployment: vi.fn(), +})); + +vi.mock("@dokploy/server/services/server", () => ({ + findServerById: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/filesystem/directory", () => ({ + removeDirectoryCode: vi.fn(), + removeDirectoryIfExistsContent: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: vi.fn(), + execAsyncRemote: vi.fn(), + ExecError: class ExecError extends Error {}, +})); + +describe("preview deployment pruning", () => { + beforeEach(() => { + vi.clearAllMocks(); + + const retainedDeployments = Array.from({ length: 10 }, (_, index) => ({ + deploymentId: `retained-deployment-${index}`, + buildServerId: "build-server-id", + serverId: null, + logPath: `/tmp/retained-deployment-${index}.log`, + rollbackId: null, + })); + + const prunedDeployment = { + deploymentId: "pruned-deployment-id", + buildServerId: "previous-build-server-id", + serverId: null, + logPath: "/tmp/pruned-deployment.log", + rollbackId: null, + }; + + vi.mocked(db.query.deployments.findMany).mockResolvedValue([ + ...retainedDeployments, + prunedDeployment, + ] as any); + + vi.mocked(previewService.findPreviewDeploymentById).mockResolvedValue({ + previewDeploymentId: "preview-id", + appName: "preview-test-application", + application: { + applicationId: "application-id", + serverId: "deployment-server-id", + buildServerId: "build-server-id", + }, + } as any); + + vi.mocked(serverService.findServerById).mockResolvedValue({ + serverId: "build-server-id", + } as any); + + dbMocks.deleteReturning.mockResolvedValue([prunedDeployment]); + + dbMocks.insertReturning.mockResolvedValue([ + { + deploymentId: "new-deployment-id", + logPath: "/tmp/new-deployment.log", + }, + ]); + + vi.mocked(execProcess.execAsyncRemote).mockResolvedValue({ + stdout: "", + stderr: "", + } as any); + }); + + it("removes logs and source when the last deployment for a historical build server is pruned", async () => { + await createDeploymentPreview({ + title: "Preview deployment", + description: "", + previewDeploymentId: "preview-id", + }); + + expect(execProcess.execAsyncRemote).toHaveBeenCalledWith( + "previous-build-server-id", + "rm -f /tmp/pruned-deployment.log;", + ); + + expect(directoryUtils.removeDirectoryCode).toHaveBeenCalledWith( + "preview-test-application", + "previous-build-server-id", + ); + + expect(directoryUtils.removeDirectoryCode).not.toHaveBeenCalledWith( + "preview-test-application", + "build-server-id", + ); + + expect(execProcess.execAsyncRemote).toHaveBeenCalledWith( + "build-server-id", + expect.stringContaining("Initializing deployment"), + ); + }); +}); From e17f7adad46e413a70343ba41de94df33e300b4a Mon Sep 17 00:00:00 2001 From: nielsklumper Date: Wed, 5 Aug 2026 16:29:04 +0200 Subject: [PATCH 7/8] fix(previews): preserve cleanup retry state --- packages/server/src/services/application.ts | 4 +- packages/server/src/services/deployment.ts | 75 ++++++++++--------- .../server/src/services/preview-deployment.ts | 45 +++++------ 3 files changed, 60 insertions(+), 64 deletions(-) diff --git a/packages/server/src/services/application.ts b/packages/server/src/services/application.ts index 00bd64c13..d96366fd3 100644 --- a/packages/server/src/services/application.ts +++ b/packages/server/src/services/application.ts @@ -508,7 +508,9 @@ export const rebuildPreviewApplication = async ({ const buildServerId = application.buildServerId || application.serverId; const shouldCloneRepository = - !previousDeployment || previousBuildServerId !== buildServerId; + !previousDeployment || + previousDeployment.status !== "done" || + previousBuildServerId !== buildServerId; const deployment = await createDeploymentPreview({ title: titleLog, diff --git a/packages/server/src/services/deployment.ts b/packages/server/src/services/deployment.ts index 666c94b78..1d06eaf3e 100644 --- a/packages/server/src/services/deployment.ts +++ b/packages/server/src/services/deployment.ts @@ -616,11 +616,9 @@ export const removeDeployment = async ( fallbackServerId?: string | null, ) => { try { - const deployment = await db - .delete(deployments) - .where(eq(deployments.deploymentId, deploymentId)) - .returning() - .then((result) => result[0]); + const deployment = await db.query.deployments.findFirst({ + where: eq(deployments.deploymentId, deploymentId), + }); if (!deployment) { return null; @@ -639,6 +637,11 @@ export const removeDeployment = async ( } } + await db + .delete(deployments) + .where(eq(deployments.deploymentId, deploymentId)) + .returning(); + return deployment; } catch (error) { const message = @@ -724,7 +727,33 @@ const removeLastTenDeployments = async ( deploymentsToDelete.map(getDeploymentServerId), ); + const failedServerIds = new Set(); + + if (type === "previewDeployment" && previewAppName) { + for (const serverId of removedServerIds) { + if (retainedServerIds.has(serverId)) { + continue; + } + + try { + await removeDirectoryCode(previewAppName, serverId); + } catch (error) { + failedServerIds.add(serverId); + console.error( + `Failed to remove preview source from ${serverId || "the local server"} during deployment cleanup:`, + error, + ); + } + } + } + for (const oldDeployment of deploymentsToDelete) { + const deploymentServerId = getDeploymentServerId(oldDeployment); + + if (failedServerIds.has(deploymentServerId)) { + continue; + } + try { if (oldDeployment.rollbackId) { await removeRollbackById(oldDeployment.rollbackId); @@ -738,23 +767,6 @@ const removeLastTenDeployments = async ( ); } } - - if (type === "previewDeployment" && previewAppName) { - for (const serverId of removedServerIds) { - if (retainedServerIds.has(serverId)) { - continue; - } - - try { - await removeDirectoryCode(previewAppName, serverId); - } catch (error) { - console.error( - `Failed to remove preview source from ${serverId || "the local server"} during deployment cleanup:`, - error, - ); - } - } - } }; export const removeDeploymentsByPreviewDeploymentId = async ( @@ -765,20 +777,13 @@ export const removeDeploymentsByPreviewDeploymentId = async ( const uniqueServerIds = [...new Set(serverIds)]; for (const serverId of uniqueServerIds) { - try { - const { LOGS_PATH } = paths(!!serverId); - const logsPath = path.join(LOGS_PATH, appName); + const { LOGS_PATH } = paths(!!serverId); + const logsPath = path.join(LOGS_PATH, appName); - if (serverId) { - await execAsyncRemote(serverId, `rm -rf ${logsPath}`); - } else { - await removeDirectoryIfExistsContent(logsPath); - } - } catch (error) { - console.error( - `Failed to remove preview deployment logs from ${serverId || "the local server"}:`, - error, - ); + if (serverId) { + await execAsyncRemote(serverId, `rm -rf ${logsPath}`); + } else { + await removeDirectoryIfExistsContent(logsPath); } } diff --git a/packages/server/src/services/preview-deployment.ts b/packages/server/src/services/preview-deployment.ts index 7fbf6bed1..c3abe2e6c 100644 --- a/packages/server/src/services/preview-deployment.ts +++ b/packages/server/src/services/preview-deployment.ts @@ -31,6 +31,7 @@ export const findPreviewDeploymentById = async ( deployments: { columns: { buildServerId: true, + status: true, }, orderBy: desc(deployments.createdAt), }, @@ -74,35 +75,23 @@ export const removePreviewDeployment = async (previewDeploymentId: string) => { const uniqueCleanupServerIds = [...new Set(cleanupServerIds)]; - const cleanupOperations = [ - async () => - await removeService(application.appName, application.serverId), - async () => - await removeDeploymentsByPreviewDeploymentId( - previewDeployment, - uniqueCleanupServerIds, - ), - ...uniqueCleanupServerIds.map( - (serverId) => async () => - await removeDirectoryCode(application.appName, serverId), - ), - async () => - await removeTraefikConfig(application.appName, application.serverId), - async () => - await db - .delete(previewDeployments) - .where( - eq(previewDeployments.previewDeploymentId, previewDeploymentId), - ) - .returning(), - ]; - for (const operation of cleanupOperations) { - try { - await operation(); - } catch (error) { - console.error(error); - } + await removeService(application.appName, application.serverId); + + for (const serverId of uniqueCleanupServerIds) { + await removeDirectoryCode(application.appName, serverId); } + + await removeTraefikConfig(application.appName, application.serverId); + + await removeDeploymentsByPreviewDeploymentId( + previewDeployment, + uniqueCleanupServerIds, + ); + + await db + .delete(previewDeployments) + .where(eq(previewDeployments.previewDeploymentId, previewDeploymentId)) + .returning(); return previewDeployment; } catch (error) { const message = From 08880ac1b60e618ba2c76f3c6b8ed8958e970aca Mon Sep 17 00:00:00 2001 From: nielsklumper Date: Wed, 5 Aug 2026 16:29:07 +0200 Subject: [PATCH 8/8] test(previews): cover failed build and cleanup retries --- .../deploy/preview-build-server.test.ts | 35 +++++++++++++++++++ .../preview-cleanup-build-servers.test.ts | 21 +++++++++++ .../preview-pruning-build-servers.test.ts | 25 +++++++++++++ 3 files changed, 81 insertions(+) diff --git a/apps/dokploy/__test__/deploy/preview-build-server.test.ts b/apps/dokploy/__test__/deploy/preview-build-server.test.ts index 4ab4a63d4..8c9ba9fdf 100644 --- a/apps/dokploy/__test__/deploy/preview-build-server.test.ts +++ b/apps/dokploy/__test__/deploy/preview-build-server.test.ts @@ -102,6 +102,7 @@ const createMockPreview = () => ({ deployments: [ { buildServerId: "build-server-id", + status: "done", }, ], domain: { @@ -230,6 +231,7 @@ describe("preview deployment build server and registry", () => { deployments: [ { buildServerId: "previous-build-server-id", + status: "done", }, ], } as any); @@ -270,6 +272,39 @@ describe("preview deployment build server and registry", () => { deployments: [ { buildServerId: null, + status: "done", + }, + ], + } as any); + + await rebuildPreviewApplication({ + applicationId: "application-id", + previewDeploymentId: "preview-id", + titleLog: "Rebuild preview deployment", + descriptionLog: "", + }); + + expect(githubProvider.cloneGithubRepository).toHaveBeenCalledWith( + expect.objectContaining({ + serverId: "build-server-id", + appName: "preview-test-application", + branch: "feature/test-preview", + }), + ); + + expect(execProcess.execAsyncRemote).toHaveBeenCalledWith( + "build-server-id", + expect.stringContaining("git clone command;"), + ); + }); + + it("reclones after a failed deployment on the same build server", async () => { + vi.mocked(previewService.findPreviewDeploymentById).mockResolvedValue({ + ...createMockPreview(), + deployments: [ + { + buildServerId: "build-server-id", + status: "error", }, ], } as any); diff --git a/apps/dokploy/__test__/deploy/preview-cleanup-build-servers.test.ts b/apps/dokploy/__test__/deploy/preview-cleanup-build-servers.test.ts index 273b651d1..d8cc20d21 100644 --- a/apps/dokploy/__test__/deploy/preview-cleanup-build-servers.test.ts +++ b/apps/dokploy/__test__/deploy/preview-cleanup-build-servers.test.ts @@ -126,4 +126,25 @@ describe("preview deployment cleanup", () => { "deployment-server-id", ); }); + + it("keeps preview records when cleanup on a historical build server fails", async () => { + vi.mocked(directoryUtils.removeDirectoryCode).mockImplementation( + async (_appName, serverId) => { + if (serverId === "previous-build-server-id") { + throw new Error("Historical build server is unavailable"); + } + }, + ); + + await expect(removePreviewDeployment("preview-id")).rejects.toThrow( + "Historical build server is unavailable", + ); + + expect(directoryUtils.removeDirectoryCode).toHaveBeenCalledWith( + "preview-test-application", + "previous-build-server-id", + ); + + expect(db.delete).not.toHaveBeenCalled(); + }); }); diff --git a/apps/dokploy/__test__/deploy/preview-pruning-build-servers.test.ts b/apps/dokploy/__test__/deploy/preview-pruning-build-servers.test.ts index 88300277d..48168d84d 100644 --- a/apps/dokploy/__test__/deploy/preview-pruning-build-servers.test.ts +++ b/apps/dokploy/__test__/deploy/preview-pruning-build-servers.test.ts @@ -16,6 +16,7 @@ vi.mock("@dokploy/server/db", () => ({ query: { deployments: { findMany: vi.fn(), + findFirst: vi.fn(), }, }, delete: vi.fn(() => ({ @@ -76,6 +77,10 @@ describe("preview deployment pruning", () => { rollbackId: null, }; + vi.mocked(db.query.deployments.findFirst).mockResolvedValue( + prunedDeployment as any, + ); + vi.mocked(db.query.deployments.findMany).mockResolvedValue([ ...retainedDeployments, prunedDeployment, @@ -137,4 +142,24 @@ describe("preview deployment pruning", () => { expect.stringContaining("Initializing deployment"), ); }); + + it("keeps historical deployment records when source cleanup fails", async () => { + vi.mocked(directoryUtils.removeDirectoryCode).mockRejectedValueOnce( + new Error("Historical build server is unavailable"), + ); + + await createDeploymentPreview({ + title: "Preview deployment", + description: "", + previewDeploymentId: "preview-id", + }); + + expect(directoryUtils.removeDirectoryCode).toHaveBeenCalledWith( + "preview-test-application", + "previous-build-server-id", + ); + + expect(db.query.deployments.findFirst).not.toHaveBeenCalled(); + expect(db.delete).not.toHaveBeenCalled(); + }); });