feat: transfer services between servers - #5396
Conversation
…nts and config Adds a Transfer action on every service page that moves an application, compose or database to another server without S3: volumes, bind mounts and deployment logs are streamed through the panel (ssh2/spawn pipe), file mounts and Traefik config are recreated on the target, the source is cleaned up and the service is deployed on the target. Failures before cleanup roll back to the source server.
| if (!(await directoryExists(sourceServerId, hostPath))) { | ||
| log(`Skipping bind mount ${hostPath}: not found on ${sourceName}`); | ||
| continue; | ||
| } |
There was a problem hiding this comment.
| try { | ||
| await stopCompose(service.composeId); | ||
| } catch (error) { | ||
| log(` Could not stop compose: ${errorMessage(error)}`); | ||
| } | ||
| return; |
There was a problem hiding this comment.
If stopCompose fails because Docker or SSH is unavailable, this catch logs the error and lets the transfer archive volumes while containers may still be writing to them. This can produce an inconsistent target copy, and a partially removed stack cannot be restored automatically by the rollback path.
| export const volumeImportCommand = (volume: string) => | ||
| `docker volume create ${q(volume)} >/dev/null && docker run --rm -i -v ${q(volume)}:/data alpine tar -xzf - --numeric-owner -C /data`; |
There was a problem hiding this comment.
If the target already has a volume with this name, docker volume create reuses it and tar extracts into its existing contents without clearing or rejecting it. A name collision or retry after a partial transfer therefore merges source data with stale files, potentially corrupting the transferred service or another service's volume.
| } | ||
| }; | ||
|
|
||
| export const transferService = async ( |
There was a problem hiding this comment.
There is no per-service lock or idempotency guard before this destructive transfer sequence. Two users, browser tabs, or re-established subscriptions can transfer the same service concurrently, allowing the operations to overwrite serverId, import volumes simultaneously, deploy to conflicting targets, and leave divergent copies behind.
| for (const step of steps) { | ||
| try { | ||
| await step(); | ||
| } catch (error) { | ||
| log(` Warning: ${errorMessage(error)}`); | ||
| } | ||
| } |
There was a problem hiding this comment.
Cleanup failures report success
If removing the source service or compose stack fails because of a Docker or SSH error, this loop converts the failure into a warning. The transfer is then marked successful and deployed on the target, so source and target instances can both remain active against diverging data while the UI reports completion.
…e suite does not time out in CI
Adds a Transfer action (icon next to Update/Delete) on every service page that moves an application, compose or database to another server, or back to the Dokploy server, without S3.
Related issues and PRs
Closes #812, closes #2260, closes #3689. #1339 asked for the same and was closed as a duplicate of #812. #5170 (duplicate a service onto a different server) is related but a different operation.
Open PRs implementing the same feature, for comparison:
scp, progress persisted in a newservice_migrationtable, redeploy on the target left to the user. ~1.6k lines.This PR keeps it to one flow and no new tables: volumes and bind mounts are streamed through the panel, the source is cleaned up, and the target is deployed automatically. ~1.2k lines.
How it works
docker run … taron both ends) and every bind mount path through the panel, source → target, without touching the panel's disk.serverId, dropsnetworkIdsthat only exist on the source.deployment.logPath.deployApplication/deployComposedirectly, databases stream their deploy into the same log.If anything fails before cleanup,
serverIdandnetworkIdsare reverted and the service is scaled back up on the source. Images are pulled or rebuilt on the target by the normal deploy.Changes
packages/server/src/utils/process/remoteStream.ts:openProcessStream(ssh2 exec or local spawn) andpipeBetweenServers. The target is opened first and the pipe attached in the same tick the source is spawned, otherwise Node drops the stdout of a child that exits before it is consumed.packages/server/src/services/transfer.ts:transferServicefor the 8 service types.packages/server/src/db/schema/transfer.ts:apiTransferServiceinput.apps/dokploy/server/api/routers/transfer.ts:transfer.startsubscription streaming log lines (same pattern asdeployWithLogs).apps/dokploy/components/dashboard/shared/transfer-service.tsx: dialog with server select, remove-volumes checkbox, warnings, logs inDrawerLogs.service.create.apps/dokploy/__test__/utils/remote-stream.test.ts.Notes