From e10ac33b0a21554357818f322f2f31989c3d942e Mon Sep 17 00:00:00 2001 From: Jonathan Sheely Date: Sat, 13 Jun 2026 09:44:49 -0400 Subject: [PATCH 1/3] fix: show real deployment error in build-failure notifications instead of leaking SSH key Deployments run as a single shell command whose output is redirected into the log file ((...) >> logPath 2>&1). When that command fails, Node's exec error.message is the entire command string, which for git/ssh sources contains the echo "" used to write the deploy key. That raw message was passed straight to build-error notifications, so Discord (and other channels) showed the SSH private key instead of the actual build error. Add getDeploymentErrorMessage to read the tail of the deployment log (the real build output, which never contains the key) and use it as the notification error message for both application and compose deployments. --- packages/server/src/services/application.ts | 10 ++++-- packages/server/src/services/compose.ts | 10 ++++-- packages/server/src/services/deployment.ts | 34 +++++++++++++++++++++ 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/packages/server/src/services/application.ts b/packages/server/src/services/application.ts index c8ebb3be77..f375b19a17 100644 --- a/packages/server/src/services/application.ts +++ b/packages/server/src/services/application.ts @@ -35,6 +35,7 @@ import { getDokployUrl } from "./admin"; import { createDeployment, createDeploymentPreview, + getDeploymentErrorMessage, updateDeployment, updateDeploymentStatus, } from "./deployment"; @@ -249,12 +250,17 @@ export const deployApplication = async ({ await updateDeploymentStatus(deployment.deploymentId, "error"); await updateApplicationStatus(applicationId, "error"); + const errorMessage = await getDeploymentErrorMessage({ + logPath: deployment.logPath, + serverId, + fallback: "Error building, check the logs for details.", + }); + await sendBuildErrorNotifications({ projectName: application.environment.project.name, applicationName: application.name, applicationType: "application", - // @ts-ignore - errorMessage: error?.message || "Error building", + errorMessage, buildLink, organizationId: application.environment.project.organizationId, }); diff --git a/packages/server/src/services/compose.ts b/packages/server/src/services/compose.ts index 7a887cdc41..b2fc7d3949 100644 --- a/packages/server/src/services/compose.ts +++ b/packages/server/src/services/compose.ts @@ -38,6 +38,7 @@ import { encodeBase64 } from "../utils/docker/utils"; import { getDokployUrl } from "./admin"; import { createDeploymentCompose, + getDeploymentErrorMessage, updateDeployment, updateDeploymentStatus, } from "./deployment"; @@ -314,12 +315,17 @@ export const deployCompose = async ({ await updateCompose(composeId, { composeStatus: "error", }); + const errorMessage = await getDeploymentErrorMessage({ + logPath: deployment.logPath, + serverId: compose.serverId, + fallback: "Error building, check the logs for details.", + }); + await sendBuildErrorNotifications({ projectName: compose.environment.project.name, applicationName: compose.name, applicationType: "compose", - // @ts-ignore - errorMessage: error?.message || "Error building", + errorMessage, buildLink, organizationId: compose.environment.project.organizationId, }); diff --git a/packages/server/src/services/deployment.ts b/packages/server/src/services/deployment.ts index a5ff577791..8e3373d5f0 100644 --- a/packages/server/src/services/deployment.ts +++ b/packages/server/src/services/deployment.ts @@ -44,6 +44,40 @@ import { findVolumeBackupById } from "./volume-backups"; export type ServicePath = { href: string | null; label: string }; +export const getDeploymentErrorMessage = async ({ + logPath, + serverId, + fallback, + maxLines = 50, +}: { + logPath: string; + serverId: string | null; + fallback: string; + maxLines?: number; +}): Promise => { + try { + if (!logPath || logPath === ".") return fallback; + + let content = ""; + if (serverId) { + const { stdout } = await execAsyncRemote( + serverId, + `tail -n ${maxLines} ${logPath}`, + ); + content = stdout; + } else { + if (!existsSync(logPath)) return fallback; + const fileContent = await fsPromises.readFile(logPath, "utf-8"); + content = fileContent.trim().split("\n").slice(-maxLines).join("\n"); + } + + const trimmed = content.trim(); + return trimmed.length > 0 ? trimmed : fallback; + } catch { + return fallback; + } +}; + export async function resolveServicePath( orgId: string, data: Record, From be5695992c6e9af8d01f741fbc718a58ee528f19 Mon Sep 17 00:00:00 2001 From: Jonathan Sheely Date: Sat, 13 Jun 2026 09:50:19 -0400 Subject: [PATCH 2/3] test: cover getDeploymentErrorMessage for build-failure notifications Adds unit tests verifying the deployment error message is read from the deployment log (the real build error) rather than the raw command string, so the SSH private key can no longer leak into notifications. Covers local and remote reads, line limiting, and fallback behaviour. --- .../deploy/deployment-error-message.test.ts | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 apps/dokploy/__test__/deploy/deployment-error-message.test.ts diff --git a/apps/dokploy/__test__/deploy/deployment-error-message.test.ts b/apps/dokploy/__test__/deploy/deployment-error-message.test.ts new file mode 100644 index 0000000000..503789359e --- /dev/null +++ b/apps/dokploy/__test__/deploy/deployment-error-message.test.ts @@ -0,0 +1,189 @@ +import { promises as fsPromises } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import * as execProcess from "@dokploy/server/utils/process/execAsync"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: vi.fn(), + execAsyncRemote: vi.fn(), + ExecError: class ExecError extends Error {}, +})); + +import { getDeploymentErrorMessage } from "@dokploy/server/services/deployment"; + +const FALLBACK = "Error building, check the logs for details."; + +// The private key that gets echoed into the deploy command. The bug was that +// Node's exec error.message (the whole command string, including this echo) +// was sent to notifications. The real build error lives in the log file, which +// never contains the key, so reading the log must never surface it. +const PRIVATE_KEY = `-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +SECRETKEYMATERIALSHOULDNEVERLEAK +-----END OPENSSH PRIVATE KEY-----`; + +describe("getDeploymentErrorMessage", () => { + let tmpDir: string; + + beforeEach(async () => { + vi.clearAllMocks(); + tmpDir = await fsPromises.mkdtemp( + path.join(os.tmpdir(), "dokploy-log-test-"), + ); + }); + + afterEach(async () => { + await fsPromises.rm(tmpDir, { recursive: true, force: true }); + }); + + describe("local deployments (no serverId)", () => { + it("returns the real build error from the log, not the fallback", async () => { + const logPath = path.join(tmpDir, "build.log"); + const realError = [ + "Cloning Repo Custom ...: ✅", + "#5 ERROR: failed to build: npm run build exited with code 1", + "error: cannot find module './missing'", + ].join("\n"); + await fsPromises.writeFile(logPath, realError); + + const result = await getDeploymentErrorMessage({ + logPath, + serverId: null, + fallback: FALLBACK, + }); + + expect(result).toContain("failed to build"); + expect(result).toContain("cannot find module"); + expect(result).not.toBe(FALLBACK); + }); + + it("returns only the log content, so the key in the command string never leaks", async () => { + // The bug: Node's exec error.message is the whole command string, which + // includes the `echo ""` from the clone step. This function + // only ever reads the log file (which never contains the key), so the + // notification message is sourced from the log, not that command string. + const logPath = path.join(tmpDir, "build.log"); + const realError = "#5 ERROR: failed to build the application"; + await fsPromises.writeFile(logPath, realError); + + const result = await getDeploymentErrorMessage({ + logPath, + serverId: null, + fallback: FALLBACK, + }); + + expect(result).toBe(realError); + expect(result).not.toContain(PRIVATE_KEY); + expect(result).not.toContain("BEGIN OPENSSH PRIVATE KEY"); + }); + + it("returns the fallback when the log file does not exist", async () => { + const result = await getDeploymentErrorMessage({ + logPath: path.join(tmpDir, "does-not-exist.log"), + serverId: null, + fallback: FALLBACK, + }); + + expect(result).toBe(FALLBACK); + }); + + it("returns the fallback when the log file is empty", async () => { + const logPath = path.join(tmpDir, "empty.log"); + await fsPromises.writeFile(logPath, " \n \n"); + + const result = await getDeploymentErrorMessage({ + logPath, + serverId: null, + fallback: FALLBACK, + }); + + expect(result).toBe(FALLBACK); + }); + + it("returns the fallback for an empty or '.' log path", async () => { + expect( + await getDeploymentErrorMessage({ + logPath: "", + serverId: null, + fallback: FALLBACK, + }), + ).toBe(FALLBACK); + expect( + await getDeploymentErrorMessage({ + logPath: ".", + serverId: null, + fallback: FALLBACK, + }), + ).toBe(FALLBACK); + }); + + it("only returns the last `maxLines` lines of the log", async () => { + const logPath = path.join(tmpDir, "long.log"); + const lines = Array.from({ length: 100 }, (_, i) => `line ${i + 1}`); + await fsPromises.writeFile(logPath, lines.join("\n")); + + const result = await getDeploymentErrorMessage({ + logPath, + serverId: null, + fallback: FALLBACK, + maxLines: 10, + }); + + const resultLines = result.split("\n"); + expect(resultLines).toHaveLength(10); + expect(resultLines[0]).toBe("line 91"); + expect(resultLines[9]).toBe("line 100"); + }); + }); + + describe("remote deployments (with serverId)", () => { + it("reads the log tail over SSH and returns it", async () => { + vi.mocked(execProcess.execAsyncRemote).mockResolvedValue({ + stdout: "#5 ERROR: failed to build on remote server\n", + stderr: "", + }); + + const result = await getDeploymentErrorMessage({ + logPath: "/etc/dokploy/logs/test/build.log", + serverId: "server-1", + fallback: FALLBACK, + }); + + expect(result).toBe("#5 ERROR: failed to build on remote server"); + expect(execProcess.execAsyncRemote).toHaveBeenCalledWith( + "server-1", + expect.stringContaining("tail -n 50 /etc/dokploy/logs/test/build.log"), + ); + }); + + it("returns the fallback when the remote read fails", async () => { + vi.mocked(execProcess.execAsyncRemote).mockRejectedValue( + new Error("ssh connection refused"), + ); + + const result = await getDeploymentErrorMessage({ + logPath: "/etc/dokploy/logs/test/build.log", + serverId: "server-1", + fallback: FALLBACK, + }); + + expect(result).toBe(FALLBACK); + }); + + it("returns the fallback when the remote log is empty", async () => { + vi.mocked(execProcess.execAsyncRemote).mockResolvedValue({ + stdout: "\n \n", + stderr: "", + }); + + const result = await getDeploymentErrorMessage({ + logPath: "/etc/dokploy/logs/test/build.log", + serverId: "server-1", + fallback: FALLBACK, + }); + + expect(result).toBe(FALLBACK); + }); + }); +}); From afb128bc0b3f2e03c76b3186471e9fdc2634c011 Mon Sep 17 00:00:00 2001 From: Jonathan Sheely Date: Sat, 13 Jun 2026 10:53:50 -0400 Subject: [PATCH 3/3] fix: truncate build-error notifications from the end, not the start Discord, Lark and Teams capped the error message with errorMessage.substring(0, 800), which keeps the FIRST 800 characters. Since the deployment log tail is sent as the error and the actual build error is at the bottom, this showed the top of the log and cut off the real error. Extract a truncateErrorMessage helper that keeps the LAST `limit` characters (prefixed with "...") and use it in all three channels. --- .../deploy/truncate-error-message.test.ts | 35 +++++++++++++++++++ .../src/utils/notifications/build-error.ts | 18 ++++++---- 2 files changed, 47 insertions(+), 6 deletions(-) create mode 100644 apps/dokploy/__test__/deploy/truncate-error-message.test.ts diff --git a/apps/dokploy/__test__/deploy/truncate-error-message.test.ts b/apps/dokploy/__test__/deploy/truncate-error-message.test.ts new file mode 100644 index 0000000000..39fd61f4e2 --- /dev/null +++ b/apps/dokploy/__test__/deploy/truncate-error-message.test.ts @@ -0,0 +1,35 @@ +import { truncateErrorMessage } from "@dokploy/server/utils/notifications/build-error"; +import { describe, expect, it } from "vitest"; + +describe("truncateErrorMessage", () => { + it("returns the message unchanged when within the limit", () => { + const message = "short build error"; + expect(truncateErrorMessage(message)).toBe(message); + }); + + it("returns the message unchanged when exactly at the limit", () => { + const message = "x".repeat(800); + expect(truncateErrorMessage(message)).toBe(message); + }); + + it("keeps the END of the message, not the start", () => { + // The actual build error is at the bottom of the log; truncating from the + // front would hide it. + const startMarker = "TOP_OF_LOG_SHOULD_BE_DROPPED"; + const noise = "noise ".repeat(200); // pushes the start past the 800 limit + const realError = "#5 ERROR: failed to build the application"; + const message = `${startMarker}${noise}${realError}`; + + const result = truncateErrorMessage(message); + + expect(result.length).toBeLessThanOrEqual(800 + 3); // "..." prefix + expect(result).toContain(realError); + expect(result).not.toContain(startMarker); + expect(result.startsWith("...")).toBe(true); + }); + + it("respects a custom limit", () => { + const message = "abcdefghij"; // 10 chars + expect(truncateErrorMessage(message, 4)).toBe("...ghij"); + }); +}); diff --git a/packages/server/src/utils/notifications/build-error.ts b/packages/server/src/utils/notifications/build-error.ts index 5b5f6c53ba..f98aa0ecff 100644 --- a/packages/server/src/utils/notifications/build-error.ts +++ b/packages/server/src/utils/notifications/build-error.ts @@ -28,6 +28,15 @@ interface Props { organizationId: string; } +/** + * Truncates an error message for notification channels that have a length + * limit. Keeps the END of the message because build errors live at the bottom + * of the deployment log — truncating from the front would hide the actual + * error. + */ +export const truncateErrorMessage = (message: string, limit = 800): string => + message.length > limit ? `...${message.slice(-limit)}` : message; + export const sendBuildErrorNotifications = async ({ projectName, applicationName, @@ -108,8 +117,7 @@ export const sendBuildErrorNotifications = async ({ const decorate = (decoration: string, text: string) => `${discord.decoration ? decoration : ""} ${text}`.trim(); - const limitCharacter = 800; - const truncatedErrorMessage = errorMessage.substring(0, limitCharacter); + const truncatedErrorMessage = truncateErrorMessage(errorMessage); await sendDiscordNotification(discord, { title: decorate(">", "`⚠️` Build Failed"), color: 0xed4245, @@ -289,8 +297,7 @@ ${errorMessage} } if (lark) { - const limitCharacter = 800; - const truncatedErrorMessage = errorMessage.substring(0, limitCharacter); + const truncatedErrorMessage = truncateErrorMessage(errorMessage); await sendLarkNotification(lark, { msg_type: "interactive", card: { @@ -409,8 +416,7 @@ ${errorMessage} } if (teams) { - const limitCharacter = 800; - const truncatedErrorMessage = errorMessage.substring(0, limitCharacter); + const truncatedErrorMessage = truncateErrorMessage(errorMessage); await sendTeamsNotification(teams, { title: "⚠️ Build Failed", facts: [