Merge remote-tracking branch 'origin/canary' into fix/env-update-issue

# Conflicts:
#	packages/server/src/utils/builders/railpack.ts
This commit is contained in:
Mauricio Siu
2026-08-03 03:53:41 -06:00
550 changed files with 127113 additions and 34784 deletions
@@ -32,7 +32,19 @@ export const startLogCleanup = async (
await execAsync(
`tail -n 1000 ${accessLogPath} > ${accessLogPath}.tmp && mv ${accessLogPath}.tmp ${accessLogPath}`,
);
await execAsync("docker exec dokploy-traefik kill -USR1 1");
// Traefik can run as a standalone container ("dokploy-traefik") or a
// swarm service task ("dokploy-traefik.1.<task-id>"), so resolve the
// running container id dynamically instead of assuming the name.
const { stdout: containerId } = await execAsync(
'docker ps -q --filter "name=dokploy-traefik" --filter "status=running" | head -n 1',
);
const traefikContainerId = containerId.trim();
if (!traefikContainerId) {
console.error("Traefik container not found, skipping log reopen");
return;
}
await execAsync(`docker exec ${traefikContainerId} kill -USR1 1`);
} catch (error) {
console.error("Error during log cleanup:", error);
}
@@ -74,8 +74,10 @@ export function selectAIProvider(config: { apiUrl: string; apiKey: string }) {
});
case "ollama":
return createOllama({
// optional settings, e.g.
baseURL: config.apiUrl,
headers: config.apiKey
? { Authorization: `Bearer ${config.apiKey}` }
: undefined,
});
case "deepinfra":
return createDeepInfra({
+2 -1
View File
@@ -4,6 +4,7 @@ import {
createDeploymentBackup,
updateDeploymentStatus,
} from "@dokploy/server/services/deployment";
import { findDestinationById } from "@dokploy/server/services/destination";
import { findEnvironmentById } from "@dokploy/server/services/environment";
import { findProjectById } from "@dokploy/server/services/project";
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
@@ -23,7 +24,7 @@ export const runComposeBackup = async (
const environment = await findEnvironmentById(environmentId);
const project = await findProjectById(environment.projectId);
const { prefix, databaseType, serviceName } = backup;
const destination = backup.destination;
const destination = await findDestinationById(backup.destinationId);
const backupFileName = `${getBackupTimestamp()}.${databaseType === "mongo" ? "bson" : "sql"}.gz`;
const s3AppName = serviceName ? `${appName}_${serviceName}` : appName;
const bucketDestination = `${s3AppName}/${normalizeS3Path(prefix)}${backupFileName}`;
+6 -3
View File
@@ -1,6 +1,7 @@
import { CLEANUP_CRON_JOB } from "@dokploy/server/constants";
import { member } from "@dokploy/server/db/schema";
import type { BackupSchedule } from "@dokploy/server/services/backup";
import { findDestinationById } from "@dokploy/server/services/destination";
import { getAllServers } from "@dokploy/server/services/server";
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
import { eq } from "drizzle-orm";
@@ -10,6 +11,7 @@ import { startLogCleanup } from "../access-log/handler";
import { cleanupAll } from "../docker/utils";
import { sendDockerCleanupNotifications } from "../notifications/docker-cleanup";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import { redactRcloneCredentials } from "./redact";
import { getS3Credentials, normalizeS3Path, scheduleBackup } from "./utils";
export const initCronJobs = async () => {
@@ -131,9 +133,10 @@ export const keepLatestNBackups = async (
if (!backup.keepLatestCount) return;
try {
const rcloneFlags = getS3Credentials(backup.destination);
const destination = await findDestinationById(backup.destinationId);
const rcloneFlags = getS3Credentials(destination);
const appName = getServiceAppName(backup);
const backupFilesPath = `:s3:${backup.destination.bucket}/${appName}/${normalizeS3Path(backup.prefix)}`;
const backupFilesPath = `:s3:${destination.bucket}/${appName}/${normalizeS3Path(backup.prefix)}`;
// --include "*.bson.gz" or "*.sql.gz" or "*.zip" ensures nothing else other than the dokploy backup files are touched by rclone
const rcloneList = `rclone lsf ${rcloneFlags.join(" ")} --include "*${backup.databaseType === "web-server" ? ".zip" : ".{sql.gz,bson.gz}"}" ${backupFilesPath}`;
@@ -151,6 +154,6 @@ export const keepLatestNBackups = async (
await execAsync(rcloneCommand);
}
} catch (error) {
console.error(error);
console.error(redactRcloneCredentials(String(error)));
}
};
+2 -1
View File
@@ -3,6 +3,7 @@ import {
createDeploymentBackup,
updateDeploymentStatus,
} from "@dokploy/server/services/deployment";
import { findDestinationById } from "@dokploy/server/services/destination";
import { findEnvironmentById } from "@dokploy/server/services/environment";
import type { Libsql } from "@dokploy/server/services/libsql";
import { findProjectById } from "@dokploy/server/services/project";
@@ -29,7 +30,7 @@ export const runLibsqlBackup = async (
description: "Initializing Backup",
});
const { prefix } = backup;
const destination = backup.destination;
const destination = await findDestinationById(backup.destinationId);
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
try {
+2 -1
View File
@@ -3,6 +3,7 @@ import {
createDeploymentBackup,
updateDeploymentStatus,
} from "@dokploy/server/services/deployment";
import { findDestinationById } from "@dokploy/server/services/destination";
import { findEnvironmentById } from "@dokploy/server/services/environment";
import type { Mariadb } from "@dokploy/server/services/mariadb";
import { findProjectById } from "@dokploy/server/services/project";
@@ -23,7 +24,7 @@ export const runMariadbBackup = async (
const environment = await findEnvironmentById(environmentId);
const project = await findProjectById(environment.projectId);
const { prefix } = backup;
const destination = backup.destination;
const destination = await findDestinationById(backup.destinationId);
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({
+2 -1
View File
@@ -3,6 +3,7 @@ import {
createDeploymentBackup,
updateDeploymentStatus,
} from "@dokploy/server/services/deployment";
import { findDestinationById } from "@dokploy/server/services/destination";
import { findEnvironmentById } from "@dokploy/server/services/environment";
import type { Mongo } from "@dokploy/server/services/mongo";
import { findProjectById } from "@dokploy/server/services/project";
@@ -20,7 +21,7 @@ export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => {
const environment = await findEnvironmentById(environmentId);
const project = await findProjectById(environment.projectId);
const { prefix } = backup;
const destination = backup.destination;
const destination = await findDestinationById(backup.destinationId);
const backupFileName = `${getBackupTimestamp()}.bson.gz`;
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({
+2 -1
View File
@@ -3,6 +3,7 @@ import {
createDeploymentBackup,
updateDeploymentStatus,
} from "@dokploy/server/services/deployment";
import { findDestinationById } from "@dokploy/server/services/destination";
import { findEnvironmentById } from "@dokploy/server/services/environment";
import type { MySql } from "@dokploy/server/services/mysql";
import { findProjectById } from "@dokploy/server/services/project";
@@ -20,7 +21,7 @@ export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => {
const environment = await findEnvironmentById(environmentId);
const project = await findProjectById(environment.projectId);
const { prefix } = backup;
const destination = backup.destination;
const destination = await findDestinationById(backup.destinationId);
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({
@@ -3,6 +3,7 @@ import {
createDeploymentBackup,
updateDeploymentStatus,
} from "@dokploy/server/services/deployment";
import { findDestinationById } from "@dokploy/server/services/destination";
import { findEnvironmentById } from "@dokploy/server/services/environment";
import type { Postgres } from "@dokploy/server/services/postgres";
import { findProjectById } from "@dokploy/server/services/project";
@@ -29,7 +30,7 @@ export const runPostgresBackup = async (
description: "Initializing Backup",
});
const { prefix } = backup;
const destination = backup.destination;
const destination = await findDestinationById(backup.destinationId);
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
try {
@@ -0,0 +1,12 @@
/**
* Redacts S3 credentials from rclone command strings.
*
* Used to prevent credential leakage in structured logs and error output.
* Matches the flag format produced by `getS3Credentials()`:
* --s3-access-key-id="VALUE" and --s3-secret-access-key="VALUE"
*/
export const redactRcloneCredentials = (command: string): string => {
return command
.replace(/(--s3-access-key-id=)"[^"]*"/g, '$1"[REDACTED]"')
.replace(/(--s3-secret-access-key=)"[^"]*"/g, '$1"[REDACTED]"');
};
+18 -11
View File
@@ -2,6 +2,7 @@ import { logger } from "@dokploy/server/lib/logger";
import type { BackupSchedule } from "@dokploy/server/services/backup";
import type { Destination } from "@dokploy/server/services/destination";
import { scheduledJobs, scheduleJob } from "node-schedule";
import { quote } from "shell-quote";
import { keepLatestNBackups } from ".";
import { runComposeBackup } from "./compose";
import { runLibsqlBackup } from "./libsql";
@@ -9,6 +10,7 @@ import { runMariadbBackup } from "./mariadb";
import { runMongoBackup } from "./mongo";
import { runMySqlBackup } from "./mysql";
import { runPostgresBackup } from "./postgres";
import { redactRcloneCredentials } from "./redact";
import { runWebServerBackup } from "./web-server";
export const scheduleBackup = (backup: BackupSchedule) => {
@@ -70,16 +72,16 @@ export const getS3Credentials = (destination: Destination) => {
const { accessKey, secretAccessKey, region, endpoint, provider } =
destination;
const rcloneFlags = [
`--s3-access-key-id="${accessKey}"`,
`--s3-secret-access-key="${secretAccessKey}"`,
`--s3-region="${region}"`,
`--s3-endpoint="${endpoint}"`,
`--s3-access-key-id=${quote([accessKey])}`,
`--s3-secret-access-key=${quote([secretAccessKey])}`,
`--s3-region=${quote([region])}`,
`--s3-endpoint=${quote([endpoint])}`,
"--s3-no-check-bucket",
"--s3-force-path-style",
];
if (provider) {
rcloneFlags.unshift(`--s3-provider="${provider}"`);
rcloneFlags.unshift(`--s3-provider=${quote([provider])}`);
}
if (destination.additionalFlags?.length) {
@@ -89,11 +91,16 @@ export const getS3Credentials = (destination: Destination) => {
return rcloneFlags;
};
// User-controlled values (database name, user, password) are passed to the
// container as environment variables via `docker exec -e VAR=<escaped>` and
// referenced as "$VAR" inside the inner shell, so they never appear in the
// inner command text. The -e value is escaped for the outer shell with
// shell-quote; the inner script is single-quoted and reads the env vars.
export const getPostgresBackupCommand = (
database: string,
databaseUser: string,
) => {
return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; pg_dump -Fc --no-acl --no-owner -h localhost -U ${databaseUser} --no-password '${database}' | gzip"`;
return `docker exec -e DB_NAME=${quote([database])} -e DB_USER=${quote([databaseUser])} -i $CONTAINER_ID bash -c 'set -o pipefail; pg_dump -Fc --no-acl --no-owner -h localhost -U "$DB_USER" --no-password "$DB_NAME" | gzip'`;
};
export const getMariadbBackupCommand = (
@@ -101,14 +108,14 @@ export const getMariadbBackupCommand = (
databaseUser: string,
databasePassword: string,
) => {
return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; mariadb-dump --user='${databaseUser}' --password='${databasePassword}' --single-transaction --quick --databases ${database} | gzip"`;
return `docker exec -e DB_NAME=${quote([database])} -e DB_USER=${quote([databaseUser])} -e DB_PASS=${quote([databasePassword])} -i $CONTAINER_ID bash -c 'set -o pipefail; mariadb-dump --user="$DB_USER" --password="$DB_PASS" --single-transaction --quick --databases "$DB_NAME" | gzip'`;
};
export const getMysqlBackupCommand = (
database: string,
databasePassword: string,
) => {
return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; mysqldump --default-character-set=utf8mb4 -u 'root' --password='${databasePassword}' --single-transaction --no-tablespaces --quick '${database}' | gzip"`;
return `docker exec -e DB_NAME=${quote([database])} -e DB_PASS=${quote([databasePassword])} -i $CONTAINER_ID bash -c 'set -o pipefail; mysqldump --default-character-set=utf8mb4 -u root --password="$DB_PASS" --single-transaction --no-tablespaces --quick "$DB_NAME" | gzip'`;
};
export const getMongoBackupCommand = (
@@ -116,11 +123,11 @@ export const getMongoBackupCommand = (
databaseUser: string,
databasePassword: string,
) => {
return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; mongodump -d '${database}' -u '${databaseUser}' -p '${databasePassword}' --archive --authenticationDatabase admin --gzip"`;
return `docker exec -e DB_NAME=${quote([database])} -e DB_USER=${quote([databaseUser])} -e DB_PASS=${quote([databasePassword])} -i $CONTAINER_ID bash -c 'set -o pipefail; mongodump -d "$DB_NAME" -u "$DB_USER" -p "$DB_PASS" --archive --authenticationDatabase admin --gzip'`;
};
export const getLibsqlBackupCommand = (database: string) => {
return `docker exec -i $CONTAINER_ID sh -c "tar cf - -C /var/lib/sqld ${database} | gzip"`;
return `docker exec -e DB_NAME=${quote([database])} -i $CONTAINER_ID sh -c 'tar cf - -C /var/lib/sqld "$DB_NAME" | gzip'`;
};
export const getServiceContainerCommand = (appName: string) => {
@@ -262,7 +269,7 @@ export const getBackupCommand = (
{
containerSearch,
backupCommand,
rcloneCommand,
rcloneCommand: redactRcloneCredentials(rcloneCommand),
logPath,
},
`Executing backup command: ${backup.databaseType} ${backup.backupType}`,
@@ -1,8 +1,12 @@
import { createWriteStream } from "node:fs";
import { mkdtemp, rm, stat } from "node:fs/promises";
import { mkdtemp, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { IS_CLOUD, paths } from "@dokploy/server/constants";
import {
ENCRYPTION_KEY_BACKUP_FILE,
exportEncryptionKeys,
} from "@dokploy/server/lib/encryption";
import type { BackupSchedule } from "@dokploy/server/services/backup";
import {
createDeploymentBackup,
@@ -11,6 +15,7 @@ import {
import { findDestinationById } from "@dokploy/server/services/destination";
import { sendDokployBackupNotifications } from "../notifications/dokploy-backup";
import { execAsync } from "../process/execAsync";
import { redactRcloneCredentials } from "./redact";
import { getBackupTimestamp, getS3Credentials, normalizeS3Path } from "./utils";
function formatBytes(bytes?: number) {
@@ -77,11 +82,22 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
await execAsync(cleanupCommand);
await execAsync(
`rsync -a --ignore-errors --no-specials --no-devices --exclude='volume-backups/' ${BASE_PATH}/ ${tempDir}/filesystem/`,
`rsync -a --ignore-errors --no-specials --no-devices --exclude='volume-backups/' --exclude='${ENCRYPTION_KEY_BACKUP_FILE}' ${BASE_PATH}/ ${tempDir}/filesystem/`,
);
writeStream.write("Copied filesystem to temp directory\n");
if (backup.includeEncryptionKey) {
// Restoring the filesystem places this file at BASE_PATH, where
// the encryption keyring picks it up as a decryption fallback.
await writeFile(
join(tempDir, "filesystem", ENCRYPTION_KEY_BACKUP_FILE),
exportEncryptionKeys(),
{ mode: 0o600 },
);
writeStream.write("Included encryption key in backup\n");
}
await execAsync(
// Zip all .sql files since we created more than one
`cd ${tempDir} && zip -r ${backupFileName} *.sql filesystem/ > /dev/null 2>&1`,
@@ -113,20 +129,23 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
try {
await rm(tempDir, { recursive: true, force: true });
} catch (cleanupError) {
console.error("Cleanup error:", cleanupError);
console.error(
"Cleanup error:",
redactRcloneCredentials(String(cleanupError)),
);
}
}
} catch (error) {
console.error("Backup error:", error);
writeStream.write("Backup error❌\n");
writeStream.write(
error instanceof Error ? error.message : "Unknown error\n",
const safeErrorMessage = redactRcloneCredentials(
error instanceof Error ? error.message : String(error),
);
console.error("Backup error:", redactRcloneCredentials(String(error)));
writeStream.write("Backup error❌\n");
writeStream.write(`${safeErrorMessage}\n`);
writeStream.end();
await sendDokployBackupNotifications({
type: "error",
// @ts-ignore
errorMessage: error?.message || "Error message not provided",
errorMessage: safeErrorMessage || "Error message not provided",
backupSize: formatBytes(computedBackupSize),
});
await updateDeploymentStatus(deployment.deploymentId, "error");
+15 -4
View File
@@ -67,9 +67,20 @@ Compose Type: ${composeType} ✅`;
return bashCommand;
};
// 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\\]/;
const sanitizeCommand = (command: string) => {
const sanitizedCommand = command.trim();
if (UNSAFE_COMPOSE_COMMAND.test(sanitizedCommand)) {
throw new Error(
"Invalid characters in compose command: shell control characters are not allowed",
);
}
const parts = sanitizedCommand.split(/\s+/);
const restCommand = parts.map((arg) => arg.replace(/^"(.*)"$/, "$1"));
@@ -88,9 +99,9 @@ export const createCommand = (compose: ComposeNested) => {
let command = "";
if (composeType === "docker-compose") {
command = `compose -p ${appName} -f ${path} up -d --build --remove-orphans`;
command = `compose -p ${quote([appName])} -f ${quote([path])} up -d --build --remove-orphans`;
} else if (composeType === "stack") {
command = `stack deploy -c ${path} ${appName} --prune --with-registry-auth`;
command = `stack deploy -c ${quote([path])} ${quote([appName])} --prune --with-registry-auth`;
}
return command;
@@ -124,8 +135,8 @@ export const getCreateEnvFileCommand = (compose: ComposeNested) => {
const encodedContent = encodeBase64(envFileContent);
return `
touch ${envFilePath};
echo "${encodedContent}" | base64 -d > "${envFilePath}";
touch ${quote([envFilePath])};
echo "${encodedContent}" | base64 -d > ${quote([envFilePath])};
`;
};
@@ -85,9 +85,9 @@ export const getDockerCommand = (application: ApplicationNested) => {
}
command += `
echo "Building ${appName}" ;
cd ${dockerContextPath} || {
echo "❌ The path ${dockerContextPath} does not exist" ;
echo ${quote([`Building ${appName}`])} ;
cd ${quote([dockerContextPath])} || {
echo ${quote([`❌ The path ${dockerContextPath} does not exist`])} ;
exit 1;
}
+25 -24
View File
@@ -1,3 +1,5 @@
import { resolveServiceNetworks } from "@dokploy/server/services/network";
import { findRegistryByIdWithCredentials } from "@dokploy/server/services/registry";
import type { InferResultType } from "@dokploy/server/types/with";
import type { CreateServiceOptions } from "dockerode";
import { getRegistryTag, uploadImageRemoteCommand } from "../cluster/upload";
@@ -28,9 +30,9 @@ export type ApplicationNested = InferResultType<
security: true;
redirects: true;
ports: true;
registry: true;
buildRegistry: true;
rollbackRegistry: true;
registry: { columns: { password: false } };
buildRegistry: { columns: { password: false } };
rollbackRegistry: { columns: { password: false } };
deployments: true;
environment: { with: { project: true } };
}
@@ -99,6 +101,8 @@ export const mechanizeDockerContainer = async (
const volumesMount = generateVolumeMounts(mounts);
const resolvedNetworks = await resolveServiceNetworks(application);
const {
HealthCheck,
RestartPolicy,
@@ -107,7 +111,6 @@ export const mechanizeDockerContainer = async (
Mode,
RollbackConfig,
UpdateConfig,
Networks,
StopGracePeriod,
EndpointSpec,
Ulimits,
@@ -121,8 +124,8 @@ export const mechanizeDockerContainer = async (
application.environment.env,
);
const image = getImageName(application);
const authConfig = getAuthConfig(application);
const image = await getImageName(application);
const authConfig = await getAuthConfig(application);
const docker = await getRemoteDocker(application.serverId);
const settings: CreateServiceOptions = {
@@ -146,7 +149,7 @@ export const mechanizeDockerContainer = async (
...(Ulimits && { Ulimits }),
Labels,
},
Networks,
Networks: resolvedNetworks,
RestartPolicy,
Placement,
Resources: {
@@ -190,7 +193,7 @@ export const mechanizeDockerContainer = async (
}
};
const getImageName = (application: ApplicationNested) => {
const getImageName = async (application: ApplicationNested) => {
const { appName, sourceType, dockerImage, registry, buildRegistry } =
application;
const imageName = `${appName}:latest`;
@@ -199,18 +202,18 @@ const getImageName = (application: ApplicationNested) => {
}
if (registry) {
const registryTag = getRegistryTag(registry, imageName);
return registryTag;
const r = await findRegistryByIdWithCredentials(registry.registryId);
return getRegistryTag(r, imageName);
}
if (buildRegistry) {
const registryTag = getRegistryTag(buildRegistry, imageName);
return registryTag;
const r = await findRegistryByIdWithCredentials(buildRegistry.registryId);
return getRegistryTag(r, imageName);
}
return imageName;
};
export const getAuthConfig = (application: ApplicationNested) => {
export const getAuthConfig = async (application: ApplicationNested) => {
const {
registry,
buildRegistry,
@@ -222,23 +225,21 @@ export const getAuthConfig = (application: ApplicationNested) => {
if (sourceType === "docker") {
if (username && password) {
return {
password,
username,
serveraddress: registryUrl || "",
};
return { password, username, serveraddress: registryUrl || "" };
}
} else if (registry) {
const r = await findRegistryByIdWithCredentials(registry.registryId);
return {
password: registry.password,
username: registry.username,
serveraddress: registry.registryUrl,
password: r.password,
username: r.username,
serveraddress: r.registryUrl,
};
} else if (buildRegistry) {
const r = await findRegistryByIdWithCredentials(buildRegistry.registryId);
return {
password: buildRegistry.password,
username: buildRegistry.username,
serveraddress: buildRegistry.registryUrl,
password: r.password,
username: r.username,
serveraddress: r.registryUrl,
};
}
@@ -1,6 +1,7 @@
import path from "node:path";
import { getStaticCommand } from "@dokploy/server/utils/builders/static";
import { nanoid } from "nanoid";
import { quote } from "shell-quote";
import { prepareEnvironmentVariablesForShell } from "../docker/utils";
import { getBuildAppDirectory } from "../filesystem/directory";
import type { ApplicationNested } from ".";
@@ -52,10 +53,10 @@ export const getNixpacksCommand = (application: ApplicationNested) => {
bashCommand += `
docker create --name ${buildContainerId} ${appName}
mkdir -p ${localPath}
docker cp ${buildContainerId}:/app/${publishDirectory}${isDirectory ? "/." : ""} ${path.join(buildAppDirectory, publishDirectory)} || {
mkdir -p ${quote([localPath])}
docker cp ${quote([`${buildContainerId}:/app/${publishDirectory}${isDirectory ? "/." : ""}`])} ${quote([localPath])} || {
docker rm ${buildContainerId}
echo "❌ Copying ${publishDirectory} to ${path.join(buildAppDirectory, publishDirectory)} failed" ;
echo ${quote([`❌ Copying ${publishDirectory} to ${localPath} failed`])} ;
exit 1;
}
docker rm ${buildContainerId}
+13 -9
View File
@@ -44,10 +44,15 @@ export const getRailpackCommand = (application: ApplicationNested) => {
const secretsHash = calculateSecretsHash(envVariables);
const cacheKey = cleanCache ? nanoid(10) : undefined;
// Build command
// Build command.
// Use a unique builder name per build so concurrent deployments don't race
// on a shared "builder-containerd" instance (create/use/rm collisions).
const builderName = `railpack-${appName}-${nanoid(6)}`;
const buildArgs = [
"buildx",
"build",
"--builder",
builderName,
"--build-arg",
`secrets-hash=${secretsHash}`,
...(cacheKey ? ["--build-arg", `cache-key=${cacheKey}`] : []),
@@ -79,17 +84,16 @@ export const getRailpackCommand = (application: ApplicationNested) => {
const bashCommand = `
# Ensure we have a builder with containerd
# Ensure we have a builder with containerd (isolated per build)
export RAILPACK_VERSION=${application.railpackVersion}
bash -c "$(curl -fsSL https://railpack.com/install.sh)"
docker buildx create --use --name builder-containerd --driver docker-container || true
docker buildx use builder-containerd
docker buildx create --name ${builderName} --driver docker-container || true
echo "Preparing Railpack build plan..." ;
railpack ${prepareArgs.join(" ")} || {
railpack ${prepareArgs.join(" ")} || {
echo "❌ Railpack prepare failed" ;
docker buildx rm builder-containerd || true
docker buildx rm ${builderName} || true
exit 1;
}
echo "✅ Railpack prepare completed." ;
@@ -97,13 +101,13 @@ echo "✅ Railpack prepare completed." ;
echo "Building with Railpack frontend..." ;
# Export environment variables for secrets
${exportEnvs.join("\n")}
docker ${buildArgs.join(" ")} || {
docker ${buildArgs.join(" ")} || {
echo "❌ Railpack build failed" ;
docker buildx rm builder-containerd || true
docker buildx rm ${builderName} || true
exit 1;
}
echo "✅ Railpack build completed." ;
docker buildx rm builder-containerd
docker buildx rm ${builderName} || true
`;
return bashCommand;
+26 -17
View File
@@ -1,6 +1,11 @@
import { findAllDeploymentsByApplicationId } from "@dokploy/server/services/deployment";
import type { Registry } from "@dokploy/server/services/registry";
import {
findRegistryByIdWithCredentials,
type Registry,
safeDockerLoginCommand,
} from "@dokploy/server/services/registry";
import { createRollback } from "@dokploy/server/services/rollbacks";
import { quote } from "shell-quote";
import type { ApplicationNested } from "../builders";
export const uploadImageRemoteCommand = async (
@@ -22,19 +27,19 @@ export const uploadImageRemoteCommand = async (
const commands: string[] = [];
if (registry) {
const registryTag = getRegistryTag(registry, imageName);
const r = await findRegistryByIdWithCredentials(registry.registryId);
const registryTag = getRegistryTag(r, imageName);
if (registryTag) {
commands.push(`echo "📦 [Enabled Registry Swarm]"`);
commands.push(getRegistryCommands(registry, imageName, registryTag));
commands.push(getRegistryCommands(r, imageName, registryTag));
}
}
if (buildRegistry) {
const buildRegistryTag = getRegistryTag(buildRegistry, imageName);
const r = await findRegistryByIdWithCredentials(buildRegistry.registryId);
const buildRegistryTag = getRegistryTag(r, imageName);
if (buildRegistryTag) {
commands.push(`echo "🔑 [Enabled Build Registry]"`);
commands.push(
getRegistryCommands(buildRegistry, imageName, buildRegistryTag),
);
commands.push(getRegistryCommands(r, imageName, buildRegistryTag));
commands.push(
`echo "⚠️ INFO: After the build is finished, you need to wait a few seconds for the server to download the image and run the container."`,
);
@@ -57,15 +62,13 @@ export const uploadImageRemoteCommand = async (
deploymentId: deploymentId,
});
const rollbackRegistryTag = getRegistryTag(
rollbackRegistry,
rollback?.image || "",
const r = await findRegistryByIdWithCredentials(
rollbackRegistry.registryId,
);
const rollbackRegistryTag = getRegistryTag(r, rollback?.image || "");
if (rollbackRegistryTag) {
commands.push(`echo "🔄 [Enabled Rollback Registry]"`);
commands.push(
getRegistryCommands(rollbackRegistry, imageName, rollbackRegistryTag),
);
commands.push(getRegistryCommands(r, imageName, rollbackRegistryTag));
}
}
try {
@@ -74,6 +77,7 @@ export const uploadImageRemoteCommand = async (
throw error;
}
};
/**
* Extract the repository name from imageName by taking the last part after '/'
* Examples:
@@ -115,19 +119,24 @@ const getRegistryCommands = (
imageName: string,
registryTag: string,
): string => {
const loginCmd = safeDockerLoginCommand(
registry.registryUrl,
registry.username,
registry.password,
);
return `
echo "📦 [Enabled Registry] Uploading image to '${registry.registryType}' | '${registryTag}'" ;
echo "${registry.password}" | docker login ${registry.registryUrl} -u '${registry.username}' --password-stdin || {
echo ${quote([`📦 [Enabled Registry] Uploading image to '${registry.registryType}' | '${registryTag}'`])} ;
${loginCmd} || {
echo "❌ DockerHub Failed" ;
exit 1;
}
echo "✅ Registry Login Success" ;
docker tag ${imageName} ${registryTag} || {
docker tag ${quote([imageName])} ${quote([registryTag])} || {
echo "❌ Error tagging image" ;
exit 1;
}
echo "✅ Image Tagged" ;
docker push ${registryTag} || {
docker push ${quote([registryTag])} || {
echo "❌ Error pushing image" ;
exit 1;
}
@@ -1,5 +1,6 @@
import type { InferResultType } from "@dokploy/server/types/with";
import type { CreateServiceOptions, PortConfig } from "dockerode";
import { resolveServiceNetworks } from "../../services/network";
import {
calculateResources,
generateBindMounts,
@@ -46,6 +47,8 @@ export const buildLibsql = async (libsql: LibsqlNested) => {
env ? `\n${env}` : ""
}${sqldNode === "replica" ? `\nSQLD_PRIMARY_URL="${sqldPrimaryUrl}"` : ""}`;
const resolvedNetworks = await resolveServiceNetworks(libsql);
const {
HealthCheck,
RestartPolicy,
@@ -54,7 +57,6 @@ export const buildLibsql = async (libsql: LibsqlNested) => {
Mode,
RollbackConfig,
UpdateConfig,
Networks,
} = generateConfigContainer(libsql);
const resources = calculateResources({
memoryLimit,
@@ -96,7 +98,7 @@ export const buildLibsql = async (libsql: LibsqlNested) => {
: {}),
Labels,
},
Networks,
Networks: resolvedNetworks,
RestartPolicy,
Placement,
Resources: {
@@ -140,7 +142,11 @@ export const buildLibsql = async (libsql: LibsqlNested) => {
: []),
],
},
UpdateConfig,
UpdateConfig: libsql.updateConfigSwarm ?? {
Parallelism: 1,
Order: "stop-first" as const,
FailureAction: "rollback" as const,
},
};
try {
const service = docker.getService(appName);
@@ -1,5 +1,6 @@
import type { InferResultType } from "@dokploy/server/types/with";
import type { CreateServiceOptions } from "dockerode";
import { resolveServiceNetworks } from "../../services/network";
import {
calculateResources,
generateBindMounts,
@@ -37,6 +38,8 @@ export const buildMariadb = async (mariadb: MariadbNested) => {
env ? `\n${env}` : ""
}`;
const resolvedNetworks = await resolveServiceNetworks(mariadb);
const {
HealthCheck,
RestartPolicy,
@@ -45,7 +48,6 @@ export const buildMariadb = async (mariadb: MariadbNested) => {
Mode,
RollbackConfig,
UpdateConfig,
Networks,
StopGracePeriod,
EndpointSpec,
Ulimits,
@@ -87,7 +89,7 @@ export const buildMariadb = async (mariadb: MariadbNested) => {
...(Ulimits && { Ulimits }),
Labels,
},
Networks,
Networks: resolvedNetworks,
RestartPolicy,
Placement,
Resources: {
@@ -111,7 +113,11 @@ export const buildMariadb = async (mariadb: MariadbNested) => {
]
: [],
},
UpdateConfig,
UpdateConfig: mariadb.updateConfigSwarm ?? {
Parallelism: 1,
Order: "stop-first" as const,
FailureAction: "rollback" as const,
},
};
try {
const service = docker.getService(appName);
+9 -3
View File
@@ -1,5 +1,6 @@
import type { InferResultType } from "@dokploy/server/types/with";
import type { CreateServiceOptions } from "dockerode";
import { resolveServiceNetworks } from "../../services/network";
import {
calculateResources,
generateBindMounts,
@@ -83,6 +84,8 @@ ${command ?? "wait $MONGOD_PID"}`;
env ? `\n${env}` : ""
}`;
const resolvedNetworks = await resolveServiceNetworks(mongo);
const {
HealthCheck,
RestartPolicy,
@@ -91,7 +94,6 @@ ${command ?? "wait $MONGOD_PID"}`;
Mode,
RollbackConfig,
UpdateConfig,
Networks,
StopGracePeriod,
EndpointSpec,
Ulimits,
@@ -143,7 +145,7 @@ ${command ?? "wait $MONGOD_PID"}`;
...(Ulimits && { Ulimits }),
Labels,
},
Networks,
Networks: resolvedNetworks,
RestartPolicy,
Placement,
Resources: {
@@ -167,7 +169,11 @@ ${command ?? "wait $MONGOD_PID"}`;
]
: [],
},
UpdateConfig,
UpdateConfig: mongo.updateConfigSwarm ?? {
Parallelism: 1,
Order: "stop-first" as const,
FailureAction: "rollback" as const,
},
};
try {
+9 -3
View File
@@ -1,5 +1,6 @@
import type { InferResultType } from "@dokploy/server/types/with";
import type { CreateServiceOptions } from "dockerode";
import { resolveServiceNetworks } from "../../services/network";
import {
calculateResources,
generateBindMounts,
@@ -43,6 +44,8 @@ export const buildMysql = async (mysql: MysqlNested) => {
env ? `\n${env}` : ""
}`;
const resolvedNetworks = await resolveServiceNetworks(mysql);
const {
HealthCheck,
RestartPolicy,
@@ -51,7 +54,6 @@ export const buildMysql = async (mysql: MysqlNested) => {
Mode,
RollbackConfig,
UpdateConfig,
Networks,
StopGracePeriod,
EndpointSpec,
Ulimits,
@@ -93,7 +95,7 @@ export const buildMysql = async (mysql: MysqlNested) => {
...(Ulimits && { Ulimits }),
Labels,
},
Networks,
Networks: resolvedNetworks,
RestartPolicy,
Placement,
Resources: {
@@ -117,7 +119,11 @@ export const buildMysql = async (mysql: MysqlNested) => {
]
: [],
},
UpdateConfig,
UpdateConfig: mysql.updateConfigSwarm ?? {
Parallelism: 1,
Order: "stop-first" as const,
FailureAction: "rollback" as const,
},
};
try {
const service = docker.getService(appName);
@@ -1,5 +1,6 @@
import type { InferResultType } from "@dokploy/server/types/with";
import type { CreateServiceOptions } from "dockerode";
import { resolveServiceNetworks } from "../../services/network";
import {
calculateResources,
generateBindMounts,
@@ -36,6 +37,8 @@ export const buildPostgres = async (postgres: PostgresNested) => {
env ? `\n${env}` : ""
}`;
const resolvedNetworks = await resolveServiceNetworks(postgres);
const {
HealthCheck,
RestartPolicy,
@@ -44,7 +47,6 @@ export const buildPostgres = async (postgres: PostgresNested) => {
Mode,
RollbackConfig,
UpdateConfig,
Networks,
StopGracePeriod,
EndpointSpec,
Ulimits,
@@ -85,7 +87,7 @@ export const buildPostgres = async (postgres: PostgresNested) => {
...(Ulimits && { Ulimits }),
Labels,
},
Networks,
Networks: resolvedNetworks,
RestartPolicy,
Placement,
Resources: {
@@ -109,7 +111,11 @@ export const buildPostgres = async (postgres: PostgresNested) => {
]
: [],
},
UpdateConfig,
UpdateConfig: postgres.updateConfigSwarm ?? {
Parallelism: 1,
Order: "stop-first" as const,
FailureAction: "rollback" as const,
},
};
try {
const service = docker.getService(appName);
@@ -14,6 +14,7 @@ import { deployMySql } from "@dokploy/server/services/mysql";
import { deployPostgres } from "@dokploy/server/services/postgres";
import { deployRedis } from "@dokploy/server/services/redis";
import { eq } from "drizzle-orm";
import { quote } from "shell-quote";
import { removeService } from "../docker/utils";
import { execAsync, execAsyncRemote } from "../process/execAsync";
@@ -40,7 +41,7 @@ export const rebuildDatabase = async (
for (const mount of database.mounts) {
if (mount.type === "volume") {
const command = `docker volume rm ${mount?.volumeName} --force`;
const command = `docker volume rm ${quote([mount?.volumeName ?? ""])} --force`;
if (database.serverId) {
await execAsyncRemote(database.serverId, command);
} else {
+9 -3
View File
@@ -1,5 +1,6 @@
import type { InferResultType } from "@dokploy/server/types/with";
import type { CreateServiceOptions } from "dockerode";
import { resolveServiceNetworks } from "../../services/network";
import {
calculateResources,
generateBindMounts,
@@ -34,6 +35,8 @@ export const buildRedis = async (redis: RedisNested) => {
env ? `\n${env}` : ""
}`;
const resolvedNetworks = await resolveServiceNetworks(redis);
const {
HealthCheck,
RestartPolicy,
@@ -42,7 +45,6 @@ export const buildRedis = async (redis: RedisNested) => {
Mode,
RollbackConfig,
UpdateConfig,
Networks,
StopGracePeriod,
EndpointSpec,
Ulimits,
@@ -91,7 +93,7 @@ export const buildRedis = async (redis: RedisNested) => {
...(Ulimits && { Ulimits }),
Labels,
},
Networks,
Networks: resolvedNetworks,
RestartPolicy,
Placement,
Resources: {
@@ -115,7 +117,11 @@ export const buildRedis = async (redis: RedisNested) => {
]
: [],
},
UpdateConfig,
UpdateConfig: redis.updateConfigSwarm ?? {
Parallelism: 1,
Order: "stop-first" as const,
FailureAction: "rollback" as const,
},
};
try {
@@ -26,11 +26,14 @@ export const addSuffixToVolumesInServices = (
if (_.has(newServiceConfig, "volumes")) {
newServiceConfig.volumes = _.map(newServiceConfig.volumes, (volume) => {
if (_.isString(volume)) {
const [volumeName, path] = volume.split(":");
// remainder is the container path plus optional access mode (:ro, :z, :Z)
const [volumeName, ...pathAndMode] = volume.split(":");
const remainder = pathAndMode.join(":");
// skip bind mounts and variables (e.g. $PWD)
if (
!volumeName ||
!remainder ||
volumeName.startsWith(".") ||
volumeName.startsWith("/") ||
volumeName.startsWith("$")
@@ -43,10 +46,10 @@ export const addSuffixToVolumesInServices = (
if (parts.length > 1) {
const baseName = parts[0];
const rest = parts.slice(1).join("/");
return `${baseName}-${suffix}/${rest}:${path}`;
return `${baseName}-${suffix}/${rest}:${remainder}`;
}
return `${volumeName}-${suffix}:${path}`;
return `${volumeName}-${suffix}:${remainder}`;
}
if (_.isObject(volume) && volume.type === "volume" && volume.source) {
return {
+102 -4
View File
@@ -1,8 +1,11 @@
import fs, { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { paths } from "@dokploy/server/constants";
import { db } from "@dokploy/server/db";
import { network } from "@dokploy/server/db/schema";
import type { Compose } from "@dokploy/server/services/compose";
import type { Domain } from "@dokploy/server/services/domain";
import { inArray } from "drizzle-orm";
import { parse, stringify } from "yaml";
import { execAsyncRemote } from "../process/execAsync";
import { cloneBitbucketRepository } from "../providers/bitbucket";
@@ -228,14 +231,79 @@ export const addDomainToCompose = async (
}
}
// Add dokploy-network to the root of the compose file
const injectedNetworkNames = await applyServiceNetworks(result, compose);
if (!compose.isolatedDeployment) {
result.networks = addDokployNetworkToRoot(result.networks);
declareUsedNetworksInRoot(result, injectedNetworkNames);
}
return result;
};
export const applyServiceNetworks = async (
result: ComposeSpecification,
compose: Compose,
) => {
const injectedNetworkNames = new Set<string>();
const serviceNetworks = compose.serviceNetworks ?? [];
if (serviceNetworks.length === 0) return injectedNetworkNames;
const allNetworkIds = [
...new Set(serviceNetworks.flatMap((s) => s.networkIds)),
];
const networks =
allNetworkIds.length > 0
? await db.query.network.findMany({
where: inArray(network.networkId, allNetworkIds),
})
: [];
for (const config of serviceNetworks) {
const service = result.services?.[config.serviceName];
if (!service) continue;
for (const networkId of config.networkIds) {
const match = networks.find((n) => n.networkId === networkId);
if (!match) continue;
service.networks = addDokployNetworkToService(
service.networks,
match.name,
);
injectedNetworkNames.add(match.name);
}
if (config.detachDokployNetwork) {
removeNetworkFromService(service, "dokploy-network");
removeNetworkFromService(service, "default");
removeDokployNetworkLabel(service);
}
}
return injectedNetworkNames;
};
export const declareUsedNetworksInRoot = (
result: ComposeSpecification,
injectedNetworkNames: Set<string>,
) => {
const isUsed = (name: string) =>
Object.values(result.services ?? {}).some((service) => {
const nets = service?.networks;
if (Array.isArray(nets)) return nets.includes(name);
if (nets && typeof nets === "object") return name in nets;
return false;
});
if (isUsed("dokploy-network")) {
result.networks = addDokployNetworkToRoot(result.networks);
}
for (const name of injectedNetworkNames) {
if (isUsed(name)) {
result.networks = addDokployNetworkToRoot(result.networks, name);
}
}
};
export const writeComposeFile = async (
compose: Compose,
composeSpec: ComposeSpecification,
@@ -349,9 +417,10 @@ export const createDomainLabels = (
export const addDokployNetworkToService = (
networkService: DefinitionsService["networks"],
networkName = "dokploy-network",
) => {
let networks = networkService;
const network = "dokploy-network";
const network = networkName;
const defaultNetwork = "default";
if (!networks) {
networks = [];
@@ -376,11 +445,40 @@ export const addDokployNetworkToService = (
return networks;
};
export const removeNetworkFromService = (
service: DefinitionsService,
networkName: string,
) => {
const networks = service.networks;
if (Array.isArray(networks)) {
service.networks = networks.filter((n) => n !== networkName);
} else if (networks && typeof networks === "object") {
delete networks[networkName];
}
};
const removeDokployNetworkLabel = (service: DefinitionsService) => {
const stripped = (labels: DefinitionsService["labels"]) => {
if (Array.isArray(labels)) {
return labels.filter(
(l) =>
l !== "traefik.docker.network=dokploy-network" &&
l !== "traefik.swarm.network=dokploy-network",
);
}
return labels;
};
if (service.labels) service.labels = stripped(service.labels);
if (service.deploy?.labels)
service.deploy.labels = stripped(service.deploy.labels);
};
export const addDokployNetworkToRoot = (
networkRoot: PropertiesNetworks | undefined,
networkName = "dokploy-network",
) => {
let networks = networkRoot;
const network = "dokploy-network";
const network = networkName;
if (!networks) {
networks = {};
+3 -55
View File
@@ -548,7 +548,6 @@ export const generateConfigContainer = (
labelsSwarm,
replicas,
mounts,
networkSwarm,
stopGracePeriodSwarm,
endpointSpecSwarm,
ulimitsSwarm,
@@ -611,13 +610,6 @@ export const generateConfigContainer = (
stopGracePeriodSwarm !== undefined && {
StopGracePeriod: stopGracePeriodSwarm,
}),
...(networkSwarm
? {
Networks: networkSwarm,
}
: {
Networks: [{ Target: "dokploy-network" }],
}),
...(endpointSpecSwarm && {
EndpointSpec: {
...(endpointSpecSwarm.Mode && { Mode: endpointSpecSwarm.Mode }),
@@ -712,14 +704,14 @@ export const getCreateFileCommand = (
) => {
const fullPath = path.join(outputPath, filePath);
if (fullPath.endsWith(path.sep) || filePath.endsWith("/")) {
return `mkdir -p ${fullPath};`;
return `mkdir -p ${quote([fullPath])};`;
}
const directory = path.dirname(fullPath);
const encodedContent = encodeBase64(content);
return `
mkdir -p ${directory};
echo "${encodedContent}" | base64 -d > "${fullPath}";
mkdir -p ${quote([directory])};
echo "${encodedContent}" | base64 -d > ${quote([fullPath])};
`;
};
@@ -900,50 +892,6 @@ export const checkPostgresHealth = async (): Promise<ServiceHealthStatus> => {
}
};
export const checkRedisHealth = async (): Promise<ServiceHealthStatus> => {
const serviceCheck = await checkSwarmServiceRunning("dokploy-redis");
if (serviceCheck.status === "unhealthy") {
return serviceCheck;
}
// Verify Redis actually responds to PING
const containerId = await getSwarmServiceContainerId("dokploy-redis");
if (!containerId) {
return { status: "unhealthy", message: "Could not find running container" };
}
try {
const exec = await docker.getContainer(containerId).exec({
Cmd: ["redis-cli", "ping"],
AttachStdout: true,
AttachStderr: true,
});
const stream = await exec.start({});
const output = await new Promise<string>((resolve) => {
let data = "";
stream.on("data", (chunk: Buffer) => {
data += chunk.toString();
});
stream.on("end", () => resolve(data));
});
if (!output.includes("PONG")) {
return {
status: "unhealthy",
message: `Redis did not respond with PONG: ${output.trim()}`,
};
}
return { status: "healthy" };
} catch (error) {
return {
status: "unhealthy",
message: error instanceof Error ? error.message : "Failed to check Redis",
};
}
};
export const checkTraefikHealth = async (): Promise<ServiceHealthStatus> => {
// Traefik can run as a standalone container or a swarm service
try {
@@ -1,3 +1,3 @@
// Valid git branch names per git-check-ref-format rules.
// Rejects shell metacharacters that would enable command injection.
export const VALID_BRANCH_REGEX = /^[a-zA-Z0-9._\-/]+$/;
export const VALID_BRANCH_REGEX = /^[a-zA-Z0-9._\-/#]+$/;
+3 -2
View File
@@ -1,4 +1,5 @@
import * as fs from "node:fs/promises";
import { quote } from "shell-quote";
import { execAsync, execAsyncRemote, sleep } from "../utils/process/execAsync";
interface GPUInfo {
@@ -322,7 +323,7 @@ const setupLocalServer = async (daemonConfig: any) => {
};
const addGpuLabel = async (nodeId: string, serverId?: string) => {
const labelCommand = `docker node update --label-add gpu=true ${nodeId}`;
const labelCommand = `docker node update --label-add gpu=true ${quote([nodeId])}`;
if (serverId) {
await execAsyncRemote(serverId, labelCommand);
} else {
@@ -335,7 +336,7 @@ const verifySetup = async (nodeId: string, serverId?: string) => {
if (!finalStatus.swarmEnabled) {
const diagnosticCommands = [
`docker node inspect ${nodeId}`,
`docker node inspect ${quote([nodeId])}`,
'nvidia-smi -a | grep "GPU UUID"',
"cat /etc/docker/daemon.json",
"cat /etc/nvidia-container-runtime/config.toml",
@@ -0,0 +1,8 @@
// Valid hostname per RFC 1123: labels of letters, digits and hyphens
// (no leading/trailing hyphen), 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])?$/;
export const INVALID_HOSTNAME_MESSAGE =
"Invalid domain name. Use only letters, numbers, hyphens and dots (e.g. example.com). Underscores are not allowed.";
@@ -1,7 +1,7 @@
import { db } from "@dokploy/server/db";
import { notifications } from "@dokploy/server/db/schema";
import BuildFailedEmail from "@dokploy/server/emails/emails/build-failed";
import { renderAsync } from "@react-email/components";
import { render } from "@react-email/components";
import { format } from "date-fns";
import { and, eq } from "drizzle-orm";
import {
@@ -76,7 +76,7 @@ export const sendBuildErrorNotifications = async ({
} = notification;
try {
if (email || resend) {
const template = await renderAsync(
const template = await render(
BuildFailedEmail({
projectName,
applicationName,
@@ -2,7 +2,7 @@ import { db } from "@dokploy/server/db";
import { notifications } from "@dokploy/server/db/schema";
import BuildSuccessEmail from "@dokploy/server/emails/emails/build-success";
import type { Domain } from "@dokploy/server/services/domain";
import { renderAsync } from "@react-email/components";
import { render } from "@react-email/components";
import { format } from "date-fns";
import { and, eq } from "drizzle-orm";
import {
@@ -79,7 +79,7 @@ export const sendBuildSuccessNotifications = async ({
} = notification;
try {
if (email || resend) {
const template = await renderAsync(
const template = await render(
BuildSuccessEmail({
projectName,
applicationName,
@@ -1,7 +1,7 @@
import { db } from "@dokploy/server/db";
import { notifications } from "@dokploy/server/db/schema";
import DatabaseBackupEmail from "@dokploy/server/emails/emails/database-backup";
import { renderAsync } from "@react-email/components";
import { render } from "@react-email/components";
import { format } from "date-fns";
import { and, eq } from "drizzle-orm";
import {
@@ -76,7 +76,7 @@ export const sendDatabaseBackupNotifications = async ({
} = notification;
try {
if (email || resend) {
const template = await renderAsync(
const template = await render(
DatabaseBackupEmail({
projectName,
applicationName,
@@ -1,7 +1,7 @@
import { db } from "@dokploy/server/db";
import { notifications } from "@dokploy/server/db/schema";
import DockerCleanupEmail from "@dokploy/server/emails/emails/docker-cleanup";
import { renderAsync } from "@react-email/components";
import { render } from "@react-email/components";
import { format } from "date-fns";
import { and, eq } from "drizzle-orm";
import {
@@ -63,7 +63,7 @@ export const sendDockerCleanupNotifications = async (
} = notification;
try {
if (email || resend) {
const template = await renderAsync(
const template = await render(
DockerCleanupEmail({ message, date: date.toLocaleString() }),
).catch();
@@ -1,7 +1,7 @@
import { db } from "@dokploy/server/db";
import { notifications } from "@dokploy/server/db/schema";
import DokployBackupEmail from "@dokploy/server/emails/emails/dokploy-backup";
import { renderAsync } from "@react-email/components";
import { render } from "@react-email/components";
import { format } from "date-fns";
import { eq } from "drizzle-orm";
import {
@@ -66,7 +66,7 @@ export const sendDokployBackupNotifications = async ({
try {
if (email || resend) {
const template = await renderAsync(
const template = await render(
DokployBackupEmail({
type,
errorMessage,
@@ -1,7 +1,7 @@
import { db } from "@dokploy/server/db";
import { notifications } from "@dokploy/server/db/schema";
import DokployRestartEmail from "@dokploy/server/emails/emails/dokploy-restart";
import { renderAsync } from "@react-email/components";
import { render } from "@react-email/components";
import { format } from "date-fns";
import { eq } from "drizzle-orm";
import {
@@ -59,7 +59,7 @@ export const sendDokployRestartNotifications = async () => {
try {
if (email || resend) {
const template = await renderAsync(
const template = await render(
DokployRestartEmail({ date: date.toLocaleString() }),
).catch();
@@ -1,7 +1,7 @@
import { db } from "@dokploy/server/db";
import { notifications } from "@dokploy/server/db/schema";
import { VolumeBackupEmail } from "@dokploy/server/emails/emails/volume-backup";
import { renderAsync } from "@react-email/components";
import { render } from "@react-email/components";
import { format } from "date-fns";
import { and, eq } from "drizzle-orm";
import {
@@ -88,7 +88,7 @@ export const sendVolumeBackupNotifications = async ({
try {
if (email || resend) {
const subject = `Volume Backup ${type === "success" ? "Successful" : "Failed"} - ${applicationName}`;
const htmlContent = await renderAsync(
const htmlContent = await render(
VolumeBackupEmail({
projectName,
applicationName,
@@ -10,6 +10,7 @@ import {
} from "@dokploy/server/services/bitbucket";
import type { InferResultType } from "@dokploy/server/types/with";
import { TRPCError } from "@trpc/server";
import { quote } from "shell-quote";
import type { z } from "zod";
export type ApplicationWithBitbucket = InferResultType<
@@ -124,8 +125,8 @@ export const cloneBitbucketRepository = async ({
const repoToUse = entity.bitbucketRepositorySlug || bitbucketRepository;
const repoclone = `bitbucket.org/${bitbucketOwner}/${repoToUse}.git`;
const cloneUrl = getBitbucketCloneUrl(bitbucket, repoclone);
command += `echo "Cloning Repo ${repoclone} to ${outputPath}: ✅";`;
command += `git clone --branch ${bitbucketBranch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${cloneUrl} ${outputPath} --progress;`;
command += `echo ${quote([`Cloning Repo ${repoclone} to ${outputPath}: ✅`])};`;
command += `git clone --branch ${quote([String(bitbucketBranch ?? "")])} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${quote([String(cloneUrl ?? "")])} ${quote([String(outputPath ?? "")])} --progress;`;
return command;
};
@@ -1,3 +1,5 @@
import { safeDockerLoginCommand } from "@dokploy/server/services/registry";
import { quote } from "shell-quote";
import type { ApplicationNested } from "../builders";
export const buildRemoteDocker = async (application: ApplicationNested) => {
@@ -8,12 +10,12 @@ export const buildRemoteDocker = async (application: ApplicationNested) => {
throw new Error("Docker image not found");
}
let command = `
echo "Pulling ${dockerImage}";
echo ${quote([`Pulling ${dockerImage}`])};
`;
if (username && password) {
command += `
if ! echo "${password}" | docker login --username "${username}" --password-stdin "${registryUrl || ""}" 2>&1; then
if ! ${safeDockerLoginCommand(registryUrl || "", username, password)} 2>&1; then
echo "❌ Login failed";
exit 1;
fi
@@ -21,7 +23,7 @@ fi
}
command += `
docker pull ${dockerImage} 2>&1 || {
docker pull ${quote([dockerImage])} 2>&1 || {
echo "❌ Pulling image failed";
exit 1;
}
+9 -5
View File
@@ -4,6 +4,7 @@ import {
findSSHKeyById,
updateSSHKeyById,
} from "@dokploy/server/services/ssh-key";
import { quote } from "shell-quote";
import { execAsync, execAsyncRemote } from "../process/execAsync";
interface CloneGitRepository {
@@ -61,7 +62,7 @@ export const cloneGitRepository = async ({
}
command += `rm -rf ${outputPath};`;
command += `mkdir -p ${outputPath};`;
command += `echo "Cloning Repo Custom ${customGitUrl} to ${outputPath}: ✅";`;
command += `echo ${quote([`Cloning Repo Custom ${customGitUrl} to ${outputPath}: ✅`])};`;
if (customGitSSHKeyId) {
await updateSSHKeyById({
@@ -73,13 +74,13 @@ export const cloneGitRepository = async ({
if (customGitSSHKeyId) {
const sshKey = await findSSHKeyById(customGitSSHKeyId);
const { port } = sanitizeRepoPathSSH(customGitUrl);
const gitSshCommand = `ssh -i /tmp/id_rsa${port ? ` -p ${port}` : ""} -o UserKnownHostsFile=${knownHostsPath}`;
const gitSshCommand = `ssh -i /tmp/id_rsa${port ? ` -p ${port}` : ""} -o UserKnownHostsFile=${knownHostsPath} -o StrictHostKeyChecking=accept-new`;
command += `echo "${sshKey.privateKey}" > /tmp/id_rsa;`;
command += "chmod 600 /tmp/id_rsa;";
command += `export GIT_SSH_COMMAND="${gitSshCommand}";`;
}
command += `if ! git clone --branch ${customGitBranch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} --progress ${customGitUrl} ${outputPath}; then
echo "❌ [ERROR] Fail to clone the repository ${customGitUrl}";
command += `if ! git clone --branch ${quote([String(customGitBranch ?? "")])} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} --progress ${quote([String(customGitUrl ?? "")])} ${quote([String(outputPath ?? "")])}; then
echo ${quote([`❌ [ERROR] Fail to clone the repository ${customGitUrl}`])};
exit 1;
fi
`;
@@ -111,7 +112,10 @@ const addHostToKnownHostsCommand = (repositoryURL: string) => {
const { domain, port } = sanitizeRepoPathSSH(repositoryURL);
const knownHostsPath = path.join(SSH_PATH, "known_hosts");
return `ssh-keyscan -p ${port} ${domain} >> ${knownHostsPath};`;
// ssh-keyscan is best-effort: some Git hosts (e.g. Hugging Face) never answer
// it, and its exit code must not abort the clone under `set -e`. The clone's
// own host-key check (StrictHostKeyChecking=accept-new) is the real boundary.
return `ssh-keyscan -p ${Number(port)} ${quote([String(domain ?? "")])} >> ${knownHostsPath} || true;`;
};
const sanitizeRepoPathSSH = (input: string) => {
const SSH_PATH_RE = new RegExp(
+3 -2
View File
@@ -7,6 +7,7 @@ import {
} from "@dokploy/server/services/gitea";
import type { InferResultType } from "@dokploy/server/types/with";
import { TRPCError } from "@trpc/server";
import { quote } from "shell-quote";
export const getErrorCloneRequirements = (entity: {
giteaRepository?: string | null;
@@ -176,8 +177,8 @@ export const cloneGiteaRepository = async ({
giteaRepository!,
);
command += `echo "Cloning Repo ${repoClone} to ${outputPath}: ✅";`;
command += `git clone --branch ${giteaBranch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${cloneUrl} ${outputPath} --progress;`;
command += `echo ${quote([`Cloning Repo ${repoClone} to ${outputPath}: ✅`])};`;
command += `git clone --branch ${quote([String(giteaBranch ?? "")])} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${quote([String(cloneUrl ?? "")])} ${quote([String(outputPath ?? "")])} --progress;`;
return command;
};
@@ -6,6 +6,7 @@ import type { InferResultType } from "@dokploy/server/types/with";
import { createAppAuth } from "@octokit/auth-app";
import { TRPCError } from "@trpc/server";
import { Octokit } from "octokit";
import { quote } from "shell-quote";
import type { z } from "zod";
export const authGithub = (githubProvider: Github): Octokit => {
@@ -166,8 +167,8 @@ export const cloneGithubRepository = async ({
command += `mkdir -p ${outputPath};`;
const cloneUrl = `https://oauth2:${token}@${repoclone}`;
command += `echo "Cloning Repo ${repoclone} to ${outputPath}: ✅";`;
command += `git clone --branch ${branch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${cloneUrl} ${outputPath} --progress;`;
command += `echo ${quote([`Cloning Repo ${repoclone} to ${outputPath}: ✅`])};`;
command += `git clone --branch ${quote([String(branch ?? "")])} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${quote([String(cloneUrl ?? "")])} ${quote([String(outputPath ?? "")])} --progress;`;
return command;
};
@@ -8,6 +8,7 @@ import {
} from "@dokploy/server/services/gitlab";
import type { InferResultType } from "@dokploy/server/types/with";
import { TRPCError } from "@trpc/server";
import { quote } from "shell-quote";
import type { z } from "zod";
export const refreshGitlabToken = async (gitlabProviderId: string) => {
@@ -151,8 +152,8 @@ export const cloneGitlabRepository = async ({
command += `mkdir -p ${outputPath};`;
const repoClone = getGitlabRepoClone(gitlab, gitlabPathNamespace);
const cloneUrl = getGitlabCloneUrl(gitlab, repoClone);
command += `echo "Cloning Repo ${repoClone} to ${outputPath}: ✅";`;
command += `git clone --branch ${gitlabBranch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${cloneUrl} ${outputPath} --progress;`;
command += `echo ${quote([`Cloning Repo ${repoClone} to ${outputPath}: ✅`])};`;
command += `git clone --branch ${quote([String(gitlabBranch ?? "")])} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${quote([String(cloneUrl ?? "")])} ${quote([String(outputPath ?? "")])} --progress;`;
return command;
};
+6 -5
View File
@@ -1,6 +1,7 @@
import type { apiRestoreBackup } from "@dokploy/server/db/schema";
import type { Compose } from "@dokploy/server/services/compose";
import type { Destination } from "@dokploy/server/services/destination";
import { quote } from "shell-quote";
import type { z } from "zod";
import { getS3Credentials } from "../backups/utils";
import { execAsync, execAsyncRemote } from "../process/execAsync";
@@ -26,10 +27,10 @@ export const restoreComposeBackup = async (
const rcloneFlags = getS3Credentials(destination);
const bucketPath = `:s3:${destination.bucket}`;
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
let rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`;
let rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} ${quote([backupPath])} | gunzip`;
if (backupInput.metadata?.mongo) {
rcloneCommand = `rclone copy ${rcloneFlags.join(" ")} "${backupPath}"`;
rcloneCommand = `rclone copy ${rcloneFlags.join(" ")} ${quote([backupPath])}`;
}
let credentials: DatabaseCredentials = {};
@@ -77,9 +78,9 @@ export const restoreComposeBackup = async (
});
emit("Starting restore...");
emit(`Backup path: ${backupPath}`);
emit(`Executing command: ${restoreCommand}`);
emit(
`Restoring database: ${backupInput.databaseName} from ${backupInput.backupFile}`,
);
if (serverId) {
await execAsyncRemote(serverId, restoreCommand);
+4 -5
View File
@@ -1,6 +1,7 @@
import type { apiRestoreBackup } from "@dokploy/server/db/schema";
import type { Destination } from "@dokploy/server/services/destination";
import type { Libsql } from "@dokploy/server/services/libsql";
import { quote } from "shell-quote";
import type { z } from "zod";
import { getS3Credentials, getServiceContainerCommand } from "../backups/utils";
import { execAsync, execAsyncRemote } from "../process/execAsync";
@@ -19,17 +20,15 @@ export const restoreLibsqlBackup = async (
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}"`;
emit("Starting restore...");
emit(`Backup path: ${backupPath}`);
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} ${quote([backupPath])}`;
const containerSearch = getServiceContainerCommand(appName);
const restoreCommand = `docker exec -i $CONTAINER_ID sh -c "tar xzf - -C /var/lib/sqld"`;
const command = `CONTAINER_ID=$(${containerSearch}) && ${rcloneCommand} | ${restoreCommand}`;
emit(`Executing command: ${command}`);
emit("Starting restore...");
emit(`Restoring libsql from ${backupInput.backupFile}`);
if (serverId) {
await execAsyncRemote(serverId, command);
+5 -3
View File
@@ -1,6 +1,7 @@
import type { apiRestoreBackup } from "@dokploy/server/db/schema";
import type { Destination } from "@dokploy/server/services/destination";
import type { Mariadb } from "@dokploy/server/services/mariadb";
import { quote } from "shell-quote";
import type { z } from "zod";
import { getS3Credentials } from "../backups/utils";
import { execAsync, execAsyncRemote } from "../process/execAsync";
@@ -19,7 +20,7 @@ export const restoreMariadbBackup = async (
const bucketPath = `:s3:${destination.bucket}`;
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`;
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} ${quote([backupPath])} | gunzip`;
const command = getRestoreCommand({
appName,
@@ -34,8 +35,9 @@ export const restoreMariadbBackup = async (
});
emit("Starting restore...");
emit(`Executing command: ${command}`);
emit(
`Restoring database: ${backupInput.databaseName} from ${backupInput.backupFile}`,
);
if (serverId) {
await execAsyncRemote(serverId, command);
+5 -3
View File
@@ -1,6 +1,7 @@
import type { apiRestoreBackup } from "@dokploy/server/db/schema";
import type { Destination } from "@dokploy/server/services/destination";
import type { Mongo } from "@dokploy/server/services/mongo";
import { quote } from "shell-quote";
import type { z } from "zod";
import { getS3Credentials } from "../backups/utils";
import { execAsync, execAsyncRemote } from "../process/execAsync";
@@ -18,7 +19,7 @@ export const restoreMongoBackup = async (
const rcloneFlags = getS3Credentials(destination);
const bucketPath = `:s3:${destination.bucket}`;
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
const rcloneCommand = `rclone copy ${rcloneFlags.join(" ")} "${backupPath}"`;
const rcloneCommand = `rclone copy ${rcloneFlags.join(" ")} ${quote([backupPath])}`;
const command = getRestoreCommand({
appName,
@@ -34,8 +35,9 @@ export const restoreMongoBackup = async (
});
emit("Starting restore...");
emit(`Executing command: ${command}`);
emit(
`Restoring database: ${backupInput.databaseName} from ${backupInput.backupFile}`,
);
if (serverId) {
await execAsyncRemote(serverId, command);
+5 -3
View File
@@ -1,6 +1,7 @@
import type { apiRestoreBackup } from "@dokploy/server/db/schema";
import type { Destination } from "@dokploy/server/services/destination";
import type { MySql } from "@dokploy/server/services/mysql";
import { quote } from "shell-quote";
import type { z } from "zod";
import { getS3Credentials } from "../backups/utils";
import { execAsync, execAsyncRemote } from "../process/execAsync";
@@ -19,7 +20,7 @@ export const restoreMySqlBackup = async (
const bucketPath = `:s3:${destination.bucket}`;
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`;
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} ${quote([backupPath])} | gunzip`;
const command = getRestoreCommand({
appName,
@@ -33,8 +34,9 @@ export const restoreMySqlBackup = async (
});
emit("Starting restore...");
emit(`Executing command: ${command}`);
emit(
`Restoring database: ${backupInput.databaseName} from ${backupInput.backupFile}`,
);
if (serverId) {
await execAsyncRemote(serverId, command);
@@ -1,6 +1,7 @@
import type { apiRestoreBackup } from "@dokploy/server/db/schema";
import type { Destination } from "@dokploy/server/services/destination";
import type { Postgres } from "@dokploy/server/services/postgres";
import { quote } from "shell-quote";
import type { z } from "zod";
import { getS3Credentials } from "../backups/utils";
import { execAsync, execAsyncRemote } from "../process/execAsync";
@@ -20,10 +21,7 @@ export const restorePostgresBackup = async (
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`;
emit("Starting restore...");
emit(`Backup path: ${backupPath}`);
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} ${quote([backupPath])} | gunzip`;
const command = getRestoreCommand({
appName,
@@ -36,7 +34,10 @@ export const restorePostgresBackup = async (
restoreType: "database",
});
emit(`Executing command: ${command}`);
emit("Starting restore...");
emit(
`Restoring database: ${backupInput.databaseName} from ${backupInput.backupFile}`,
);
if (serverId) {
await execAsyncRemote(serverId, command);
+10 -6
View File
@@ -1,13 +1,17 @@
import { quote } from "shell-quote";
import {
getComposeContainerCommand,
getServiceContainerCommand,
} from "../backups/utils";
// User-controlled values are passed to the container via `docker exec -e` and
// read as "$VAR" inside a single-quoted inner script, so they never enter the
// inner command text. See the matching note in backups/utils.ts.
export const getPostgresRestoreCommand = (
database: string,
databaseUser: string,
) => {
return `docker exec -i $CONTAINER_ID sh -c "pg_restore -U '${databaseUser}' -d ${database} -O --clean --if-exists"`;
return `docker exec -e DB_NAME=${quote([database])} -e DB_USER=${quote([databaseUser])} -i $CONTAINER_ID sh -c 'pg_restore -U "$DB_USER" -d "$DB_NAME" -O --clean --if-exists'`;
};
export const getMariadbRestoreCommand = (
@@ -15,14 +19,14 @@ export const getMariadbRestoreCommand = (
databaseUser: string,
databasePassword: string,
) => {
return `docker exec -i $CONTAINER_ID sh -c "mariadb -u '${databaseUser}' -p'${databasePassword}' ${database}"`;
return `docker exec -e DB_NAME=${quote([database])} -e DB_USER=${quote([databaseUser])} -e DB_PASS=${quote([databasePassword])} -i $CONTAINER_ID sh -c 'mariadb -u "$DB_USER" -p"$DB_PASS" "$DB_NAME"'`;
};
export const getMysqlRestoreCommand = (
database: string,
databasePassword: string,
) => {
return `docker exec -i $CONTAINER_ID sh -c "mysql -u root -p'${databasePassword}' ${database}"`;
return `docker exec -e DB_NAME=${quote([database])} -e DB_PASS=${quote([databasePassword])} -i $CONTAINER_ID sh -c 'mysql -u root -p"$DB_PASS" "$DB_NAME"'`;
};
export const getMongoRestoreCommand = (
@@ -30,7 +34,7 @@ export const getMongoRestoreCommand = (
databaseUser: string,
databasePassword: string,
) => {
return `docker exec -i $CONTAINER_ID sh -c "mongorestore --username '${databaseUser}' --password '${databasePassword}' --authenticationDatabase admin --db ${database} --archive --drop"`;
return `docker exec -e DB_NAME=${quote([database])} -e DB_USER=${quote([databaseUser])} -e DB_PASS=${quote([databasePassword])} -i $CONTAINER_ID sh -c 'mongorestore --username "$DB_USER" --password "$DB_PASS" --authenticationDatabase admin --db "$DB_NAME" --archive --drop'`;
};
export const getComposeSearchCommand = (
@@ -88,8 +92,8 @@ rm -rf ${tempDir} && \
mkdir -p ${tempDir} && \
${rcloneCommand} ${tempDir} && \
cd ${tempDir} && \
gunzip -f "${fileName}" && \
${restoreCommand} < "${decompressedName}" && \
gunzip -f ${quote([fileName])} && \
${restoreCommand} < ${quote([decompressedName])} && \
rm -rf ${tempDir}
`;
};
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { IS_CLOUD, paths } from "@dokploy/server/constants";
import type { Destination } from "@dokploy/server/services/destination";
import { quote } from "shell-quote";
import { getS3Credentials } from "../backups/utils";
import { execAsync } from "../process/execAsync";
@@ -35,7 +36,7 @@ export const restoreWebServerBackup = async (
// Download backup from S3
emit("Downloading backup from S3...");
await execAsync(
`rclone copyto ${rcloneFlags.join(" ")} "${backupPath}" "${tempDir}/${backupFile}"`,
`rclone copyto ${rcloneFlags.join(" ")} ${quote([backupPath])} ${quote([`${tempDir}/${backupFile}`])}`,
);
// List files before extraction
@@ -45,7 +46,9 @@ export const restoreWebServerBackup = async (
// Extract backup
emit("Extracting backup...");
await execAsync(`cd ${tempDir} && unzip ${backupFile} > /dev/null 2>&1`);
await execAsync(
`cd ${quote([tempDir])} && unzip ${quote([backupFile])} > /dev/null 2>&1`,
);
// Restore filesystem first
emit("Restoring filesystem...");
+6 -2
View File
@@ -9,8 +9,12 @@ export const initSchedules = async () => {
where: eq(schedules.enabled, true),
with: {
server: true,
application: true,
compose: true,
application: {
columns: { applicationId: true, appName: true, serverId: true },
},
compose: {
columns: { composeId: true, appName: true, serverId: true },
},
organization: true,
},
});
+83 -68
View File
@@ -9,6 +9,7 @@ import {
} from "@dokploy/server/services/deployment";
import { findScheduleById } from "@dokploy/server/services/schedule";
import { scheduledJobs, scheduleJob as scheduleJobNode } from "node-schedule";
import { quote } from "shell-quote";
import { getComposeContainer, getServiceContainer } from "../docker/utils";
import { execAsyncRemote } from "../process/execAsync";
import { spawnAsync } from "../process/spawnAsync";
@@ -54,78 +55,96 @@ export const runCommand = async (scheduleId: string) => {
description: "Schedule",
});
if (scheduleType === "application" || scheduleType === "compose") {
let containerId = "";
let serverId = "";
if (scheduleType === "application" && application) {
const container = await getServiceContainer(
application.appName,
application.serverId,
);
containerId = container?.Id || "";
serverId = application.serverId || "";
}
if (scheduleType === "compose" && compose) {
const container = await getComposeContainer(compose, serviceName || "");
containerId = container?.Id || "";
serverId = compose.serverId || "";
}
try {
if (scheduleType === "application" || scheduleType === "compose") {
let containerId = "";
let serverId = "";
if (scheduleType === "application" && application) {
const container = await getServiceContainer(
application.appName,
application.serverId,
);
containerId = container?.Id || "";
serverId = application.serverId || "";
}
if (scheduleType === "compose" && compose) {
const container = await getComposeContainer(compose, serviceName || "");
containerId = container?.Id || "";
serverId = compose.serverId || "";
}
if (serverId) {
try {
if (!containerId) {
const target =
scheduleType === "compose"
? `service '${serviceName}' of compose '${compose?.name}'`
: `application '${application?.appName}'`;
const message = `Container not found for ${target}, make sure the service is running`;
if (serverId) {
await execAsyncRemote(
serverId,
`echo ${quote([`❌ ${message}`])} >> ${quote([deployment.logPath])}`,
);
} else {
const writeStream = createWriteStream(deployment.logPath, {
flags: "a",
});
writeStream.write(`❌ ${message}\n`);
writeStream.end();
}
throw new Error(message);
}
if (serverId) {
await execAsyncRemote(
serverId,
`
set -e
echo "Running command: docker exec ${containerId} ${shellType} -c '${command}'" >> ${deployment.logPath};
docker exec ${containerId} ${shellType} -c '${command}' >> ${deployment.logPath} 2>> ${deployment.logPath} || {
echo "❌ Command failed" >> ${deployment.logPath};
echo "Running scheduled command" >> ${quote([deployment.logPath])};
docker exec ${quote([containerId])} ${quote([shellType])} -c ${quote([command])} >> ${quote([deployment.logPath])} 2>> ${quote([deployment.logPath])} || {
echo "❌ Command failed" >> ${quote([deployment.logPath])};
exit 1;
}
echo "✅ Command executed successfully" >> ${deployment.logPath};
echo "✅ Command executed successfully" >> ${quote([deployment.logPath])};
`,
);
} catch (error) {
await updateDeploymentStatus(deployment.deploymentId, "error");
throw error;
}
} else {
const writeStream = createWriteStream(deployment.logPath, { flags: "a" });
} else {
const writeStream = createWriteStream(deployment.logPath, {
flags: "a",
});
try {
if (IS_CLOUD) {
try {
if (IS_CLOUD) {
writeStream.write(
"This feature is not available in the cloud version.",
);
writeStream.end();
return { ...deployment, status: "running" as const };
}
writeStream.write(
"This feature is not available in the cloud version.",
`docker exec ${containerId} ${shellType} -c ${command}\n`,
);
await spawnAsync(
"docker",
["exec", containerId, shellType, "-c", command],
(data) => {
if (writeStream.writable) {
writeStream.write(data);
}
},
);
writeStream.write("✅ Command executed successfully\n");
writeStream.end();
} catch (error) {
writeStream.write("❌ Command failed\n");
writeStream.write(
error instanceof Error ? error.message : "Unknown error",
);
writeStream.end();
return;
throw error;
}
writeStream.write(
`docker exec ${containerId} ${shellType} -c ${command}\n`,
);
await spawnAsync(
"docker",
["exec", containerId, shellType, "-c", command],
(data) => {
if (writeStream.writable) {
writeStream.write(data);
}
},
);
writeStream.write("✅ Command executed successfully\n");
} catch (error) {
writeStream.write("❌ Command failed\n");
writeStream.write(
error instanceof Error ? error.message : "Unknown error",
);
writeStream.end();
await updateDeploymentStatus(deployment.deploymentId, "error");
throw error;
}
}
} else if (scheduleType === "dokploy-server") {
try {
} else if (scheduleType === "dokploy-server") {
const writeStream = createWriteStream(deployment.logPath, { flags: "a" });
const { SCHEDULES_PATH } = paths();
const fullPath = path.join(SCHEDULES_PATH, appName || "");
@@ -150,18 +169,13 @@ export const runCommand = async (scheduleId: string) => {
cwd: fullPath,
},
);
} catch (error) {
await updateDeploymentStatus(deployment.deploymentId, "error");
throw error;
}
} else if (scheduleType === "server") {
try {
} else if (scheduleType === "server") {
const { SCHEDULES_PATH } = paths(true);
const fullPath = path.join(SCHEDULES_PATH, appName || "");
const command = `
set -e
echo "Running script" >> ${deployment.logPath};
bash -c ${fullPath}/script.sh 2>&1 | tee -a ${deployment.logPath} || {
bash -c ${fullPath}/script.sh 2>&1 | tee -a ${deployment.logPath} || {
echo "❌ Command failed" >> ${deployment.logPath};
exit 1;
}
@@ -176,10 +190,11 @@ export const runCommand = async (scheduleId: string) => {
});
}
});
} catch (error) {
await updateDeploymentStatus(deployment.deploymentId, "error");
throw error;
}
await updateDeploymentStatus(deployment.deploymentId, "done");
return { ...deployment, status: "done" as const };
} catch {
await updateDeploymentStatus(deployment.deploymentId, "error");
return { ...deployment, status: "error" as const };
}
await updateDeploymentStatus(deployment.deploymentId, "done");
};
@@ -1,5 +1,5 @@
import { deployments } from "@dokploy/server/db/schema";
import { eq } from "drizzle-orm";
import { applications, compose, deployments } from "@dokploy/server/db/schema";
import { eq, inArray } from "drizzle-orm";
import { db } from "../../db/index";
export const initCancelDeployments = async () => {
@@ -14,6 +14,36 @@ export const initCancelDeployments = async () => {
.where(eq(deployments.status, "running"))
.returning();
// Reset the related services so they don't stay stuck in "running".
const applicationIds = [
...new Set(
result
.map((deployment) => deployment.applicationId)
.filter((id): id is string => !!id),
),
];
const composeIds = [
...new Set(
result
.map((deployment) => deployment.composeId)
.filter((id): id is string => !!id),
),
];
if (applicationIds.length > 0) {
await db
.update(applications)
.set({ applicationStatus: "idle" })
.where(inArray(applications.applicationId, applicationIds));
}
if (composeIds.length > 0) {
await db
.update(compose)
.set({ composeStatus: "idle" })
.where(inArray(compose.composeId, composeIds));
}
console.log(`Cancelled ${result.length} deployments`);
} catch (error) {
console.error(error);
@@ -3,6 +3,7 @@ import path from "node:path";
import { createInterface } from "node:readline";
import { paths } from "@dokploy/server/constants";
import type { Domain } from "@dokploy/server/services/domain";
import { quote } from "shell-quote";
import { parse, stringify } from "yaml";
import { encodeBase64 } from "../docker/utils";
import { execAsync, execAsyncRemote } from "../process/execAsync";
@@ -57,7 +58,7 @@ export const removeTraefikConfig = async (
try {
const { DYNAMIC_TRAEFIK_PATH } = paths(!!serverId);
const configPath = path.join(DYNAMIC_TRAEFIK_PATH, `${appName}.yml`);
const command = `rm -f ${configPath}`;
const command = `rm -f ${quote([configPath])}`;
if (serverId) {
await execAsyncRemote(serverId, command);
@@ -76,7 +77,7 @@ export const removeTraefikConfigRemote = async (
try {
const { DYNAMIC_TRAEFIK_PATH } = paths(true);
const configPath = path.join(DYNAMIC_TRAEFIK_PATH, `${appName}.yml`);
await execAsyncRemote(serverId, `rm -f ${configPath}`);
await execAsyncRemote(serverId, `rm -f ${quote([configPath])}`);
} catch (error) {
console.error(
`Error removing remote traefik config for ${appName}:`,
@@ -106,7 +107,10 @@ export const loadOrCreateConfigRemote = async (
const fileConfig: FileConfig = { http: { routers: {}, services: {} } };
const configPath = path.join(DYNAMIC_TRAEFIK_PATH, `${appName}.yml`);
try {
const { stdout } = await execAsyncRemote(serverId, `cat ${configPath}`);
const { stdout } = await execAsyncRemote(
serverId,
`cat ${quote([configPath])}`,
);
if (!stdout) return fileConfig;
@@ -133,7 +137,10 @@ export const readRemoteConfig = async (serverId: string, appName: string) => {
const { DYNAMIC_TRAEFIK_PATH } = paths(true);
const configPath = path.join(DYNAMIC_TRAEFIK_PATH, `${appName}.yml`);
try {
const { stdout } = await execAsyncRemote(serverId, `cat ${configPath}`);
const { stdout } = await execAsyncRemote(
serverId,
`cat ${quote([configPath])}`,
);
if (!stdout) return null;
return stdout;
} catch {
@@ -189,7 +196,10 @@ export const readConfigInPath = async (pathFile: string, serverId?: string) => {
const configPath = path.join(pathFile);
if (serverId) {
const { stdout } = await execAsyncRemote(serverId, `cat ${configPath}`);
const { stdout } = await execAsyncRemote(
serverId,
`cat ${quote([configPath])}`,
);
if (!stdout) return null;
return stdout;
}
@@ -221,7 +231,7 @@ export const writeConfigRemote = async (
const encoded = encodeBase64(traefikConfig);
await execAsyncRemote(
serverId,
`echo "${encoded}" | base64 -d > "${configPath}"`,
`echo "${encoded}" | base64 -d > ${quote([configPath])}`,
);
} catch (e) {
console.error("Error saving the YAML config file:", e);
@@ -239,7 +249,7 @@ export const writeTraefikConfigInPath = async (
const encoded = encodeBase64(traefikConfig);
await execAsyncRemote(
serverId,
`echo "${encoded}" | base64 -d > "${configPath}"`,
`echo "${encoded}" | base64 -d > ${quote([configPath])}`,
);
} else {
fs.writeFileSync(configPath, traefikConfig, "utf8");
@@ -272,7 +282,11 @@ export const writeTraefikConfigRemote = async (
const { DYNAMIC_TRAEFIK_PATH } = paths(true);
const configPath = path.join(DYNAMIC_TRAEFIK_PATH, `${appName}.yml`);
const yamlStr = stringify(traefikConfig);
await execAsyncRemote(serverId, `echo '${yamlStr}' > ${configPath}`);
const encoded = encodeBase64(yamlStr);
await execAsyncRemote(
serverId,
`echo "${encoded}" | base64 -d > ${quote([configPath])}`,
);
} catch (e) {
console.error("Error saving the YAML config file:", e);
}
@@ -10,6 +10,11 @@ import {
writeTraefikConfigRemote,
} from "./application";
import type { FileConfig, HttpRouter } from "./file-types";
import {
createForwardAuthMiddleware,
forwardAuthMiddlewareName,
removeForwardAuthMiddleware,
} from "./forward-auth";
import { createPathMiddlewares, removePathMiddlewares } from "./middleware";
export const manageDomain = async (app: ApplicationNested, domain: Domain) => {
@@ -48,6 +53,10 @@ export const manageDomain = async (app: ApplicationNested, domain: Domain) => {
config.http.services[serviceName] = createServiceConfig(appName, domain);
await createPathMiddlewares(app, domain);
// SSO forward-auth: writes the per-app forwardAuth + errors middlewares (the
// /oauth2/* router lives on the central auth domain, not here). No-op unless
// the domain links a provider and the org has an auth domain configured.
await createForwardAuthMiddleware(app, domain);
if (app.serverId) {
await writeTraefikConfigRemote(config, appName, app.serverId);
@@ -84,6 +93,7 @@ export const removeDomain = async (
}
await removePathMiddlewares(application, uniqueKey);
await removeForwardAuthMiddleware(application, uniqueKey);
// verify if is the last router if so we delete the router
if (
@@ -184,6 +194,16 @@ export const createRouterConfig = async (
routerConfig.middlewares?.push(middlewareName);
}
// Enterprise SSO forward-auth gate. Placed before custom middlewares so
// authentication runs first. No-op unless the domain links a provider.
// The -errors middleware must come first so a 401 from the auth check is
// rewritten to a 302 redirect to the login page.
if (domain.forwardAuthEnabled) {
const name = forwardAuthMiddlewareName(appName, uniqueConfigKey);
routerConfig.middlewares?.push(`${name}-errors`);
routerConfig.middlewares?.push(name);
}
// custom middlewares from domain
if (domain.middlewares && domain.middlewares.length > 0) {
routerConfig.middlewares?.push(...domain.middlewares);
@@ -652,6 +652,13 @@ export interface ErrorsMiddleware {
* The URL for the error page (hosted by service). You can use {status} in the query, that will be replaced by the received status code.
*/
query?: string;
/**
* Rewrites the returning status code, mapping the original status to a new one
* (e.g. { "401": 302 } so the browser follows the redirect to the login page).
*/
statusRewrites?: {
[k: string]: number;
};
}
/**
* The ForwardAuth middleware delegate the authentication to an external service. If the service response code is 2XX, access is granted and the original request is performed. Otherwise, the response from the authentication server is returned.
@@ -0,0 +1,204 @@
import { db } from "@dokploy/server/db";
import { forwardAuthSettings } from "@dokploy/server/db/schema";
import type { Domain } from "@dokploy/server/services/domain";
import {
FORWARD_AUTH_PORT,
FORWARD_AUTH_SERVICE_NAME,
} from "@dokploy/server/setup/forward-auth-setup";
import { eq, isNull } from "drizzle-orm";
import type { ApplicationNested } from "../builders";
import {
removeTraefikConfig,
removeTraefikConfigRemote,
writeTraefikConfig,
writeTraefikConfigRemote,
} from "./application";
import type { FileConfig } from "./file-types";
import {
loadMiddlewares,
loadRemoteMiddlewares,
writeMiddleware,
} from "./middleware";
export interface AuthDomainConfig {
authDomain: string;
https: boolean;
certificateType: "none" | "letsencrypt" | "custom";
customCertResolver?: string | null;
}
const TRAEFIK_SERVICE = "forward-auth-proxy";
export const forwardAuthMiddlewareName = (
appName: string,
uniqueConfigKey: number,
): string => `forward-auth-${appName}-${uniqueConfigKey}`;
const proxyUrl = () =>
`http://${FORWARD_AUTH_SERVICE_NAME}:${FORWARD_AUTH_PORT}`;
const loadOrEmptyMiddlewares = async (
serverId: string | null,
): Promise<FileConfig> => {
try {
return serverId
? await loadRemoteMiddlewares(serverId)
: loadMiddlewares<FileConfig>();
} catch {
return { http: { middlewares: {} } };
}
};
const persistMiddlewares = async (
config: FileConfig,
serverId: string | null,
) => {
if (serverId) {
await writeTraefikConfigRemote(config, "middlewares", serverId);
} else {
writeMiddleware(config);
}
};
const loadAuthGateDomain = async (serverId: string | null) => {
return db.query.forwardAuthSettings.findFirst({
where: serverId
? eq(forwardAuthSettings.serverId, serverId)
: isNull(forwardAuthSettings.serverId),
columns: { authDomain: true, https: true },
});
};
export const createForwardAuthMiddleware = async (
app: ApplicationNested,
domain: Domain,
) => {
if (!domain.forwardAuthEnabled) {
return;
}
const authGate = await loadAuthGateDomain(app.serverId ?? null);
if (!authGate) {
return;
}
const authDomain = authGate.authDomain;
const authDomainHttps = authGate.https;
const { appName, serverId } = app;
const config = await loadOrEmptyMiddlewares(serverId);
config.http = config.http || {};
config.http.middlewares = config.http.middlewares || {};
const name = forwardAuthMiddlewareName(appName, domain.uniqueConfigKey);
const scheme = authDomainHttps ? "https" : "http";
config.http.middlewares[name] = {
forwardAuth: {
address: `${scheme}://${authDomain}/oauth2/auth`,
trustForwardHeader: true,
authResponseHeaders: [
"X-Auth-Request-User",
"X-Auth-Request-Email",
"X-Auth-Request-Preferred-Username",
"Authorization",
],
},
};
config.http.middlewares[`${name}-errors`] = {
errors: {
status: ["401-403"],
service: TRAEFIK_SERVICE,
query: "/oauth2/sign_in?rd={url}",
statusRewrites: { "401": 302 },
},
};
await persistMiddlewares(config, serverId);
};
export const removeForwardAuthMiddleware = async (
app: ApplicationNested,
uniqueConfigKey: number,
) => {
const { appName, serverId } = app;
let config: FileConfig;
try {
config = serverId
? await loadRemoteMiddlewares(serverId)
: loadMiddlewares<FileConfig>();
} catch {
return;
}
const name = forwardAuthMiddlewareName(appName, uniqueConfigKey);
let changed = false;
for (const key of [name, `${name}-errors`]) {
if (config.http?.middlewares?.[key]) {
delete config.http.middlewares[key];
changed = true;
}
}
if (changed) {
await persistMiddlewares(config, serverId);
}
};
export const buildAuthDomainRouter = (cfg: AuthDomainConfig): FileConfig => {
const entry = cfg.https ? "websecure" : "web";
const oauthRouter: NonNullable<
NonNullable<FileConfig["http"]>["routers"]
>[string] = {
rule: `Host(\`${cfg.authDomain}\`) && PathPrefix(\`/oauth2/\`)`,
service: TRAEFIK_SERVICE,
entryPoints: [entry],
priority: 1000,
};
if (cfg.https) {
if (cfg.certificateType === "letsencrypt") {
oauthRouter.tls = { certResolver: "letsencrypt" };
} else if (cfg.certificateType === "custom" && cfg.customCertResolver) {
oauthRouter.tls = { certResolver: cfg.customCertResolver };
} else {
oauthRouter.tls = {};
}
}
return {
http: {
routers: { "forward-auth-oauth": oauthRouter },
services: {
[TRAEFIK_SERVICE]: {
loadBalancer: {
servers: [{ url: proxyUrl() }],
passHostHeader: true,
},
},
},
},
};
};
export const authDomainConfigName = "forward-auth-domain";
export const manageForwardAuthDomain = async (
serverId: string | null,
cfg: AuthDomainConfig,
) => {
const config = buildAuthDomainRouter(cfg);
if (serverId) {
await writeTraefikConfigRemote(config, authDomainConfigName, serverId);
} else {
writeTraefikConfig(config, authDomainConfigName);
}
};
export const removeForwardAuthDomain = async (serverId: string | null) => {
if (serverId) {
await removeTraefikConfigRemote(authDomainConfigName, serverId);
} else {
await removeTraefikConfig(authDomainConfigName);
}
};
@@ -1,6 +1,7 @@
import path from "node:path";
import { paths } from "@dokploy/server/constants";
import { findComposeById } from "@dokploy/server/services/compose";
import { findDestinationById } from "@dokploy/server/services/destination";
import type { findVolumeBackupById } from "@dokploy/server/services/volume-backups";
import {
getBackupTimestamp,
@@ -31,14 +32,14 @@ export const backupVolume = async (
volumeBackup: Awaited<ReturnType<typeof findVolumeBackupById>>,
) => {
const { serviceType, volumeName, turnOff, prefix } = volumeBackup;
const destination = await findDestinationById(volumeBackup.destinationId);
const serverId =
volumeBackup.application?.serverId || volumeBackup.compose?.serverId;
const { VOLUME_BACKUPS_PATH, VOLUME_BACKUP_LOCK_PATH } = paths(!!serverId);
const destination = volumeBackup.destination;
const s3AppName = getVolumeServiceAppName(volumeBackup);
const backupFileName = `${volumeName}-${getBackupTimestamp()}.tar`;
const bucketDestination = `${s3AppName}/${normalizeS3Path(prefix || "")}${backupFileName}`;
const rcloneFlags = getS3Credentials(volumeBackup.destination);
const rcloneFlags = getS3Credentials(destination);
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
const volumeBackupPath = path.join(VOLUME_BACKUPS_PATH, volumeBackup.appName);
@@ -13,8 +13,8 @@ export const initVolumeBackupsCronJobs = async () => {
const volumeBackupsResult = await db.query.volumeBackups.findMany({
where: eq(volumeBackups.enabled, true),
with: {
application: true,
compose: true,
application: { columns: { applicationId: true } },
compose: { columns: { composeId: true } },
},
});
@@ -1,4 +1,5 @@
import path from "node:path";
import { quote } from "shell-quote";
import {
findApplicationById,
findComposeById,
@@ -23,7 +24,7 @@ export const restoreVolume = async (
const backupPath = `${bucketPath}/${backupFileName}`;
// Command to download backup file from S3
const downloadCommand = `rclone copyto ${rcloneFlags.join(" ")} "${backupPath}" "${volumeBackupPath}/${backupFileName}"`;
const downloadCommand = `rclone copyto ${rcloneFlags.join(" ")} ${quote([backupPath])} ${quote([`${volumeBackupPath}/${backupFileName}`])}`;
// Base restore command that creates the volume and restores data
const baseRestoreCommand = `
@@ -40,7 +41,7 @@ export const restoreVolume = async (
-v ${volumeName}:/volume_data \
-v ${volumeBackupPath}:/backup \
ubuntu \
bash -c "cd /volume_data && tar xvf /backup/${backupFileName} ."
bash -c "cd /volume_data && tar xvf /backup/${quote([backupFileName])} ."
echo "Volume restore completed ✅"
`;
@@ -4,6 +4,7 @@ import {
createDeploymentVolumeBackup,
updateDeploymentStatus,
} from "@dokploy/server/services/deployment";
import { findDestinationById } from "@dokploy/server/services/destination";
import { findVolumeBackupById } from "@dokploy/server/services/volume-backups";
import {
execAsync,
@@ -77,7 +78,8 @@ const cleanupOldVolumeBackups = async (
volumeBackup: Awaited<ReturnType<typeof findVolumeBackupById>>,
serverId?: string | null,
) => {
const { keepLatestCount, destination, prefix, volumeName } = volumeBackup;
const { keepLatestCount, prefix, volumeName } = volumeBackup;
const destination = await findDestinationById(volumeBackup.destinationId);
if (!keepLatestCount) return;