Skip to content

feat: transfer services between servers - #5396

Merged
Siumauricio merged 2 commits into
canaryfrom
feat/transfer-service-between-servers
Sep 9, 2026
Merged

Siumauricio merged 2 commits into
canaryfrom
feat/transfer-service-between-servers

Conversation

@Siumauricio

@Siumauricio Siumauricio commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

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:

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

  1. Stops the service on the source.
  2. Streams every named volume (docker run … tar on both ends) and every bind mount path through the panel, source → target, without touching the panel's disk.
  3. Switches serverId, drops networkIds that only exist on the source.
  4. Recreates file mounts from the DB, copies the Traefik yml and regenerates domains, security and redirect middlewares (applications).
  5. Moves the deployment logs directory and rewrites deployment.logPath.
  6. Cleans up the source (service/stack, Traefik config, middlewares, code/monitoring dirs). Volumes are kept unless Remove volumes from the source server is checked.
  7. Deploys on the target: apps/compose run deployApplication/deployCompose directly, databases stream their deploy into the same log.

If anything fails before cleanup, serverId and networkIds are 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) and pipeBetweenServers. 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: transferService for the 8 service types.
  • packages/server/src/db/schema/transfer.ts: apiTransferService input.
  • apps/dokploy/server/api/routers/transfer.ts: transfer.start subscription streaming log lines (same pattern as deployWithLogs).
  • apps/dokploy/components/dashboard/shared/transfer-service.tsx: dialog with server select, remove-volumes checkbox, warnings, logs in DrawerLogs.
  • Service pages: the Transfer button, gated by service.create.
  • apps/dokploy/__test__/utils/remote-stream.test.ts.

Notes

  • DNS still points to the old server until changed; Let's Encrypt certificates are issued again on the target; custom certificates are per server and are not moved.
  • Bind mount host paths are copied but never deleted from the source.
  • Verified local ⇄ Hetzner with applications (volumes, bind mounts, file mounts, domains, custom network), Postgres with data, raw compose and the Plausible template.

…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.
Comment on lines +517 to +520
if (!(await directoryExists(sourceServerId, hostPath))) {
log(`Skipping bind mount ${hostPath}: not found on ${sourceName}`);
continue;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 File bind mounts skipped

Regular-file bind mounts are valid, but this check uses -d, so it treats a file path as missing and skips copying it. The mount definition is still used on the target, causing deployment to fail or the service to run without the file's contents.

Comment on lines +244 to +249
try {
await stopCompose(service.composeId);
} catch (error) {
log(` Could not stop compose: ${errorMessage(error)}`);
}
return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Failed stops are ignored

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.

Comment on lines +182 to +183
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`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Existing volumes are reused

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 (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Concurrent transfers can race

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.

Comment on lines +399 to +405
for (const step of steps) {
try {
await step();
} catch (error) {
log(` Warning: ${errorMessage(error)}`);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

@Siumauricio
Siumauricio merged commit ec31739 into canary Sep 9, 2026
4 checks passed
@Siumauricio
Siumauricio deleted the feat/transfer-service-between-servers branch September 9, 2026 19:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant