feat: add application server migration workflow - #5169
Conversation
| .mutation(async ({ input, ctx }) => { | ||
| // For now, only support application migrations | ||
| if (input.serviceType !== "application") { | ||
| throw new TRPCError({ | ||
| code: "BAD_REQUEST", | ||
| message: "Only application migrations are currently supported", | ||
| }); | ||
| } | ||
|
|
||
| const migration = await createServiceMigration({ | ||
| ...input, | ||
| initiatedBy: input.initiatedBy || ctx.user.id, | ||
| }); |
There was a problem hiding this comment.
Migration bypasses resource authorization
When an authenticated member supplies an application, server, or migration ID outside their permitted scope, these procedures pass it directly to unscoped helpers, allowing cross-tenant migration disclosure, server probing, cancellation, application shutdown, or server reassignment. How this was verified: Every migration endpoint uses identity-only protectedProcedure, while the reachable orchestrator stops the selected application and updates its serverId.
Knowledge Base Used:
| await updateApplication(applicationId, { | ||
| applicationStatus: "paused", | ||
| pausedAt: new Date().toISOString(), | ||
| }); |
There was a problem hiding this comment.
Pause state cannot be persisted
Every migration reaching this step writes applicationStatus: "paused" and pausedAt, but the application schema defines neither that enum value nor that column. The update therefore fails after the source service has already been stopped, leaving the migration failed and the application unavailable.
Knowledge Base Used: Applications and deployments
| if (volumes.length > 0) { | ||
| for (const volume of volumes) { | ||
| try { | ||
| await backupVolume(application.serverId || null, volume, migrationId); | ||
| backedUpVolumes.push(volume); | ||
| } catch (error) { | ||
| console.error(`Failed to backup volume ${volume}:`, error); | ||
| // Continue with other volumes | ||
| } | ||
| } | ||
|
|
||
| await updateServiceMigration(migrationId, { | ||
| volumesBackedUp: backedUpVolumes, | ||
| }); | ||
|
|
||
| addProgress( | ||
| "backup_volumes", | ||
| "completed", | ||
| `Backed up ${backedUpVolumes.length} volumes`, | ||
| ); |
There was a problem hiding this comment.
Failed volume backups are ignored
When any configured volume cannot be archived, this catch omits it from backedUpVolumes and still records the backup step as completed. The migration then transfers only the successful subset, changes serverId, and reports success, so redeployment on the destination starts without the omitted persistent data.
Knowledge Base Used: Backups and restore
| }); | ||
| } | ||
|
|
||
| await failMigration(input.migrationId, "Cancelled by user"); |
There was a problem hiding this comment.
Cancellation does not stop migration
When a user cancels an active migration, this only marks its record failed; the detached migrateApplication promise receives no cancellation signal and never checks that status. It continues stopping, copying, restoring, and reassigning the application and can ultimately overwrite the canceled record as completed.
Knowledge Base Used: Applications and deployments
| export const migrationServiceType = pgEnum("serviceType", [ | ||
| "application", | ||
| "postgres", | ||
| "mysql", | ||
| "mariadb", | ||
| "mongo", | ||
| "redis", | ||
| "compose", | ||
| ]); | ||
|
|
||
| export const serviceMigrations = pgTable("service_migration", { |
There was a problem hiding this comment.
Migration schema lacks database upgrade
On an existing Dokploy database, the first migration request queries a service_migration table and migrationStatus enum that no SQL migration creates. This declaration also reuses the existing serviceType enum name with additional values, so deployed databases cannot satisfy the new schema and migration requests fail.
| export * from "./utils/schedules/utils"; | ||
| export * from "./utils/servers/remote-docker"; | ||
| export * from "./utils/startup/cancel-deployments"; | ||
| export * from "./utils/startup/cancell-deployments"; |
There was a problem hiding this comment.
Barrel exports unresolved modules
The package barrel now exports ./utils/startup/cancell-deployments, while the file is named cancel-deployments.ts, and it also exports absent cloud-provider, provider-types, and wildcard-domain modules; the root router similarly imports an absent cloud-provider router. Static module resolution therefore fails when building the server or dashboard.
Knowledge Base Used: API boundary
| }); | ||
| } | ||
|
|
||
| await assertCanAccessService(ctx, input.serviceId); |
There was a problem hiding this comment.
Read access permits migrations
When a scoped member has application read access but lacks deployment create or cancel permission, create, retry, and cancel still pass authorization and allow that member to stop the application, transfer its data, reassign its server, or control migration state. How this was verified: These procedures use checkServiceAccess with "read", while deployment create and cancel are separate permissions.
Knowledge Base Used:
| return mounts | ||
| .filter((mount) => mount.type === "volume" && mount.volumeName) | ||
| .map((mount) => mount.volumeName as string); |
There was a problem hiding this comment.
When an application uses a bind or managed file mount, this filter excludes it from backup, transfer, and restoration, but the workflow still reassigns the application and reports success. The destination therefore lacks persistent data required by the application.
Knowledge Base Used: Managed databases and storage
| if (application.serverId) { | ||
| await stopServiceRemote(application.serverId, application.appName); | ||
| } else { | ||
| await stopService(application.appName); | ||
| } |
There was a problem hiding this comment.
Stop failures permit live backups
When Docker fails to scale the source service to zero, stopService and stopServiceRemote return the caught error instead of rejecting, so this code records a successful pause and begins backup while the application remains live. Writes during archiving can then produce an inconsistent volume snapshot.
Knowledge Base Used: Applications and deployments
| } catch (error) { | ||
| throw new Error( | ||
| `Failed to backup volume ${volume}: ${error instanceof Error ? error.message : "Unknown error"}`, | ||
| ); | ||
| } |
There was a problem hiding this comment.
Backup failure leaves source stopped
When any volume archive fails, this throw reaches failure handling after the source application has already been stopped and marked idle. Because the catch only marks the migration failed, the source remains unavailable even though no migration completed.
Knowledge Base Used:
| export * from "./cloud-provider"; | ||
| export * from "./compose"; | ||
| export * from "./deployment"; | ||
| export * from "./destination"; | ||
| export * from "./dns-provider"; |
There was a problem hiding this comment.
Schema barrel exports missing modules
When TypeScript resolves this schema barrel, the cloud-provider and dns-provider exports target source modules that do not exist. Static module resolution therefore fails and prevents the server package or dashboard from compiling.
Knowledge Base Used: API boundary
| ); | ||
|
|
||
| // Step 9: Mark migration as completed | ||
| await completeMigration(migrationId); |
There was a problem hiding this comment.
Cancellation race overwrites failure
If cancellation overlaps the final migration steps after the last ensureMigrationActive check, completeMigration unconditionally overwrites the failed cancellation state, causing the API to report a successful cancellation while the migration finishes and records success.
Knowledge Base Used: API boundary
6121de3 to
b20ec7a
Compare
| const migration = await createServiceMigration({ | ||
| ...input, | ||
| initiatedBy: input.initiatedBy || ctx.user.id, |
There was a problem hiding this comment.
Caller can forge migration initiator
When an authorized caller supplies another valid user ID in initiatedBy, the create procedure persists that value instead of the authenticated ctx.user.id, causing the migration record and its initiator relation to attribute the server migration to the wrong user.
How this was verified: The public input accepts initiatedBy, and the persisted value explicitly prefers it over ctx.user.id.
Knowledge Base Used: Identity, permissions, and audit
| await checkServicePermissionAndAccess(ctx, migration.serviceId, { | ||
| deployment: ["create"], | ||
| }); | ||
|
|
||
| if (migration.status === "completed" || migration.status === "failed") { |
There was a problem hiding this comment.
Cancellation checks create permission
When a custom-role member has deployment creation permission but lacks cancellation permission, this check still authorizes serviceMigration.cancel, allowing that member to force an active migration into failure and rollback.
How this was verified: The cancellation procedure requests deployment: ["create"] immediately before calling failMigration.
| await checkServicePermissionAndAccess(ctx, migration.serviceId, { | |
| deployment: ["create"], | |
| }); | |
| if (migration.status === "completed" || migration.status === "failed") { | |
| await checkServicePermissionAndAccess(ctx, migration.serviceId, { | |
| deployment: ["cancel"], | |
| }); | |
| if (migration.status === "completed" || migration.status === "failed") { |
Knowledge Base Used:
Summary
Adds a focused application server migration workflow that can pause an app on the source server, validate the target server, move volume data, and record step-by-step progress.
Changes
service_migrationtable and shared migration helpers for tracking jobs and progress.serverId, and marks the migration complete or failed.How It Works
application.scp, and restored on the destination server.Test Plan
git diff --checkpnpm -C packages/server typechecknot run here because localnode_modulesare missing in this checkoutpnpm -C apps/dokploy typechecknot run here because localnode_modulesare missing in this checkoutRisks
~/.ssh/dokploy_keyexists on the machine running the migration.Rollback
a802ac5b.Greptile Summary
The PR adds an end-to-end application server migration workflow with persisted progress, target validation, source pausing, volume transfer, rollback handling, API procedures, and dashboard controls.
Confidence Score: 0/5
The PR is not safe to merge because migration authorization remains bypassable at two action and identity boundaries, and cancellation can still be overwritten by successful completion.
The create endpoint persists a caller-selected initiator, the cancel endpoint accepts deployment creation authority instead of cancellation authority, and the orchestrator still has an unguarded window between its final cancellation check and unconditional completion.
Files Needing Attention: apps/dokploy/server/api/routers/service-migration.ts, packages/server/src/services/migrate-application.ts, packages/server/src/services/service-migration.ts
Security Review
Two authorization-boundary problems remain: callers can falsify the persisted migration initiator, and migration cancellation is guarded by deployment creation permission rather than cancellation permission.
Reviews (4): Last reviewed commit: "fix: address migration follow-up review ..." | Re-trigger Greptile
Context used (4)