diff --git a/apps/dokploy/components/dashboard/shared/show-database-advanced-settings.tsx b/apps/dokploy/components/dashboard/shared/show-database-advanced-settings.tsx
index bf2089498..e8117aa29 100644
--- a/apps/dokploy/components/dashboard/shared/show-database-advanced-settings.tsx
+++ b/apps/dokploy/components/dashboard/shared/show-database-advanced-settings.tsx
@@ -3,13 +3,15 @@ import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes
import { ShowCustomCommand } from "@/components/dashboard/postgres/advanced/show-custom-command";
import { ShowClusterSettings } from "../application/advanced/cluster/show-cluster-settings";
import { RebuildDatabase } from "./rebuild-database";
+import { TransferService } from "./transfer-service";
interface Props {
id: string;
type: "libsql" | "mariadb" | "mongo" | "mysql" | "postgres" | "redis";
+ serverId?: string | null;
}
-export const ShowDatabaseAdvancedSettings = ({ id, type }: Props) => {
+export const ShowDatabaseAdvancedSettings = ({ id, type, serverId }: Props) => {
return (
@@ -23,6 +25,13 @@ export const ShowDatabaseAdvancedSettings = ({ id, type }: Props) => {
+ {type !== "libsql" && (
+
+ )}
);
};
diff --git a/apps/dokploy/components/dashboard/shared/transfer-service.tsx b/apps/dokploy/components/dashboard/shared/transfer-service.tsx
new file mode 100644
index 000000000..575b7dbd2
--- /dev/null
+++ b/apps/dokploy/components/dashboard/shared/transfer-service.tsx
@@ -0,0 +1,493 @@
+import { AlertTriangle, ArrowRightLeft, Loader2, Server } from "lucide-react";
+import { useState } from "react";
+import { toast } from "sonner";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { api } from "@/utils/api";
+
+type ServiceType =
+ | "application"
+ | "compose"
+ | "postgres"
+ | "mysql"
+ | "mariadb"
+ | "mongo"
+ | "redis";
+
+interface TransferServiceProps {
+ serviceId: string;
+ serviceType: ServiceType;
+ currentServerId: string | null;
+}
+
+interface ScanResult {
+ serviceDirectory: {
+ files: Array<{
+ path: string;
+ status: string;
+ sourceFile: { path: string; size: number; modifiedAt: number };
+ targetFile?: { path: string; size: number; modifiedAt: number };
+ }>;
+ totalSize: number;
+ };
+ traefikConfig: {
+ exists: boolean;
+ hasConflict: boolean;
+ };
+ mounts: Array<{
+ mount: {
+ mountId: string;
+ type: string;
+ volumeName?: string | null;
+ hostPath?: string | null;
+ mountPath: string;
+ };
+ files: Array<{
+ path: string;
+ status: string;
+ }>;
+ totalSize: number;
+ }>;
+ totalTransferSize: number;
+ totalFiles: number;
+ conflicts: Array<{
+ path: string;
+ status: string;
+ }>;
+}
+
+const formatBytes = (bytes: number): string => {
+ if (bytes === 0) return "0 B";
+ const k = 1024;
+ const sizes = ["B", "KB", "MB", "GB", "TB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
+};
+
+const useTransferMutations = (serviceType: ServiceType) => {
+ const appScan = api.application.transferScan.useMutation();
+ const appTransfer = api.application.transfer.useMutation();
+ const composeScan = api.compose.transferScan.useMutation();
+ const composeTransfer = api.compose.transfer.useMutation();
+ const postgresScan = api.postgres.transferScan.useMutation();
+ const postgresTransfer = api.postgres.transfer.useMutation();
+ const mysqlScan = api.mysql.transferScan.useMutation();
+ const mysqlTransfer = api.mysql.transfer.useMutation();
+ const mariadbScan = api.mariadb.transferScan.useMutation();
+ const mariadbTransfer = api.mariadb.transfer.useMutation();
+ const mongoScan = api.mongo.transferScan.useMutation();
+ const mongoTransfer = api.mongo.transfer.useMutation();
+ const redisScan = api.redis.transferScan.useMutation();
+ const redisTransfer = api.redis.transfer.useMutation();
+
+ const mutations: Record<
+ ServiceType,
+ {
+ scan: { mutateAsync: (input: any) => Promise; isPending: boolean };
+ transfer: {
+ mutateAsync: (input: any) => Promise;
+ isPending: boolean;
+ };
+ }
+ > = {
+ application: { scan: appScan, transfer: appTransfer },
+ compose: { scan: composeScan, transfer: composeTransfer },
+ postgres: { scan: postgresScan, transfer: postgresTransfer },
+ mysql: { scan: mysqlScan, transfer: mysqlTransfer },
+ mariadb: { scan: mariadbScan, transfer: mariadbTransfer },
+ mongo: { scan: mongoScan, transfer: mongoTransfer },
+ redis: { scan: redisScan, transfer: redisTransfer },
+ };
+
+ return mutations[serviceType];
+};
+
+const getServiceIdKey = (serviceType: ServiceType): string => {
+ const map: Record = {
+ application: "applicationId",
+ compose: "composeId",
+ postgres: "postgresId",
+ mysql: "mysqlId",
+ mariadb: "mariadbId",
+ mongo: "mongoId",
+ redis: "redisId",
+ };
+ return map[serviceType];
+};
+
+export const TransferService = ({
+ serviceId,
+ serviceType,
+ currentServerId,
+}: TransferServiceProps) => {
+ const [targetServerId, setTargetServerId] = useState("");
+ const [scanResult, setScanResult] = useState(null);
+ const [step, setStep] = useState<"select" | "scan" | "confirm" | "transfer">(
+ "select",
+ );
+ const [showConfirm, setShowConfirm] = useState(false);
+ const [transferLogs, setTransferLogs] = useState([]);
+
+ const { data: servers } = api.server.all.useQuery();
+ const utils = api.useUtils();
+ const { scan, transfer } = useTransferMutations(serviceType);
+
+ const idKey = getServiceIdKey(serviceType);
+
+ const availableServers = servers?.filter(
+ (s) => s.serverId !== currentServerId,
+ );
+
+ const selectedServer = servers?.find((s) => s.serverId === targetServerId);
+
+ const handleScan = async () => {
+ if (!targetServerId) {
+ toast.error("Please select a target server");
+ return;
+ }
+
+ setStep("scan");
+ try {
+ const result = await scan.mutateAsync({
+ [idKey]: serviceId,
+ targetServerId,
+ });
+ setScanResult(result as ScanResult);
+ setStep("confirm");
+ } catch (error) {
+ toast.error(
+ `Scan failed: ${error instanceof Error ? error.message : "Unknown error"}`,
+ );
+ setStep("select");
+ }
+ };
+
+ const handleTransfer = async () => {
+ setShowConfirm(false);
+ setStep("transfer");
+ setTransferLogs([]);
+
+ try {
+ await transfer.mutateAsync({
+ [idKey]: serviceId,
+ targetServerId,
+ decisions: {},
+ });
+
+ toast.success("Transfer completed successfully!");
+ setTransferLogs((prev) => [...prev, "Transfer completed successfully!"]);
+
+ await utils.invalidate();
+
+ setTimeout(() => {
+ setStep("select");
+ setScanResult(null);
+ setTargetServerId("");
+ }, 3000);
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : "Unknown error";
+ toast.error(`Transfer failed: ${message}`);
+ setTransferLogs((prev) => [...prev, `Transfer failed: ${message}`]);
+ setStep("confirm");
+ }
+ };
+
+ const isDbService = ["postgres", "mysql", "mariadb", "mongo", "redis"].includes(
+ serviceType,
+ );
+
+ return (
+
+
+
+
+ Transfer Service
+
+
+ Transfer this {serviceType} service to a different server. Source data
+ is never modified or deleted.
+
+
+
+ {!availableServers?.length ? (
+
+
+
+ No other servers available. Add a remote server first.
+
+
+ ) : (
+ <>
+ {/* Step 1: Select target server */}
+
+ Target Server
+ {
+ setTargetServerId(value);
+ setScanResult(null);
+ setStep("select");
+ }}
+ disabled={step === "transfer"}
+ >
+
+
+
+
+
+ {availableServers.map((server) => (
+
+
+ {server.name}
+
+ {server.ipAddress}
+
+
+
+ ))}
+
+ Servers ({availableServers.length})
+
+
+
+
+
+
+ {/* Scan button */}
+ {step === "select" && targetServerId && (
+
+ {scan.isPending ? (
+ <>
+
+ Scanning...
+ >
+ ) : (
+ "Scan for Transfer"
+ )}
+
+ )}
+
+ {/* Step 2: Scan in progress */}
+ {step === "scan" && (
+
+
+
+ Scanning source and target servers for files and
+ conflicts...
+
+
+ )}
+
+ {/* Step 3: Scan results + confirm */}
+ {step === "confirm" && scanResult && (
+
+
+
Scan Results
+
+
+
+ Total Files:
+ {" "}
+
+ {scanResult.totalFiles}
+
+
+
+
+ Transfer Size:
+ {" "}
+
+ {formatBytes(scanResult.totalTransferSize)}
+
+
+
+
+ Volumes/Mounts:
+ {" "}
+
+ {scanResult.mounts.length}
+
+
+
+
+ Conflicts:
+ {" "}
+ 0
+ ? "destructive"
+ : "secondary"
+ }
+ >
+ {scanResult.conflicts.length}
+
+
+
+ {scanResult.traefikConfig.exists && (
+
+
+ Traefik Config:
+ {" "}
+ Will be synced
+
+ )}
+
+
+ {/* Conflict list */}
+ {scanResult.conflicts.length > 0 && (
+
+
+ File Conflicts (will be overwritten)
+
+
+ {scanResult.conflicts.map((conflict) => (
+
+
+ {conflict.status}
+
+
+ {conflict.path}
+
+
+ ))}
+
+
+ )}
+
+ {/* Warning */}
+
+
+
+
+ Service Downtime Warning
+
+
+
+ {isDbService
+ ? "Stop the database service before transferring to avoid data corruption. The service will be unavailable until deployed on the target server."
+ : "The service will be unavailable during transfer. After transfer completes, deploy the service on the target server to start it."}
+
+
+
+ {/* Transfer button */}
+
+
{
+ setStep("select");
+ setScanResult(null);
+ }}
+ >
+ Cancel
+
+
setShowConfirm(true)}
+ disabled={transfer.isPending}
+ >
+
+ Transfer to {selectedServer?.name}
+
+
+
+ )}
+
+ {/* Step 4: Transfer in progress */}
+ {step === "transfer" && (
+
+
+
+
+ Transferring service...
+
+
+ {transferLogs.length > 0 && (
+
+ {transferLogs.map((log, i) => (
+
+ {log}
+
+ ))}
+
+ )}
+
+ )}
+ >
+ )}
+
+ {/* Confirmation dialog */}
+
+
+
+ Confirm Service Transfer
+
+
+ You are about to transfer this {serviceType} to{" "}
+ {selectedServer?.name} (
+ {selectedServer?.ipAddress}).
+
+ {scanResult && (
+
+ {scanResult.totalFiles} files (
+ {formatBytes(scanResult.totalTransferSize)}) will be
+ copied.
+
+ )}
+
+ The service will experience downtime during this
+ process. After transfer, you must deploy the service on
+ the target server.
+
+
+
+
+ Cancel
+
+ Confirm Transfer
+
+
+
+
+
+
+ );
+};
diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx
index be4518c7b..591700d9e 100644
--- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx
+++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx
@@ -18,6 +18,7 @@ import { ShowPorts } from "@/components/dashboard/application/advanced/ports/sho
import { ShowRedirects } from "@/components/dashboard/application/advanced/redirects/show-redirects";
import { ShowSecurity } from "@/components/dashboard/application/advanced/security/show-security";
import { ShowBuildServer } from "@/components/dashboard/application/advanced/show-build-server";
+import { TransferService } from "@/components/dashboard/shared/transfer-service";
import { ShowResources } from "@/components/dashboard/application/advanced/show-resources";
import { ShowTraefikConfig } from "@/components/dashboard/application/advanced/traefik/show-traefik-config";
import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes";
@@ -419,6 +420,11 @@ const Service = (
+
)}
diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx
index ba575706d..8261136d1 100644
--- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx
+++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx
@@ -22,6 +22,7 @@ import { ShowSchedules } from "@/components/dashboard/application/schedules/show
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
import { IsolatedDeploymentTab } from "@/components/dashboard/compose/advanced/add-isolation";
+import { TransferService } from "@/components/dashboard/shared/transfer-service";
import { ShowComposeContainers } from "@/components/dashboard/compose/containers/show-compose-containers";
import { DeleteService } from "@/components/dashboard/compose/delete-service";
import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show";
@@ -423,6 +424,11 @@ const Service = (
+
)}
diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mariadb/[mariadbId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mariadb/[mariadbId].tsx
index b54804279..f43d4ff47 100644
--- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mariadb/[mariadbId].tsx
+++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mariadb/[mariadbId].tsx
@@ -303,6 +303,7 @@ const Mariadb = (
diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mongo/[mongoId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mongo/[mongoId].tsx
index 402718625..f5383c7f1 100644
--- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mongo/[mongoId].tsx
+++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mongo/[mongoId].tsx
@@ -307,6 +307,7 @@ const Mongo = (
diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mysql/[mysqlId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mysql/[mysqlId].tsx
index 55e1ee6e0..d3c0541b5 100644
--- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mysql/[mysqlId].tsx
+++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mysql/[mysqlId].tsx
@@ -284,6 +284,7 @@ const MySql = (
diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/postgres/[postgresId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/postgres/[postgresId].tsx
index f73ce35cb..b287b0d05 100644
--- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/postgres/[postgresId].tsx
+++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/postgres/[postgresId].tsx
@@ -292,6 +292,7 @@ const Postgresql = (
diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/redis/[redisId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/redis/[redisId].tsx
index 8847ed891..37fc252dd 100644
--- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/redis/[redisId].tsx
+++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/redis/[redisId].tsx
@@ -296,6 +296,7 @@ const Redis = (
diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts
index 228c98656..34701d9a6 100644
--- a/apps/dokploy/server/api/routers/application.ts
+++ b/apps/dokploy/server/api/routers/application.ts
@@ -28,6 +28,8 @@ import {
updateDeploymentStatus,
writeConfig,
writeConfigRemote,
+ scanServiceForTransfer,
+ executeTransfer,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
@@ -62,6 +64,7 @@ import {
apiSaveGithubProvider,
apiSaveGitlabProvider,
apiSaveGitProvider,
+ apiTransferApplication,
apiUpdateApplication,
applications,
environments,
@@ -1137,4 +1140,138 @@ export const applicationRouter = createTRPCRouter({
application.serverId,
);
}),
+
+ transferScan: protectedProcedure
+ .input(apiTransferApplication.pick({ applicationId: true, targetServerId: true }))
+ .mutation(async ({ input, ctx }) => {
+ const application = await findApplicationById(input.applicationId);
+ await checkServicePermissionAndAccess(ctx, input.applicationId, {
+ service: ["delete"],
+ });
+ if (
+ application.environment.project.organizationId !==
+ ctx.session.activeOrganizationId
+ ) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You are not authorized to access this application",
+ });
+ }
+
+ return await scanServiceForTransfer({
+ serviceId: input.applicationId,
+ serviceType: "application",
+ appName: application.appName,
+ sourceServerId: application.serverId,
+ targetServerId: input.targetServerId,
+ });
+ }),
+
+ transferWithLogs: protectedProcedure
+ .input(apiTransferApplication)
+ .subscription(async function* ({ input, ctx, signal }) {
+ const application = await findApplicationById(input.applicationId);
+ await checkServicePermissionAndAccess(ctx, input.applicationId, {
+ service: ["delete"],
+ });
+ if (
+ application.environment.project.organizationId !==
+ ctx.session.activeOrganizationId
+ ) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You are not authorized to access this application",
+ });
+ }
+
+ const queue: string[] = [];
+ let done = false;
+
+ executeTransfer(
+ {
+ serviceId: input.applicationId,
+ serviceType: "application",
+ appName: application.appName,
+ sourceServerId: application.serverId,
+ targetServerId: input.targetServerId,
+ },
+ input.decisions || {},
+ (progress) => {
+ queue.push(JSON.stringify(progress));
+ },
+ )
+ .then(async (result) => {
+ if (result.success) {
+ await db
+ .update(applications)
+ .set({ serverId: input.targetServerId })
+ .where(eq(applications.applicationId, input.applicationId));
+ queue.push("Transfer completed successfully!");
+ } else {
+ queue.push(`Transfer failed: ${result.errors.join(", ")}`);
+ }
+ })
+ .catch((error) => {
+ queue.push(
+ `Transfer error: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ })
+ .finally(() => {
+ done = true;
+ });
+
+ while (!done || queue.length > 0) {
+ if (queue.length > 0) {
+ yield queue.shift()!;
+ } else {
+ await new Promise((r) => setTimeout(r, 50));
+ }
+ if (signal?.aborted) {
+ return;
+ }
+ }
+ }),
+
+ transfer: protectedProcedure
+ .input(apiTransferApplication)
+ .mutation(async ({ input, ctx }) => {
+ const application = await findApplicationById(input.applicationId);
+ await checkServicePermissionAndAccess(ctx, input.applicationId, {
+ service: ["delete"],
+ });
+ if (
+ application.environment.project.organizationId !==
+ ctx.session.activeOrganizationId
+ ) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You are not authorized to access this application",
+ });
+ }
+
+ const result = await executeTransfer(
+ {
+ serviceId: input.applicationId,
+ serviceType: "application",
+ appName: application.appName,
+ sourceServerId: application.serverId,
+ targetServerId: input.targetServerId,
+ },
+ input.decisions || {},
+ );
+
+ if (!result.success) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: `Transfer failed: ${result.errors.join(", ")}`,
+ });
+ }
+
+ await db
+ .update(applications)
+ .set({ serverId: input.targetServerId })
+ .where(eq(applications.applicationId, input.applicationId));
+
+ return { success: true };
+ }),
});
diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts
index d395bdffc..1384a225f 100644
--- a/apps/dokploy/server/api/routers/compose.ts
+++ b/apps/dokploy/server/api/routers/compose.ts
@@ -32,6 +32,8 @@ import {
stopCompose,
updateCompose,
updateDeploymentStatus,
+ scanServiceForTransfer,
+ executeTransfer,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
@@ -63,6 +65,7 @@ import {
apiRandomizeCompose,
apiRedeployCompose,
apiSaveEnvironmentVariablesCompose,
+ apiTransferCompose,
apiUpdateCompose,
compose as composeTable,
environments,
@@ -1171,4 +1174,138 @@ export const composeRouter = createTRPCRouter({
true,
);
}),
+
+ transferScan: protectedProcedure
+ .input(apiTransferCompose.pick({ composeId: true, targetServerId: true }))
+ .mutation(async ({ input, ctx }) => {
+ const compose = await findComposeById(input.composeId);
+ await checkServicePermissionAndAccess(ctx, input.composeId, {
+ service: ["delete"],
+ });
+ if (
+ compose.environment.project.organizationId !==
+ ctx.session.activeOrganizationId
+ ) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You are not authorized to access this compose",
+ });
+ }
+
+ return await scanServiceForTransfer({
+ serviceId: input.composeId,
+ serviceType: "compose",
+ appName: compose.appName,
+ sourceServerId: compose.serverId,
+ targetServerId: input.targetServerId,
+ });
+ }),
+
+ transferWithLogs: protectedProcedure
+ .input(apiTransferCompose)
+ .subscription(async function* ({ input, ctx, signal }) {
+ const compose = await findComposeById(input.composeId);
+ await checkServicePermissionAndAccess(ctx, input.composeId, {
+ service: ["delete"],
+ });
+ if (
+ compose.environment.project.organizationId !==
+ ctx.session.activeOrganizationId
+ ) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You are not authorized to access this compose",
+ });
+ }
+
+ const queue: string[] = [];
+ let done = false;
+
+ executeTransfer(
+ {
+ serviceId: input.composeId,
+ serviceType: "compose",
+ appName: compose.appName,
+ sourceServerId: compose.serverId,
+ targetServerId: input.targetServerId,
+ },
+ input.decisions || {},
+ (progress) => {
+ queue.push(JSON.stringify(progress));
+ },
+ )
+ .then(async (result) => {
+ if (result.success) {
+ await db
+ .update(composeTable)
+ .set({ serverId: input.targetServerId })
+ .where(eq(composeTable.composeId, input.composeId));
+ queue.push("Transfer completed successfully!");
+ } else {
+ queue.push(`Transfer failed: ${result.errors.join(", ")}`);
+ }
+ })
+ .catch((error) => {
+ queue.push(
+ `Transfer error: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ })
+ .finally(() => {
+ done = true;
+ });
+
+ while (!done || queue.length > 0) {
+ if (queue.length > 0) {
+ yield queue.shift()!;
+ } else {
+ await new Promise((r) => setTimeout(r, 50));
+ }
+ if (signal?.aborted) {
+ return;
+ }
+ }
+ }),
+
+ transfer: protectedProcedure
+ .input(apiTransferCompose)
+ .mutation(async ({ input, ctx }) => {
+ const compose = await findComposeById(input.composeId);
+ await checkServicePermissionAndAccess(ctx, input.composeId, {
+ service: ["delete"],
+ });
+ if (
+ compose.environment.project.organizationId !==
+ ctx.session.activeOrganizationId
+ ) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You are not authorized to access this compose",
+ });
+ }
+
+ const result = await executeTransfer(
+ {
+ serviceId: input.composeId,
+ serviceType: "compose",
+ appName: compose.appName,
+ sourceServerId: compose.serverId,
+ targetServerId: input.targetServerId,
+ },
+ input.decisions || {},
+ );
+
+ if (!result.success) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: `Transfer failed: ${result.errors.join(", ")}`,
+ });
+ }
+
+ await db
+ .update(composeTable)
+ .set({ serverId: input.targetServerId })
+ .where(eq(composeTable.composeId, input.composeId));
+
+ return { success: true };
+ }),
});
diff --git a/apps/dokploy/server/api/routers/mariadb.ts b/apps/dokploy/server/api/routers/mariadb.ts
index a58a33a9c..e0d3a2b80 100644
--- a/apps/dokploy/server/api/routers/mariadb.ts
+++ b/apps/dokploy/server/api/routers/mariadb.ts
@@ -21,6 +21,8 @@ import {
stopService,
stopServiceRemote,
updateMariadbById,
+ scanServiceForTransfer,
+ executeTransfer,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
@@ -44,6 +46,7 @@ import {
apiResetMariadb,
apiSaveEnvironmentVariablesMariaDB,
apiSaveExternalPortMariaDB,
+ apiTransferMariadb,
apiUpdateMariaDB,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
@@ -626,4 +629,120 @@ export const mariadbRouter = createTRPCRouter({
mariadb.serverId,
);
}),
+
+ transferScan: protectedProcedure
+ .input(apiTransferMariadb.pick({ mariadbId: true, targetServerId: true }))
+ .mutation(async ({ input, ctx }) => {
+ const mariadb = await findMariadbById(input.mariadbId);
+ await checkServicePermissionAndAccess(ctx, input.mariadbId, {
+ service: ["delete"],
+ });
+ if (
+ mariadb.environment.project.organizationId !==
+ ctx.session.activeOrganizationId
+ ) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You are not authorized to access this MariaDB",
+ });
+ }
+ return await scanServiceForTransfer({
+ serviceId: input.mariadbId,
+ serviceType: "mariadb",
+ appName: mariadb.appName,
+ sourceServerId: mariadb.serverId,
+ targetServerId: input.targetServerId,
+ });
+ }),
+
+ transferWithLogs: protectedProcedure
+ .input(apiTransferMariadb)
+ .subscription(async function* ({ input, ctx, signal }) {
+ const mariadb = await findMariadbById(input.mariadbId);
+ await checkServicePermissionAndAccess(ctx, input.mariadbId, {
+ service: ["delete"],
+ });
+ if (
+ mariadb.environment.project.organizationId !==
+ ctx.session.activeOrganizationId
+ ) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You are not authorized to access this MariaDB",
+ });
+ }
+ const queue: string[] = [];
+ let done = false;
+ executeTransfer(
+ {
+ serviceId: input.mariadbId,
+ serviceType: "mariadb",
+ appName: mariadb.appName,
+ sourceServerId: mariadb.serverId,
+ targetServerId: input.targetServerId,
+ },
+ input.decisions || {},
+ (progress) => { queue.push(JSON.stringify(progress)); },
+ )
+ .then(async (result) => {
+ if (result.success) {
+ await db
+ .update(mariadbTable)
+ .set({ serverId: input.targetServerId })
+ .where(eq(mariadbTable.mariadbId, input.mariadbId));
+ queue.push("Transfer completed successfully!");
+ } else {
+ queue.push(`Transfer failed: ${result.errors.join(", ")}`);
+ }
+ })
+ .catch((error) => {
+ queue.push(`Transfer error: ${error instanceof Error ? error.message : String(error)}`);
+ })
+ .finally(() => { done = true; });
+
+ while (!done || queue.length > 0) {
+ if (queue.length > 0) { yield queue.shift()!; }
+ else { await new Promise((r) => setTimeout(r, 50)); }
+ if (signal?.aborted) { return; }
+ }
+ }),
+
+ transfer: protectedProcedure
+ .input(apiTransferMariadb)
+ .mutation(async ({ input, ctx }) => {
+ const mariadb = await findMariadbById(input.mariadbId);
+ await checkServicePermissionAndAccess(ctx, input.mariadbId, {
+ service: ["delete"],
+ });
+ if (
+ mariadb.environment.project.organizationId !==
+ ctx.session.activeOrganizationId
+ ) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You are not authorized to access this MariaDB",
+ });
+ }
+ const result = await executeTransfer(
+ {
+ serviceId: input.mariadbId,
+ serviceType: "mariadb",
+ appName: mariadb.appName,
+ sourceServerId: mariadb.serverId,
+ targetServerId: input.targetServerId,
+ },
+ input.decisions || {},
+ );
+ if (!result.success) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: `Transfer failed: ${result.errors.join(", ")}`,
+ });
+ }
+ await db
+ .update(mariadbTable)
+ .set({ serverId: input.targetServerId })
+ .where(eq(mariadbTable.mariadbId, input.mariadbId));
+ return { success: true };
+ }),
});
diff --git a/apps/dokploy/server/api/routers/mongo.ts b/apps/dokploy/server/api/routers/mongo.ts
index 222917b9f..11cc115f3 100644
--- a/apps/dokploy/server/api/routers/mongo.ts
+++ b/apps/dokploy/server/api/routers/mongo.ts
@@ -21,6 +21,8 @@ import {
stopService,
stopServiceRemote,
updateMongoById,
+ scanServiceForTransfer,
+ executeTransfer,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
@@ -43,6 +45,7 @@ import {
apiResetMongo,
apiSaveEnvironmentVariablesMongo,
apiSaveExternalPortMongo,
+ apiTransferMongo,
apiUpdateMongo,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
@@ -637,4 +640,120 @@ export const mongoRouter = createTRPCRouter({
mongo.serverId,
);
}),
+
+ transferScan: protectedProcedure
+ .input(apiTransferMongo.pick({ mongoId: true, targetServerId: true }))
+ .mutation(async ({ input, ctx }) => {
+ const mongo = await findMongoById(input.mongoId);
+ await checkServicePermissionAndAccess(ctx, input.mongoId, {
+ service: ["delete"],
+ });
+ if (
+ mongo.environment.project.organizationId !==
+ ctx.session.activeOrganizationId
+ ) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You are not authorized to access this MongoDB",
+ });
+ }
+ return await scanServiceForTransfer({
+ serviceId: input.mongoId,
+ serviceType: "mongo",
+ appName: mongo.appName,
+ sourceServerId: mongo.serverId,
+ targetServerId: input.targetServerId,
+ });
+ }),
+
+ transferWithLogs: protectedProcedure
+ .input(apiTransferMongo)
+ .subscription(async function* ({ input, ctx, signal }) {
+ const mongo = await findMongoById(input.mongoId);
+ await checkServicePermissionAndAccess(ctx, input.mongoId, {
+ service: ["delete"],
+ });
+ if (
+ mongo.environment.project.organizationId !==
+ ctx.session.activeOrganizationId
+ ) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You are not authorized to access this MongoDB",
+ });
+ }
+ const queue: string[] = [];
+ let done = false;
+ executeTransfer(
+ {
+ serviceId: input.mongoId,
+ serviceType: "mongo",
+ appName: mongo.appName,
+ sourceServerId: mongo.serverId,
+ targetServerId: input.targetServerId,
+ },
+ input.decisions || {},
+ (progress) => { queue.push(JSON.stringify(progress)); },
+ )
+ .then(async (result) => {
+ if (result.success) {
+ await db
+ .update(mongoTable)
+ .set({ serverId: input.targetServerId })
+ .where(eq(mongoTable.mongoId, input.mongoId));
+ queue.push("Transfer completed successfully!");
+ } else {
+ queue.push(`Transfer failed: ${result.errors.join(", ")}`);
+ }
+ })
+ .catch((error) => {
+ queue.push(`Transfer error: ${error instanceof Error ? error.message : String(error)}`);
+ })
+ .finally(() => { done = true; });
+
+ while (!done || queue.length > 0) {
+ if (queue.length > 0) { yield queue.shift()!; }
+ else { await new Promise((r) => setTimeout(r, 50)); }
+ if (signal?.aborted) { return; }
+ }
+ }),
+
+ transfer: protectedProcedure
+ .input(apiTransferMongo)
+ .mutation(async ({ input, ctx }) => {
+ const mongo = await findMongoById(input.mongoId);
+ await checkServicePermissionAndAccess(ctx, input.mongoId, {
+ service: ["delete"],
+ });
+ if (
+ mongo.environment.project.organizationId !==
+ ctx.session.activeOrganizationId
+ ) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You are not authorized to access this MongoDB",
+ });
+ }
+ const result = await executeTransfer(
+ {
+ serviceId: input.mongoId,
+ serviceType: "mongo",
+ appName: mongo.appName,
+ sourceServerId: mongo.serverId,
+ targetServerId: input.targetServerId,
+ },
+ input.decisions || {},
+ );
+ if (!result.success) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: `Transfer failed: ${result.errors.join(", ")}`,
+ });
+ }
+ await db
+ .update(mongoTable)
+ .set({ serverId: input.targetServerId })
+ .where(eq(mongoTable.mongoId, input.mongoId));
+ return { success: true };
+ }),
});
diff --git a/apps/dokploy/server/api/routers/mysql.ts b/apps/dokploy/server/api/routers/mysql.ts
index 263fa53f0..40b519f56 100644
--- a/apps/dokploy/server/api/routers/mysql.ts
+++ b/apps/dokploy/server/api/routers/mysql.ts
@@ -21,6 +21,8 @@ import {
stopServiceRemote,
updateMySqlById,
getAccessibleServerIds,
+ scanServiceForTransfer,
+ executeTransfer,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
@@ -43,6 +45,7 @@ import {
apiResetMysql,
apiSaveEnvironmentVariablesMySql,
apiSaveExternalPortMySql,
+ apiTransferMysql,
apiUpdateMySql,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
@@ -640,4 +643,120 @@ export const mysqlRouter = createTRPCRouter({
mysql.serverId,
);
}),
+
+ transferScan: protectedProcedure
+ .input(apiTransferMysql.pick({ mysqlId: true, targetServerId: true }))
+ .mutation(async ({ input, ctx }) => {
+ const mysql = await findMySqlById(input.mysqlId);
+ await checkServicePermissionAndAccess(ctx, input.mysqlId, {
+ service: ["delete"],
+ });
+ if (
+ mysql.environment.project.organizationId !==
+ ctx.session.activeOrganizationId
+ ) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You are not authorized to access this MySQL",
+ });
+ }
+ return await scanServiceForTransfer({
+ serviceId: input.mysqlId,
+ serviceType: "mysql",
+ appName: mysql.appName,
+ sourceServerId: mysql.serverId,
+ targetServerId: input.targetServerId,
+ });
+ }),
+
+ transferWithLogs: protectedProcedure
+ .input(apiTransferMysql)
+ .subscription(async function* ({ input, ctx, signal }) {
+ const mysql = await findMySqlById(input.mysqlId);
+ await checkServicePermissionAndAccess(ctx, input.mysqlId, {
+ service: ["delete"],
+ });
+ if (
+ mysql.environment.project.organizationId !==
+ ctx.session.activeOrganizationId
+ ) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You are not authorized to access this MySQL",
+ });
+ }
+ const queue: string[] = [];
+ let done = false;
+ executeTransfer(
+ {
+ serviceId: input.mysqlId,
+ serviceType: "mysql",
+ appName: mysql.appName,
+ sourceServerId: mysql.serverId,
+ targetServerId: input.targetServerId,
+ },
+ input.decisions || {},
+ (progress) => { queue.push(JSON.stringify(progress)); },
+ )
+ .then(async (result) => {
+ if (result.success) {
+ await db
+ .update(mysqlTable)
+ .set({ serverId: input.targetServerId })
+ .where(eq(mysqlTable.mysqlId, input.mysqlId));
+ queue.push("Transfer completed successfully!");
+ } else {
+ queue.push(`Transfer failed: ${result.errors.join(", ")}`);
+ }
+ })
+ .catch((error) => {
+ queue.push(`Transfer error: ${error instanceof Error ? error.message : String(error)}`);
+ })
+ .finally(() => { done = true; });
+
+ while (!done || queue.length > 0) {
+ if (queue.length > 0) { yield queue.shift()!; }
+ else { await new Promise((r) => setTimeout(r, 50)); }
+ if (signal?.aborted) { return; }
+ }
+ }),
+
+ transfer: protectedProcedure
+ .input(apiTransferMysql)
+ .mutation(async ({ input, ctx }) => {
+ const mysql = await findMySqlById(input.mysqlId);
+ await checkServicePermissionAndAccess(ctx, input.mysqlId, {
+ service: ["delete"],
+ });
+ if (
+ mysql.environment.project.organizationId !==
+ ctx.session.activeOrganizationId
+ ) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You are not authorized to access this MySQL",
+ });
+ }
+ const result = await executeTransfer(
+ {
+ serviceId: input.mysqlId,
+ serviceType: "mysql",
+ appName: mysql.appName,
+ sourceServerId: mysql.serverId,
+ targetServerId: input.targetServerId,
+ },
+ input.decisions || {},
+ );
+ if (!result.success) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: `Transfer failed: ${result.errors.join(", ")}`,
+ });
+ }
+ await db
+ .update(mysqlTable)
+ .set({ serverId: input.targetServerId })
+ .where(eq(mysqlTable.mysqlId, input.mysqlId));
+ return { success: true };
+ }),
});
diff --git a/apps/dokploy/server/api/routers/postgres.ts b/apps/dokploy/server/api/routers/postgres.ts
index 33d8fd3f4..d7cf295dd 100644
--- a/apps/dokploy/server/api/routers/postgres.ts
+++ b/apps/dokploy/server/api/routers/postgres.ts
@@ -22,6 +22,8 @@ import {
stopServiceRemote,
updatePostgresById,
getAccessibleServerIds,
+ scanServiceForTransfer,
+ executeTransfer,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
@@ -44,6 +46,7 @@ import {
apiResetPostgres,
apiSaveEnvironmentVariablesPostgres,
apiSaveExternalPortPostgres,
+ apiTransferPostgres,
apiUpdatePostgres,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
@@ -650,4 +653,120 @@ export const postgresRouter = createTRPCRouter({
postgres.serverId,
);
}),
+
+ transferScan: protectedProcedure
+ .input(apiTransferPostgres.pick({ postgresId: true, targetServerId: true }))
+ .mutation(async ({ input, ctx }) => {
+ const postgres = await findPostgresById(input.postgresId);
+ await checkServicePermissionAndAccess(ctx, input.postgresId, {
+ service: ["delete"],
+ });
+ if (
+ postgres.environment.project.organizationId !==
+ ctx.session.activeOrganizationId
+ ) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You are not authorized to access this Postgres",
+ });
+ }
+ return await scanServiceForTransfer({
+ serviceId: input.postgresId,
+ serviceType: "postgres",
+ appName: postgres.appName,
+ sourceServerId: postgres.serverId,
+ targetServerId: input.targetServerId,
+ });
+ }),
+
+ transferWithLogs: protectedProcedure
+ .input(apiTransferPostgres)
+ .subscription(async function* ({ input, ctx, signal }) {
+ const postgres = await findPostgresById(input.postgresId);
+ await checkServicePermissionAndAccess(ctx, input.postgresId, {
+ service: ["delete"],
+ });
+ if (
+ postgres.environment.project.organizationId !==
+ ctx.session.activeOrganizationId
+ ) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You are not authorized to access this Postgres",
+ });
+ }
+ const queue: string[] = [];
+ let done = false;
+ executeTransfer(
+ {
+ serviceId: input.postgresId,
+ serviceType: "postgres",
+ appName: postgres.appName,
+ sourceServerId: postgres.serverId,
+ targetServerId: input.targetServerId,
+ },
+ input.decisions || {},
+ (progress) => { queue.push(JSON.stringify(progress)); },
+ )
+ .then(async (result) => {
+ if (result.success) {
+ await db
+ .update(postgresTable)
+ .set({ serverId: input.targetServerId })
+ .where(eq(postgresTable.postgresId, input.postgresId));
+ queue.push("Transfer completed successfully!");
+ } else {
+ queue.push(`Transfer failed: ${result.errors.join(", ")}`);
+ }
+ })
+ .catch((error) => {
+ queue.push(`Transfer error: ${error instanceof Error ? error.message : String(error)}`);
+ })
+ .finally(() => { done = true; });
+
+ while (!done || queue.length > 0) {
+ if (queue.length > 0) { yield queue.shift()!; }
+ else { await new Promise((r) => setTimeout(r, 50)); }
+ if (signal?.aborted) { return; }
+ }
+ }),
+
+ transfer: protectedProcedure
+ .input(apiTransferPostgres)
+ .mutation(async ({ input, ctx }) => {
+ const postgres = await findPostgresById(input.postgresId);
+ await checkServicePermissionAndAccess(ctx, input.postgresId, {
+ service: ["delete"],
+ });
+ if (
+ postgres.environment.project.organizationId !==
+ ctx.session.activeOrganizationId
+ ) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You are not authorized to access this Postgres",
+ });
+ }
+ const result = await executeTransfer(
+ {
+ serviceId: input.postgresId,
+ serviceType: "postgres",
+ appName: postgres.appName,
+ sourceServerId: postgres.serverId,
+ targetServerId: input.targetServerId,
+ },
+ input.decisions || {},
+ );
+ if (!result.success) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: `Transfer failed: ${result.errors.join(", ")}`,
+ });
+ }
+ await db
+ .update(postgresTable)
+ .set({ serverId: input.targetServerId })
+ .where(eq(postgresTable.postgresId, input.postgresId));
+ return { success: true };
+ }),
});
diff --git a/apps/dokploy/server/api/routers/redis.ts b/apps/dokploy/server/api/routers/redis.ts
index a1e912e0b..0635a8225 100644
--- a/apps/dokploy/server/api/routers/redis.ts
+++ b/apps/dokploy/server/api/routers/redis.ts
@@ -20,6 +20,8 @@ import {
stopServiceRemote,
updateRedisById,
getAccessibleServerIds,
+ scanServiceForTransfer,
+ executeTransfer,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
@@ -42,6 +44,7 @@ import {
apiResetRedis,
apiSaveEnvironmentVariablesRedis,
apiSaveExternalPortRedis,
+ apiTransferRedis,
apiUpdateRedis,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
@@ -623,4 +626,120 @@ export const redisRouter = createTRPCRouter({
redis.serverId,
);
}),
+
+ transferScan: protectedProcedure
+ .input(apiTransferRedis.pick({ redisId: true, targetServerId: true }))
+ .mutation(async ({ input, ctx }) => {
+ const redis = await findRedisById(input.redisId);
+ await checkServicePermissionAndAccess(ctx, input.redisId, {
+ service: ["delete"],
+ });
+ if (
+ redis.environment.project.organizationId !==
+ ctx.session.activeOrganizationId
+ ) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You are not authorized to access this Redis",
+ });
+ }
+ return await scanServiceForTransfer({
+ serviceId: input.redisId,
+ serviceType: "redis",
+ appName: redis.appName,
+ sourceServerId: redis.serverId,
+ targetServerId: input.targetServerId,
+ });
+ }),
+
+ transferWithLogs: protectedProcedure
+ .input(apiTransferRedis)
+ .subscription(async function* ({ input, ctx, signal }) {
+ const redis = await findRedisById(input.redisId);
+ await checkServicePermissionAndAccess(ctx, input.redisId, {
+ service: ["delete"],
+ });
+ if (
+ redis.environment.project.organizationId !==
+ ctx.session.activeOrganizationId
+ ) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You are not authorized to access this Redis",
+ });
+ }
+ const queue: string[] = [];
+ let done = false;
+ executeTransfer(
+ {
+ serviceId: input.redisId,
+ serviceType: "redis",
+ appName: redis.appName,
+ sourceServerId: redis.serverId,
+ targetServerId: input.targetServerId,
+ },
+ input.decisions || {},
+ (progress) => { queue.push(JSON.stringify(progress)); },
+ )
+ .then(async (result) => {
+ if (result.success) {
+ await db
+ .update(redisTable)
+ .set({ serverId: input.targetServerId })
+ .where(eq(redisTable.redisId, input.redisId));
+ queue.push("Transfer completed successfully!");
+ } else {
+ queue.push(`Transfer failed: ${result.errors.join(", ")}`);
+ }
+ })
+ .catch((error) => {
+ queue.push(`Transfer error: ${error instanceof Error ? error.message : String(error)}`);
+ })
+ .finally(() => { done = true; });
+
+ while (!done || queue.length > 0) {
+ if (queue.length > 0) { yield queue.shift()!; }
+ else { await new Promise((r) => setTimeout(r, 50)); }
+ if (signal?.aborted) { return; }
+ }
+ }),
+
+ transfer: protectedProcedure
+ .input(apiTransferRedis)
+ .mutation(async ({ input, ctx }) => {
+ const redis = await findRedisById(input.redisId);
+ await checkServicePermissionAndAccess(ctx, input.redisId, {
+ service: ["delete"],
+ });
+ if (
+ redis.environment.project.organizationId !==
+ ctx.session.activeOrganizationId
+ ) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You are not authorized to access this Redis",
+ });
+ }
+ const result = await executeTransfer(
+ {
+ serviceId: input.redisId,
+ serviceType: "redis",
+ appName: redis.appName,
+ sourceServerId: redis.serverId,
+ targetServerId: input.targetServerId,
+ },
+ input.decisions || {},
+ );
+ if (!result.success) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: `Transfer failed: ${result.errors.join(", ")}`,
+ });
+ }
+ await db
+ .update(redisTable)
+ .set({ serverId: input.targetServerId })
+ .where(eq(redisTable.redisId, input.redisId));
+ return { success: true };
+ }),
});
diff --git a/packages/server/src/db/schema/application.ts b/packages/server/src/db/schema/application.ts
index a7067f63f..0eeae0fcb 100644
--- a/packages/server/src/db/schema/application.ts
+++ b/packages/server/src/db/schema/application.ts
@@ -534,3 +534,9 @@ export const apiUpdateApplication = createSchema
applicationId: z.string().min(1),
})
.omit({ serverId: true });
+
+export const apiTransferApplication = z.object({
+ applicationId: z.string().min(1),
+ targetServerId: z.string().min(1),
+ decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
+});
diff --git a/packages/server/src/db/schema/compose.ts b/packages/server/src/db/schema/compose.ts
index 7803cb0a7..34ae31bb6 100644
--- a/packages/server/src/db/schema/compose.ts
+++ b/packages/server/src/db/schema/compose.ts
@@ -240,3 +240,9 @@ export const apiRandomizeCompose = createSchema
suffix: z.string().optional(),
composeId: z.string().min(1),
});
+
+export const apiTransferCompose = z.object({
+ composeId: z.string().min(1),
+ targetServerId: z.string().min(1),
+ decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
+});
diff --git a/packages/server/src/db/schema/mariadb.ts b/packages/server/src/db/schema/mariadb.ts
index ce3f3e2d2..666d432a8 100644
--- a/packages/server/src/db/schema/mariadb.ts
+++ b/packages/server/src/db/schema/mariadb.ts
@@ -213,3 +213,9 @@ export const apiRebuildMariadb = createSchema
mariadbId: true,
})
.required();
+
+export const apiTransferMariadb = z.object({
+ mariadbId: z.string().min(1),
+ targetServerId: z.string().min(1),
+ decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
+});
diff --git a/packages/server/src/db/schema/mongo.ts b/packages/server/src/db/schema/mongo.ts
index a5910f197..82a57caa9 100644
--- a/packages/server/src/db/schema/mongo.ts
+++ b/packages/server/src/db/schema/mongo.ts
@@ -210,3 +210,9 @@ export const apiRebuildMongo = createSchema
mongoId: true,
})
.required();
+
+export const apiTransferMongo = z.object({
+ mongoId: z.string().min(1),
+ targetServerId: z.string().min(1),
+ decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
+});
diff --git a/packages/server/src/db/schema/mysql.ts b/packages/server/src/db/schema/mysql.ts
index a9a5d072f..259d8046e 100644
--- a/packages/server/src/db/schema/mysql.ts
+++ b/packages/server/src/db/schema/mysql.ts
@@ -210,3 +210,9 @@ export const apiRebuildMysql = createSchema
mysqlId: true,
})
.required();
+
+export const apiTransferMysql = z.object({
+ mysqlId: z.string().min(1),
+ targetServerId: z.string().min(1),
+ decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
+});
diff --git a/packages/server/src/db/schema/postgres.ts b/packages/server/src/db/schema/postgres.ts
index 954b15d4a..2b997eb24 100644
--- a/packages/server/src/db/schema/postgres.ts
+++ b/packages/server/src/db/schema/postgres.ts
@@ -204,3 +204,9 @@ export const apiRebuildPostgres = createSchema
postgresId: true,
})
.required();
+
+export const apiTransferPostgres = z.object({
+ postgresId: z.string().min(1),
+ targetServerId: z.string().min(1),
+ decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
+});
diff --git a/packages/server/src/db/schema/redis.ts b/packages/server/src/db/schema/redis.ts
index c07074d0c..0a8926938 100644
--- a/packages/server/src/db/schema/redis.ts
+++ b/packages/server/src/db/schema/redis.ts
@@ -187,3 +187,9 @@ export const apiRebuildRedis = createSchema
redisId: true,
})
.required();
+
+export const apiTransferRedis = z.object({
+ redisId: z.string().min(1),
+ targetServerId: z.string().min(1),
+ decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
+});
diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts
index e6fd0ba59..fcc3f80a3 100644
--- a/packages/server/src/index.ts
+++ b/packages/server/src/index.ts
@@ -47,6 +47,7 @@ export * from "./services/server";
export * from "./services/settings";
export * from "./services/ssh-key";
export * from "./services/user";
+export * from "./services/transfer";
export * from "./services/volume-backups";
export * from "./services/web-server-settings";
export * from "./setup/config-paths";
@@ -131,6 +132,7 @@ export * from "./utils/traefik/redirect";
export * from "./utils/traefik/security";
export * from "./utils/traefik/types";
export * from "./utils/traefik/web-server";
+export * from "./utils/transfer/index";
export * from "./utils/volume-backups/index";
export * from "./utils/watch-paths/should-deploy";
export * from "./wss/utils";
diff --git a/packages/server/src/services/transfer.ts b/packages/server/src/services/transfer.ts
new file mode 100644
index 000000000..2a0ae03b9
--- /dev/null
+++ b/packages/server/src/services/transfer.ts
@@ -0,0 +1,396 @@
+import { paths } from "@dokploy/server/constants";
+import path from "node:path";
+import { findMountsByApplicationId } from "./mount";
+import {
+ compareFileLists,
+ scanDirectory,
+ scanMount,
+} from "../utils/transfer/scanner";
+import { runPreflightChecks } from "../utils/transfer/preflight";
+import {
+ syncDirectory,
+ syncDockerVolume,
+ syncMount,
+ syncTraefikConfig,
+} from "../utils/transfer/sync";
+import type {
+ ConflictDecision,
+ MountTransferConfig,
+ ServiceType,
+ TransferOptions,
+ TransferProgress,
+ TransferResult,
+ TransferScanResult,
+} from "../utils/transfer/types";
+
+const getServiceBasePath = (
+ serviceType: ServiceType,
+ appName: string,
+ isRemote: boolean,
+): string => {
+ if (serviceType === "compose") {
+ const { COMPOSE_PATH } = paths(isRemote);
+ return path.join(COMPOSE_PATH, appName);
+ }
+ const { APPLICATIONS_PATH } = paths(isRemote);
+ return path.join(APPLICATIONS_PATH, appName);
+};
+
+const hasServiceDirectory = (serviceType: ServiceType): boolean => {
+ return serviceType === "application" || serviceType === "compose";
+};
+
+const getAutoDataVolumeName = (
+ serviceType: ServiceType,
+ appName: string,
+): string | null => {
+ const dbTypes: ServiceType[] = [
+ "postgres",
+ "mysql",
+ "mariadb",
+ "mongo",
+ "redis",
+ ];
+ if (dbTypes.includes(serviceType)) {
+ return `${appName}-data`;
+ }
+ return null;
+};
+
+export const scanServiceForTransfer = async (
+ opts: TransferOptions,
+): Promise => {
+ const { serviceType, appName, sourceServerId, targetServerId } = opts;
+
+ const result: TransferScanResult = {
+ serviceDirectory: { files: [], totalSize: 0 },
+ traefikConfig: { exists: false, hasConflict: false },
+ mounts: [],
+ totalTransferSize: 0,
+ totalFiles: 0,
+ conflicts: [],
+ };
+
+ // 1. Scan service directory (application/compose only)
+ if (hasServiceDirectory(serviceType)) {
+ const sourcePath = getServiceBasePath(
+ serviceType,
+ appName,
+ !!sourceServerId,
+ );
+ const targetPath = getServiceBasePath(serviceType, appName, true);
+
+ try {
+ const sourceFiles = await scanDirectory(sourceServerId, sourcePath);
+ const targetFiles = await scanDirectory(targetServerId, targetPath);
+
+ const fileConflicts = await compareFileLists(
+ sourceFiles,
+ targetFiles,
+ sourceServerId,
+ targetServerId,
+ sourcePath,
+ );
+
+ result.serviceDirectory = {
+ files: fileConflicts,
+ totalSize: sourceFiles.reduce((sum, f) => sum + f.size, 0),
+ };
+ } catch {
+ // Directory may not exist yet, that's ok
+ }
+ }
+
+ // 2. Check Traefik config
+ if (serviceType === "application" || serviceType === "compose") {
+ const configPath = "/etc/dokploy/traefik/dynamic";
+ const configFile = `${configPath}/${appName}.yml`;
+
+ try {
+ const sourceFiles = await scanDirectory(sourceServerId, configPath);
+ const sourceConfig = sourceFiles.find(
+ (f) => f.path === `${appName}.yml`,
+ );
+ if (sourceConfig) {
+ result.traefikConfig.exists = true;
+ const targetFiles = await scanDirectory(targetServerId, configPath);
+ const targetConfig = targetFiles.find(
+ (f) => f.path === `${appName}.yml`,
+ );
+ if (targetConfig) {
+ result.traefikConfig.hasConflict = true;
+ }
+ }
+ } catch {
+ // Config may not exist
+ }
+ }
+
+ // 3. Scan auto data volume for databases
+ const autoVolume = getAutoDataVolumeName(serviceType, appName);
+ if (autoVolume) {
+ try {
+ const sourceFiles = await scanMount(sourceServerId, {
+ mountId: "auto",
+ type: "volume",
+ volumeName: autoVolume,
+ mountPath: "/data",
+ });
+ const targetFiles = await scanMount(targetServerId, {
+ mountId: "auto",
+ type: "volume",
+ volumeName: autoVolume,
+ mountPath: "/data",
+ });
+
+ const fileConflicts = await compareFileLists(
+ sourceFiles,
+ targetFiles,
+ sourceServerId,
+ targetServerId,
+ undefined,
+ autoVolume,
+ );
+
+ result.mounts.push({
+ mount: {
+ mountId: "auto",
+ type: "volume",
+ volumeName: autoVolume,
+ mountPath: "/data",
+ },
+ files: fileConflicts,
+ totalSize: sourceFiles.reduce((sum, f) => sum + f.size, 0),
+ });
+ } catch {
+ // Volume may not exist
+ }
+ }
+
+ // 4. Scan user-defined mounts
+ const serviceTypeForMount = serviceType as
+ | "application"
+ | "postgres"
+ | "mysql"
+ | "mariadb"
+ | "mongo"
+ | "redis"
+ | "compose";
+ try {
+ const userMounts = await findMountsByApplicationId(
+ opts.serviceId,
+ serviceTypeForMount,
+ );
+
+ for (const mount of userMounts) {
+ const mountConfig: MountTransferConfig = {
+ mountId: mount.mountId,
+ type: mount.type,
+ hostPath: mount.hostPath,
+ volumeName: mount.volumeName,
+ mountPath: mount.mountPath,
+ content: mount.content,
+ filePath: mount.filePath,
+ };
+
+ if (mount.type === "file") continue; // File mounts are DB-stored
+
+ try {
+ const sourceFiles = await scanMount(sourceServerId, mountConfig);
+ const targetFiles = await scanMount(targetServerId, mountConfig);
+
+ const fileConflicts = await compareFileLists(
+ sourceFiles,
+ targetFiles,
+ sourceServerId,
+ targetServerId,
+ mount.type === "bind" ? mount.hostPath || undefined : undefined,
+ mount.type === "volume" ? mount.volumeName || undefined : undefined,
+ );
+
+ result.mounts.push({
+ mount: mountConfig,
+ files: fileConflicts,
+ totalSize: sourceFiles.reduce((sum, f) => sum + f.size, 0),
+ });
+ } catch {
+ // Individual mount scan failure shouldn't stop entire scan
+ }
+ }
+ } catch {
+ // No mounts found
+ }
+
+ // Calculate totals
+ result.totalTransferSize =
+ result.serviceDirectory.totalSize +
+ result.mounts.reduce((sum, m) => sum + m.totalSize, 0);
+
+ result.totalFiles =
+ result.serviceDirectory.files.length +
+ result.mounts.reduce((sum, m) => sum + m.files.length, 0);
+
+ result.conflicts = [
+ ...result.serviceDirectory.files,
+ ...result.mounts.flatMap((m) => m.files),
+ ].filter(
+ (f) =>
+ f.status !== "match" &&
+ f.status !== "missing_target",
+ );
+
+ return result;
+};
+
+export const executeTransfer = async (
+ opts: TransferOptions,
+ decisions: Record,
+ onProgress?: (progress: TransferProgress) => void,
+): Promise => {
+ const { serviceType, appName, sourceServerId, targetServerId } = opts;
+ const errors: string[] = [];
+ let processedFiles = 0;
+ let transferredBytes = 0;
+
+ const scan = await scanServiceForTransfer(opts);
+ const totalFiles = scan.totalFiles;
+ const totalBytes = scan.totalTransferSize;
+
+ const reportProgress = (
+ phase: TransferProgress["phase"],
+ message?: string,
+ currentFile?: string,
+ ) => {
+ if (processedFiles > 0) {
+ const percentage = totalFiles > 0 ? Math.round((processedFiles / totalFiles) * 100) : 0;
+ onProgress?.({
+ phase,
+ currentFile,
+ processedFiles,
+ totalFiles,
+ transferredBytes,
+ totalBytes,
+ percentage,
+ message,
+ });
+ } else {
+ onProgress?.({
+ phase,
+ currentFile,
+ processedFiles,
+ totalFiles,
+ transferredBytes,
+ totalBytes,
+ percentage: 0,
+ message,
+ });
+ }
+ };
+
+ try {
+ // Phase 1: Preflight checks
+ reportProgress("preparing", "Running preflight checks...");
+
+ const mountConfigs: MountTransferConfig[] = scan.mounts.map(
+ (m) => m.mount,
+ );
+ const targetBasePath = getServiceBasePath(serviceType, appName, true);
+
+ const preflight = await runPreflightChecks(
+ targetServerId,
+ targetBasePath,
+ totalBytes,
+ mountConfigs,
+ (msg) => reportProgress("preparing", msg),
+ );
+
+ if (!preflight.passed) {
+ return { success: false, errors: preflight.errors };
+ }
+
+ // Phase 2: Sync service directory
+ if (hasServiceDirectory(serviceType)) {
+ reportProgress("syncing_directory", "Syncing service directory...");
+
+ const sourcePath = getServiceBasePath(
+ serviceType,
+ appName,
+ !!sourceServerId,
+ );
+
+ try {
+ await syncDirectory(
+ sourceServerId,
+ targetServerId,
+ sourcePath,
+ targetBasePath,
+ (msg) => reportProgress("syncing_directory", msg),
+ );
+ processedFiles += scan.serviceDirectory.files.length;
+ transferredBytes += scan.serviceDirectory.totalSize;
+ reportProgress("syncing_directory", "Service directory synced");
+ } catch (error) {
+ errors.push(
+ `Failed to sync service directory: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+ }
+
+ // Phase 3: Sync Traefik config
+ if (scan.traefikConfig.exists) {
+ reportProgress("syncing_traefik", "Syncing Traefik configuration...");
+ try {
+ await syncTraefikConfig(
+ sourceServerId,
+ targetServerId,
+ appName,
+ (msg) => reportProgress("syncing_traefik", msg),
+ );
+ reportProgress("syncing_traefik", "Traefik config synced");
+ } catch (error) {
+ errors.push(
+ `Failed to sync Traefik config: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+ }
+
+ // Phase 4: Sync mounts
+ reportProgress("syncing_mounts", "Syncing mounts and volumes...");
+ for (const mountScan of scan.mounts) {
+ const mountLabel =
+ mountScan.mount.volumeName ||
+ mountScan.mount.hostPath ||
+ mountScan.mount.mountPath;
+ reportProgress("syncing_mounts", `Syncing: ${mountLabel}`, mountLabel);
+
+ try {
+ await syncMount(
+ sourceServerId,
+ targetServerId,
+ mountScan.mount,
+ decisions,
+ (msg) => reportProgress("syncing_mounts", msg),
+ );
+ processedFiles += mountScan.files.length;
+ transferredBytes += mountScan.totalSize;
+ reportProgress("syncing_mounts", `Completed: ${mountLabel}`);
+ } catch (error) {
+ errors.push(
+ `Failed to sync mount ${mountLabel}: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+ }
+
+ if (errors.length > 0) {
+ reportProgress("failed", `Transfer completed with errors: ${errors.join(", ")}`);
+ return { success: false, errors };
+ }
+
+ reportProgress("completed", "Transfer completed successfully!");
+ return { success: true, errors: [] };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ reportProgress("failed", `Transfer failed: ${message}`);
+ return { success: false, errors: [message] };
+ }
+};
diff --git a/packages/server/src/utils/transfer/index.ts b/packages/server/src/utils/transfer/index.ts
new file mode 100644
index 000000000..d560bd05d
--- /dev/null
+++ b/packages/server/src/utils/transfer/index.ts
@@ -0,0 +1,4 @@
+export * from "./types";
+export * from "./scanner";
+export * from "./sync";
+export * from "./preflight";
diff --git a/packages/server/src/utils/transfer/preflight.ts b/packages/server/src/utils/transfer/preflight.ts
new file mode 100644
index 000000000..0c6156e3a
--- /dev/null
+++ b/packages/server/src/utils/transfer/preflight.ts
@@ -0,0 +1,100 @@
+import { execAsync, execAsyncRemote } from "../process/execAsync";
+import type { MountTransferConfig } from "./types";
+
+const execOnServer = async (
+ serverId: string | null,
+ command: string,
+): Promise<{ stdout: string; stderr: string }> => {
+ if (serverId) {
+ return execAsyncRemote(serverId, command);
+ }
+ return execAsync(command);
+};
+
+export const ensureDirectoryExists = async (
+ serverId: string | null,
+ dirPath: string,
+): Promise => {
+ await execOnServer(serverId, `mkdir -p "${dirPath}"`);
+};
+
+export const ensureVolumeExists = async (
+ serverId: string | null,
+ volumeName: string,
+): Promise => {
+ await execOnServer(
+ serverId,
+ `docker volume inspect ${volumeName} > /dev/null 2>&1 || docker volume create ${volumeName}`,
+ );
+};
+
+export const checkDiskSpace = async (
+ serverId: string | null,
+ path: string,
+): Promise => {
+ const { stdout } = await execOnServer(
+ serverId,
+ `df -B1 "${path}" | tail -1 | awk '{print $4}'`,
+ );
+ return Number.parseInt(stdout.trim(), 10);
+};
+
+export const runPreflightChecks = async (
+ targetServerId: string,
+ targetBasePath: string,
+ requiredBytes: number,
+ mounts: MountTransferConfig[],
+ onLog?: (message: string) => void,
+): Promise<{ passed: boolean; errors: string[] }> => {
+ const errors: string[] = [];
+
+ onLog?.("Checking disk space on target server...");
+ try {
+ const availableBytes = await checkDiskSpace(targetServerId, "/");
+ if (availableBytes < requiredBytes * 1.2) {
+ errors.push(
+ `Insufficient disk space on target server. Required: ${formatBytes(requiredBytes)}, Available: ${formatBytes(availableBytes)}`,
+ );
+ }
+ } catch {
+ errors.push("Failed to check disk space on target server");
+ }
+
+ onLog?.("Ensuring target directories exist...");
+ try {
+ await ensureDirectoryExists(targetServerId, targetBasePath);
+ } catch {
+ errors.push(`Failed to create directory: ${targetBasePath}`);
+ }
+
+ for (const mount of mounts) {
+ if (mount.type === "volume" && mount.volumeName) {
+ onLog?.(`Ensuring volume exists: ${mount.volumeName}`);
+ try {
+ await ensureVolumeExists(targetServerId, mount.volumeName);
+ } catch {
+ errors.push(`Failed to create volume: ${mount.volumeName}`);
+ }
+ } else if (mount.type === "bind" && mount.hostPath) {
+ onLog?.(`Ensuring bind mount path exists: ${mount.hostPath}`);
+ try {
+ await ensureDirectoryExists(targetServerId, mount.hostPath);
+ } catch {
+ errors.push(`Failed to create directory: ${mount.hostPath}`);
+ }
+ }
+ }
+
+ return {
+ passed: errors.length === 0,
+ errors,
+ };
+};
+
+const formatBytes = (bytes: number): string => {
+ if (bytes === 0) return "0 B";
+ const k = 1024;
+ const sizes = ["B", "KB", "MB", "GB", "TB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
+};
diff --git a/packages/server/src/utils/transfer/scanner.ts b/packages/server/src/utils/transfer/scanner.ts
new file mode 100644
index 000000000..6595eadfc
--- /dev/null
+++ b/packages/server/src/utils/transfer/scanner.ts
@@ -0,0 +1,232 @@
+import { execAsync, execAsyncRemote } from "../process/execAsync";
+import type {
+ ConflictStatus,
+ FileConflict,
+ FileInfo,
+ MountTransferConfig,
+} from "./types";
+
+export const scanDirectory = async (
+ serverId: string | null,
+ dirPath: string,
+): Promise => {
+ const command = `find ${dirPath} -type f -exec stat --format='%n|%s|%Y' {} + 2>/dev/null || true`;
+
+ let stdout: string;
+ if (serverId) {
+ const result = await execAsyncRemote(serverId, command);
+ stdout = result.stdout;
+ } else {
+ const result = await execAsync(command);
+ stdout = result.stdout;
+ }
+
+ if (!stdout.trim()) return [];
+
+ return stdout
+ .trim()
+ .split("\n")
+ .filter(Boolean)
+ .map((line) => {
+ const [filePath, size, modifiedAt] = line.split("|");
+ return {
+ path: filePath!.replace(dirPath, "").replace(/^\//, ""),
+ size: Number.parseInt(size || "0", 10),
+ modifiedAt: Number.parseInt(modifiedAt || "0", 10),
+ };
+ });
+};
+
+export const scanDockerVolume = async (
+ serverId: string | null,
+ volumeName: string,
+): Promise => {
+ const command = `docker run --rm -v ${volumeName}:/volume alpine find /volume -type f -exec stat -c '%n|%s|%Y' {} + 2>/dev/null || true`;
+
+ let stdout: string;
+ if (serverId) {
+ const result = await execAsyncRemote(serverId, command);
+ stdout = result.stdout;
+ } else {
+ const result = await execAsync(command);
+ stdout = result.stdout;
+ }
+
+ if (!stdout.trim()) return [];
+
+ return stdout
+ .trim()
+ .split("\n")
+ .filter(Boolean)
+ .map((line) => {
+ const [filePath, size, modifiedAt] = line.split("|");
+ return {
+ path: (filePath || "").replace("/volume/", ""),
+ size: Number.parseInt(size || "0", 10),
+ modifiedAt: Number.parseInt(modifiedAt || "0", 10),
+ };
+ });
+};
+
+export const computeFileHash = async (
+ serverId: string | null,
+ filePath: string,
+): Promise => {
+ const command = `md5sum "${filePath}" | awk '{print $1}'`;
+
+ let stdout: string;
+ if (serverId) {
+ const result = await execAsyncRemote(serverId, command);
+ stdout = result.stdout;
+ } else {
+ const result = await execAsync(command);
+ stdout = result.stdout;
+ }
+
+ return stdout.trim();
+};
+
+export const computeVolumeFileHash = async (
+ serverId: string | null,
+ volumeName: string,
+ filePath: string,
+): Promise => {
+ const command = `docker run --rm -v ${volumeName}:/volume alpine md5sum "/volume/${filePath}" | awk '{print $1}'`;
+
+ let stdout: string;
+ if (serverId) {
+ const result = await execAsyncRemote(serverId, command);
+ stdout = result.stdout;
+ } else {
+ const result = await execAsync(command);
+ stdout = result.stdout;
+ }
+
+ return stdout.trim();
+};
+
+export const scanMount = async (
+ serverId: string | null,
+ mount: MountTransferConfig,
+): Promise => {
+ if (mount.type === "volume" && mount.volumeName) {
+ return scanDockerVolume(serverId, mount.volumeName);
+ }
+ if (mount.type === "bind" && mount.hostPath) {
+ return scanDirectory(serverId, mount.hostPath);
+ }
+ if (mount.type === "file") {
+ return [];
+ }
+ return [];
+};
+
+export const compareFileLists = async (
+ sourceFiles: FileInfo[],
+ targetFiles: FileInfo[],
+ sourceServerId: string | null,
+ targetServerId: string,
+ basePath?: string,
+ volumeName?: string,
+): Promise => {
+ const targetMap = new Map();
+ for (const f of targetFiles) {
+ targetMap.set(f.path, f);
+ }
+
+ const conflicts: FileConflict[] = [];
+
+ for (const sourceFile of sourceFiles) {
+ const targetFile = targetMap.get(sourceFile.path);
+
+ if (!targetFile) {
+ conflicts.push({
+ path: sourceFile.path,
+ status: "missing_target",
+ sourceFile,
+ });
+ continue;
+ }
+
+ if (
+ sourceFile.size === targetFile.size &&
+ sourceFile.modifiedAt === targetFile.modifiedAt
+ ) {
+ conflicts.push({
+ path: sourceFile.path,
+ status: "match",
+ sourceFile,
+ targetFile,
+ });
+ continue;
+ }
+
+ let sourceHash: string;
+ let targetHash: string;
+
+ if (volumeName) {
+ sourceHash = await computeVolumeFileHash(
+ sourceServerId,
+ volumeName,
+ sourceFile.path,
+ );
+ targetHash = await computeVolumeFileHash(
+ targetServerId,
+ volumeName,
+ targetFile.path,
+ );
+ } else if (basePath) {
+ sourceHash = await computeFileHash(
+ sourceServerId,
+ `${basePath}/${sourceFile.path}`,
+ );
+ targetHash = await computeFileHash(
+ targetServerId,
+ `${basePath}/${targetFile.path}`,
+ );
+ } else {
+ sourceHash = "";
+ targetHash = "";
+ }
+
+ if (sourceHash && targetHash && sourceHash === targetHash) {
+ conflicts.push({
+ path: sourceFile.path,
+ status: "match",
+ sourceFile: { ...sourceFile, hash: sourceHash },
+ targetFile: { ...targetFile, hash: targetHash },
+ });
+ continue;
+ }
+
+ let status: ConflictStatus;
+ if (sourceFile.modifiedAt > targetFile.modifiedAt) {
+ status = "newer_source";
+ } else if (targetFile.modifiedAt > sourceFile.modifiedAt) {
+ status = "newer_target";
+ } else {
+ status = "conflict";
+ }
+
+ conflicts.push({
+ path: sourceFile.path,
+ status,
+ sourceFile: { ...sourceFile, hash: sourceHash || undefined },
+ targetFile: { ...targetFile, hash: targetHash || undefined },
+ });
+ }
+
+ for (const targetFile of targetFiles) {
+ const exists = sourceFiles.some((sf) => sf.path === targetFile.path);
+ if (!exists) {
+ conflicts.push({
+ path: targetFile.path,
+ status: "newer_target",
+ sourceFile: { path: targetFile.path, size: 0, modifiedAt: 0 },
+ targetFile,
+ });
+ }
+ }
+
+ return conflicts;
+};
diff --git a/packages/server/src/utils/transfer/sync.ts b/packages/server/src/utils/transfer/sync.ts
new file mode 100644
index 000000000..0bdd8e733
--- /dev/null
+++ b/packages/server/src/utils/transfer/sync.ts
@@ -0,0 +1,171 @@
+import { execAsync, execAsyncRemote } from "../process/execAsync";
+import type { ConflictDecision, MountTransferConfig } from "./types";
+
+const execOnServer = async (
+ serverId: string | null,
+ command: string,
+ onData?: (data: string) => void,
+): Promise<{ stdout: string; stderr: string }> => {
+ if (serverId) {
+ return execAsyncRemote(serverId, command, onData);
+ }
+ return execAsync(command);
+};
+
+export const syncDirectory = async (
+ sourceServerId: string | null,
+ targetServerId: string,
+ sourcePath: string,
+ targetPath: string,
+ onLog?: (message: string) => void,
+): Promise => {
+ onLog?.(`Syncing directory: ${sourcePath} → ${targetPath}`);
+
+ await execOnServer(targetServerId, `mkdir -p "${targetPath}"`);
+
+ if (!sourceServerId && targetServerId) {
+ // Local → Remote: use rsync over SSH
+ const { stdout: sshKeyInfo } = await execAsyncRemote(
+ targetServerId,
+ "echo connected",
+ );
+ // Tar from local, pipe to remote via SSH
+ await execAsync(
+ `tar czf - -C "${sourcePath}" . 2>/dev/null | ssh -o StrictHostKeyChecking=no -i /tmp/transfer_key_${targetServerId} "tar xzf - -C ${targetPath}"`,
+ ).catch(async () => {
+ // Fallback: read from local, write to remote via tar through dokploy
+ const { stdout: tarData } = await execAsync(
+ `tar czf - -C "${sourcePath}" . | base64`,
+ );
+ await execAsyncRemote(
+ targetServerId,
+ `echo "${tarData}" | base64 -d | tar xzf - -C "${targetPath}"`,
+ );
+ });
+ } else if (sourceServerId && targetServerId) {
+ // Remote → Remote: tar pipeline through Dokploy server
+ onLog?.("Using tar pipeline for remote-to-remote transfer...");
+ const { stdout: tarData } = await execAsyncRemote(
+ sourceServerId,
+ `tar czf - -C "${sourcePath}" . | base64`,
+ );
+ await execAsyncRemote(
+ targetServerId,
+ `echo "${tarData}" | base64 -d | tar xzf - -C "${targetPath}"`,
+ );
+ } else if (sourceServerId && !targetServerId) {
+ // Remote → Local
+ const { stdout: tarData } = await execAsyncRemote(
+ sourceServerId,
+ `tar czf - -C "${sourcePath}" . | base64`,
+ );
+ await execAsync(
+ `echo "${tarData}" | base64 -d | tar xzf - -C "${targetPath}"`,
+ );
+ }
+
+ onLog?.(`Directory synced successfully: ${targetPath}`);
+};
+
+export const syncDockerVolume = async (
+ sourceServerId: string | null,
+ targetServerId: string,
+ volumeName: string,
+ onLog?: (message: string) => void,
+): Promise => {
+ onLog?.(`Syncing Docker volume: ${volumeName}`);
+
+ await execOnServer(
+ targetServerId,
+ `docker volume inspect ${volumeName} > /dev/null 2>&1 || docker volume create ${volumeName}`,
+ );
+
+ // Export volume from source as tar
+ const exportCommand = `docker run --rm -v ${volumeName}:/volume alpine tar czf - -C /volume . | base64`;
+ let tarData: string;
+
+ if (sourceServerId) {
+ const result = await execAsyncRemote(sourceServerId, exportCommand);
+ tarData = result.stdout;
+ } else {
+ const result = await execAsync(exportCommand);
+ tarData = result.stdout;
+ }
+
+ // Import volume on target
+ const importCommand = `echo "${tarData}" | base64 -d | docker run --rm -i -v ${volumeName}:/volume alpine tar xzf - -C /volume`;
+
+ await execOnServer(targetServerId, importCommand);
+ onLog?.(`Volume synced successfully: ${volumeName}`);
+};
+
+export const syncMount = async (
+ sourceServerId: string | null,
+ targetServerId: string,
+ mount: MountTransferConfig,
+ _decisions: Record,
+ onLog?: (message: string) => void,
+): Promise => {
+ if (mount.type === "volume" && mount.volumeName) {
+ await syncDockerVolume(
+ sourceServerId,
+ targetServerId,
+ mount.volumeName,
+ onLog,
+ );
+ } else if (mount.type === "bind" && mount.hostPath) {
+ await syncDirectory(
+ sourceServerId,
+ targetServerId,
+ mount.hostPath,
+ mount.hostPath,
+ onLog,
+ );
+ } else if (mount.type === "file" && mount.content) {
+ onLog?.(`Syncing file mount: ${mount.mountPath}`);
+ // File mounts are stored in the database, they get created during deploy
+ // No file transfer needed, the content is in the DB
+ onLog?.("File mount will be recreated from database content during deploy");
+ }
+};
+
+export const syncTraefikConfig = async (
+ sourceServerId: string | null,
+ targetServerId: string,
+ appName: string,
+ onLog?: (message: string) => void,
+): Promise => {
+ onLog?.(`Syncing Traefik config for: ${appName}`);
+
+ const configPath = "/etc/dokploy/traefik/dynamic";
+ const configFile = `${configPath}/${appName}.yml`;
+
+ let configContent: string;
+ if (sourceServerId) {
+ const { stdout } = await execAsyncRemote(
+ sourceServerId,
+ `cat "${configFile}" 2>/dev/null || echo ""`,
+ );
+ configContent = stdout;
+ } else {
+ const { stdout } = await execAsync(
+ `cat "${configFile}" 2>/dev/null || echo ""`,
+ );
+ configContent = stdout;
+ }
+
+ if (!configContent.trim()) {
+ onLog?.("No Traefik config found on source, skipping");
+ return;
+ }
+
+ await execOnServer(targetServerId, `mkdir -p "${configPath}"`);
+
+ const escapedContent = configContent.replace(/'/g, "'\\''");
+ await execOnServer(
+ targetServerId,
+ `echo '${escapedContent}' > "${configFile}"`,
+ );
+
+ onLog?.("Traefik config synced successfully");
+};
diff --git a/packages/server/src/utils/transfer/types.ts b/packages/server/src/utils/transfer/types.ts
new file mode 100644
index 000000000..4d63e68a2
--- /dev/null
+++ b/packages/server/src/utils/transfer/types.ts
@@ -0,0 +1,91 @@
+export type ServiceType =
+ | "application"
+ | "compose"
+ | "postgres"
+ | "mysql"
+ | "mariadb"
+ | "mongo"
+ | "redis";
+
+export interface FileInfo {
+ path: string;
+ size: number;
+ modifiedAt: number;
+ hash?: string;
+}
+
+export type ConflictStatus =
+ | "missing_target"
+ | "newer_source"
+ | "newer_target"
+ | "conflict"
+ | "match";
+
+export interface FileConflict {
+ path: string;
+ status: ConflictStatus;
+ sourceFile: FileInfo;
+ targetFile?: FileInfo;
+}
+
+export interface MountTransferConfig {
+ mountId: string;
+ type: "bind" | "volume" | "file";
+ hostPath?: string | null;
+ volumeName?: string | null;
+ mountPath: string;
+ content?: string | null;
+ filePath?: string | null;
+}
+
+export interface TransferScanResult {
+ serviceDirectory: {
+ files: FileConflict[];
+ totalSize: number;
+ };
+ traefikConfig: {
+ exists: boolean;
+ hasConflict: boolean;
+ };
+ mounts: Array<{
+ mount: MountTransferConfig;
+ files: FileConflict[];
+ totalSize: number;
+ }>;
+ totalTransferSize: number;
+ totalFiles: number;
+ conflicts: FileConflict[];
+}
+
+export type ConflictDecision = "skip" | "overwrite";
+
+export interface TransferProgress {
+ phase:
+ | "preparing"
+ | "syncing_directory"
+ | "syncing_traefik"
+ | "syncing_mounts"
+ | "updating_database"
+ | "completed"
+ | "failed";
+ currentFile?: string;
+ processedFiles: number;
+ totalFiles: number;
+ transferredBytes: number;
+ totalBytes: number;
+ percentage: number;
+ message?: string;
+}
+
+export interface TransferOptions {
+ serviceId: string;
+ serviceType: ServiceType;
+ appName: string;
+ sourceServerId: string | null;
+ targetServerId: string;
+}
+
+export interface TransferResult {
+ success: boolean;
+ errors: string[];
+}