Skip to content

feat: add application server migration workflow - #5169

Open
JoshuaRileyDev wants to merge 3 commits into
Dokploy:canaryfrom
JoshuaRileyDev:pr/server-migration
Open

feat: add application server migration workflow#5169
JoshuaRileyDev wants to merge 3 commits into
Dokploy:canaryfrom
JoshuaRileyDev:pr/server-migration

Conversation

@JoshuaRileyDev

@JoshuaRileyDev JoshuaRileyDev commented Aug 23, 2026

Copy link
Copy Markdown

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

  • Adds a service_migration table and shared migration helpers for tracking jobs and progress.
  • Adds a TRPC router for creating, inspecting, validating, retrying, and canceling migration jobs.
  • Adds the application migration orchestrator that validates the target server, pauses the source app, backs up volume mounts, transfers them, updates the application's serverId, and marks the migration complete or failed.
  • Adds UI in application settings to start a migration and monitor migration progress.
  • Registers the new server exports and app router so the workflow is available end to end.

How It Works

  • This is application-only for now. Other service types are accepted by the schema, but the router currently rejects anything except application.
  • The target server must be reachable over SSH, have Docker installed, and have Docker running.
  • Volume backups are created as tar archives, transferred with scp, and restored on the destination server.
  • The app is updated to point at the new server, but redeploying it on the destination remains a user action.

Test Plan

  • git diff --check
  • pnpm -C packages/server typecheck not run here because local node_modules are missing in this checkout
  • pnpm -C apps/dokploy typecheck not run here because local node_modules are missing in this checkout

Risks

  • The transfer flow assumes ~/.ssh/dokploy_key exists on the machine running the migration.
  • The implementation is intentionally limited to applications, so databases and compose services are not migrated yet.

Rollback

  • Revert commit 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.

  • Adds migration persistence and application migration orchestration.
  • Adds scoped migration API endpoints and server validation.
  • Adds dashboard controls for starting and monitoring migration jobs.

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

Greptile also left 2 inline comments on this PR.

Context used (4)

@dosubot dosubot Bot added the size:XXL This PR changes 1000+ lines, ignoring generated files. label Aug 23, 2026
Comment on lines +36 to +48
.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,
});

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 security 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:

Comment on lines +92 to +95
await updateApplication(applicationId, {
applicationStatus: "paused",
pausedAt: new Date().toISOString(),
});

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

Comment on lines +110 to +129
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`,
);

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 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");

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

Comment on lines +29 to +39
export const migrationServiceType = pgEnum("serviceType", [
"application",
"postgres",
"mysql",
"mariadb",
"mongo",
"redis",
"compose",
]);

export const serviceMigrations = pgTable("service_migration", {

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

Comment thread packages/server/src/index.ts Outdated
export * from "./utils/schedules/utils";
export * from "./utils/servers/remote-docker";
export * from "./utils/startup/cancel-deployments";
export * from "./utils/startup/cancell-deployments";

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 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);

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 security 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:

Comment on lines +235 to +237
return mounts
.filter((mount) => mount.type === "volume" && mount.volumeName)
.map((mount) => mount.volumeName as string);

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 Non-volume mounts are omitted

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

Comment on lines +85 to +89
if (application.serverId) {
await stopServiceRemote(application.serverId, application.appName);
} else {
await stopService(application.appName);
}

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

Comment on lines +113 to +117
} catch (error) {
throw new Error(
`Failed to backup volume ${volume}: ${error instanceof Error ? error.message : "Unknown 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 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:

Comment thread packages/server/src/db/schema/index.ts Outdated
Comment on lines +7 to +11
export * from "./cloud-provider";
export * from "./compose";
export * from "./deployment";
export * from "./destination";
export * from "./dns-provider";

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 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);

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

Comment on lines +81 to +83
const migration = await createServiceMigration({
...input,
initiatedBy: input.initiatedBy || ctx.user.id,

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 security 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

Comment on lines +156 to +160
await checkServicePermissionAndAccess(ctx, migration.serviceId, {
deployment: ["create"],
});

if (migration.status === "completed" || migration.status === "failed") {

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

Suggested change
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:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL This PR changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant