Migration and takeover
Reading another panel, importing it project by project, moving its data, and taking over the machine it runs on.
A migration reads a Dokploy or Coolify panel through its API and recreates what it finds here: environments, apps, compose stacks, databases, variables, domains, config files, volumes, limits, basic-auth users and crons. Nothing is deployed by an import, the source is still answering those hostnames.
The source's API key rides every call in MigrationSourceInput and is stored only for the length of a run started with startMigration. Everything is gated on create_projects and refused to a narrowed principal, since an import writes across the whole team.
Neither data-move call accepts a volume, a path or a host
The volumes are derived from the service and the app, the target from the run's own record of what it created, and the source machine from its address. Naming any of them would be an instruction to copy any volume on any host over any other one.
Reading a source
identifyMigrationSource
Needs create_projects, or instance admin. Returns MigrationSourceTeam.
WHICH team a token reads, without reading it all. A token covers one team of the panel, so bringing several over takes one token each, and this is what lets the wizard collect them without paying for a full scan per token. Refuses a token that cannot read values, exactly as the scan does. Writes nothing.
| Argument | Type | Description |
|---|---|---|
input | MigrationSourceInput! | |
input.apiKey | String! | The panel's API key or token. Dokploy: Settings -> Profile -> API/CLI. Coolify: Keys & Tokens -> API tokens, with deploy ticked first, then read and read:sensitive - without deploy the data step cannot stop a service, without read:sensitive the values and database passwords arrive empty. Use an owner's or admin's either way: a plain member's is refused. Never stored. |
input.kind | MigrationPlatform | Read the panel as this product. Omit it and Deplo works out which it is from the address and the token. One of coolify, dokploy. |
input.url | String! | The panel's address. Deplo appends the API path itself, so paste the address you open in a browser. |
mutation {
identifyMigrationSource(
input: { url: "https://dokploy.example.com", apiKey: "<source panel api key>" }
) {
platform
teamName
otherTeams
}
}scanMigrationSource
Needs create_projects, or instance admin. Returns MigrationPlan.
Read a source panel and describe what an import would do. Works out which product it is when kind is omitted. Writes NOTHING, here or there. The per-service detail calls happen now, not at import time, so the preview can already say which hostname belongs to another team, which compose file needs a grant you do not hold, and what has no equivalent here. Answers about the request's team, which needs create_projects there - or, with newTeam, about a team not made yet, which an instance admin may ask from any team of theirs.
| Argument | Type | Description |
|---|---|---|
input | MigrationSourceInput! | |
input.apiKey | String! | The panel's API key or token. Dokploy: Settings -> Profile -> API/CLI. Coolify: Keys & Tokens -> API tokens, with deploy ticked first, then read and read:sensitive - without deploy the data step cannot stop a service, without read:sensitive the values and database passwords arrive empty. Use an owner's or admin's either way: a plain member's is refused. Never stored. |
input.kind | MigrationPlatform | Read the panel as this product. Omit it and Deplo works out which it is from the address and the token. One of coolify, dokploy. |
input.url | String! | The panel's address. Deplo appends the API path itself, so paste the address you open in a browser. |
newTeam | Boolean | Describe the import into a team that does not exist yet: nothing counts as already here, and every hostname another team serves is another team's. Instance admins only; the wizard sends it for a source team it will land in a team made at Start. |
mutation {
scanMigrationSource(input: { url: "https://dokploy.example.com", apiKey: "<source panel api key>" }) {
platform
orgName
projects {
name
sourceId
exists
environments { name services { name kind status } }
}
servers { name ipAddress deploServerName }
}
}Running an import
startMigration hands the whole run to the control plane. The older per-project calls (beginMigration, importMigrationProject, finishMigration) drive the same import one step at a time.
startMigration
Needs create_projects. Returns String.
Start a migration and hand it to the control plane. Returns the run id as soon as the plan is DURABLE, not when the migration is done: the loop lives here now, under the identity of whoever started it, so the tab can be closed, reloaded or replaced and the run does not notice. The panel's token is stored encrypted for the length of the run and wiped the moment it leaves running - a deliberate reversal of never storing it, made because the alternative is a migration that cannot survive a page reload.
| Argument | Type | Description |
|---|---|---|
input | MigrationSourceInput! | |
input.apiKey | String! | The panel's API key or token. Dokploy: Settings -> Profile -> API/CLI. Coolify: Keys & Tokens -> API tokens, with deploy ticked first, then read and read:sensitive - without deploy the data step cannot stop a service, without read:sensitive the values and database passwords arrive empty. Use an owner's or admin's either way: a plain member's is refused. Never stored. |
input.kind | MigrationPlatform | Read the panel as this product. Omit it and Deplo works out which it is from the address and the token. One of coolify, dokploy. |
input.url | String! | The panel's address. Deplo appends the API path itself, so paste the address you open in a browser. |
keepSources | Boolean | Another team of the SAME panel is still to come, so this run must leave Deplo's agents on the source machines - the next team reads the same disks, and an uninstall scheduled here would race the install that follows it. The last run of a series leaves this false, and it is that one that clears them. It also holds the takeover: the ports cannot be taken while a run says more teams are owed. |
orgName | String | |
servers | [MigrationServerChoiceInput!] | |
servers.from | String! | |
servers.to | String! | |
targets | [MigrationRunTargetInput!]! | |
targets.buildServerId | String | |
targets.exposedPort | Int | A database's host port. THREE values, and the difference matters: omitted keeps the source's own, null publishes nothing, a number publishes there. |
targets.projectId | String! | |
targets.projectName | String! | Shown while the run works through it, so the runner needs no second read of the API for a name. |
targets.serverId | String | |
targets.serviceId | String! |
mutation {
startMigration(
input: { url: "https://dokploy.example.com", apiKey: "<source panel api key>" }
targets: [
{ projectId: "<project id from the scan>", projectName: "shop", serviceId: "<service id from the scan>", serverId: "srv_9f1c2ab7d3e4f5a6" }
]
)
}beginMigration
Needs create_projects. Returns String.
Open an import run and return its id. Any run this team left open (a closed tab) is closed as failed first, so the history never shows two live imports.
| Argument | Type | Description |
|---|---|---|
kind | MigrationPlatform | One of coolify, dokploy. |
orgName | String | |
url | String! |
mutation {
beginMigration(url: "https://panel.example.com")
}importMigrationProject
Needs create_projects. Returns MigrationProjectResult.
Import ONE project from the source panel into the active team: its environments, apps, compose stacks, databases, variables, domains, config files, volumes, resource limits, basic-auth users and crons. Nothing is deployed - the source instance is still answering those hostnames. Anything already here is skipped by name, so running it again resumes an interrupted import instead of duplicating it. One object failing never stops the rest: it becomes a line in the report.
| Argument | Type | Description |
|---|---|---|
input | MigrationSourceInput! | |
input.apiKey | String! | The panel's API key or token. Dokploy: Settings -> Profile -> API/CLI. Coolify: Keys & Tokens -> API tokens, with deploy ticked first, then read and read:sensitive - without deploy the data step cannot stop a service, without read:sensitive the values and database passwords arrive empty. Use an owner's or admin's either way: a plain member's is refused. Never stored. |
input.kind | MigrationPlatform | Read the panel as this product. Omit it and Deplo works out which it is from the address and the token. One of coolify, dokploy. |
input.url | String! | The panel's address. Deplo appends the API path itself, so paste the address you open in a browser. |
placements | [MigrationPlacementInput!] | Where each service lands, one entry per service. Wins over servers, which stays the fallback for anything not listed here. Both are about where a service RUNS; where its data is READ FROM is derived from that machine's own address and is never a caller's choice. A server this team cannot deploy to is refused into a report line, never used. |
placements.buildServerId | String | |
placements.exposedPort | Int | A database's host port. Omit the field to keep the port it had over there (what the import has always done); send null to publish nothing; send a number to publish there instead - which is how a review resolves a port something else already holds on the target server. Ignored for anything that is not a database. |
placements.serverId | String! | |
placements.serviceId | String! | |
projectId | String! | The panel's projectId to import. |
runId | String! | |
servers | [MigrationServerChoiceInput!] | |
servers.from | String! | |
servers.to | String! | |
serviceIds | [String!] | Which of the project's services to import, by their id over there (the sourceId a scan reports). Omit to import all of them. A service left out is left out silently - it is a choice, not an outcome, so it produces no report line. An environment nothing was picked from is not created. |
mutation {
importMigrationProject(
input: { url: "https://dokploy.example.com", apiKey: "<source panel api key>" }
runId: "run_9f1c2ab7d3e4f5a6"
projectId: "<project id from the scan>"
placements: [{ serviceId: "<service id from the scan>", serverId: "srv_9f1c2ab7d3e4f5a6" }]
) {
projectName
created
skipped
failed
items { sourceName outcome message }
}
}importMigrationMembers
Instance admin only. Returns [MigrationInvite!].
Bring the source team's people over. Someone who already has a Deplo account is added to this team; everyone else gets a single-use registration link to send them. Passwords cannot travel in either direction, and everyone arrives as a plain member whatever they were over there - the report says who was an owner or admin so it can be granted on purpose.
| Argument | Type | Description |
|---|---|---|
input | MigrationSourceInput! | |
input.apiKey | String! | The panel's API key or token. Dokploy: Settings -> Profile -> API/CLI. Coolify: Keys & Tokens -> API tokens, with deploy ticked first, then read and read:sensitive - without deploy the data step cannot stop a service, without read:sensitive the values and database passwords arrive empty. Use an owner's or admin's either way: a plain member's is refused. Never stored. |
input.kind | MigrationPlatform | Read the panel as this product. Omit it and Deplo works out which it is from the address and the token. One of coolify, dokploy. |
input.url | String! | The panel's address. Deplo appends the API path itself, so paste the address you open in a browser. |
runId | String! |
mutation {
importMigrationMembers(
input: { url: "https://dokploy.example.com", apiKey: "<source panel api key>" }
runId: "brun_9f1c2ab7d3e4f5a6"
) {
name
email
outcome
link
}
}finishMigration
Needs create_projects. Returns Boolean.
Close the run and settle its totals. Idempotent - a finished run is left alone.
| Argument | Type | Description |
|---|---|---|
runId | String! |
mutation {
finishMigration(runId: "brun_9f1c2ab7d3e4f5a6")
}stopMigration
Needs create_projects. Returns Boolean.
Close a run somebody stopped part-way, WITHOUT finishing it - the migration sources keep their agents, because re-running is how a stopped migration is resumed.
| Argument | Type | Description |
|---|---|---|
runId | String! |
mutation {
stopMigration(runId: "brun_9f1c2ab7d3e4f5a6")
}abandonMigration
Needs create_projects, or instance admin. Returns Int.
Leaving the wizard behind: take Deplo's agent back off the machines it registered to read, exactly the way finishing does. No-op while a run is in flight (it owns those agents) and after one whose volume copy failed (the bytes are still over there). Returns how many sources it is removing.
mutation {
abandonMigration
}revertMigration
Needs create_projects. Returns MigrationRevertResult.
Remove everything this run CREATED in Deplo - apps, databases, and the projects it made. Anything it merely reused is left alone, and the source is not restarted. Each delete keeps its own capability gate, so what the actor may not remove comes back in failed.
| Argument | Type | Description |
|---|---|---|
runId | String! |
mutation {
revertMigration(runId: "brun_9f1c2ab7d3e4f5a6") {
apps
databases
projects
failed
}
}dismissMigrationReport
Needs create_projects. Returns Boolean.
"I am done looking at this run": the migration wizard stops opening on it and shows an empty connect form again. Pressing Finish on the report is what sends it.
| Argument | Type | Description |
|---|---|---|
runId | String! |
mutation {
dismissMigrationReport(runId: "brun_9f1c2ab7d3e4f5a6")
}History
migrationRuns
Needs create_projects. Returns [MigrationRun!].
This team's import history, newest first. Without the per-run report - read one run for that.
query {
migrationRuns {
id
status
phase
created
failed
}
}migrationRun
Needs create_projects. Returns MigrationRun.
One import with its full report.
| Argument | Type | Description |
|---|---|---|
id | String! |
query {
migrationRun(id: "brun_9f1c2ab7d3e4f5a6") {
id
status
phase
created
failed
}
}activeMigration
Any signed-in principal. Returns MigrationRun.
Emits the migration this team currently has in flight - or, once it is done, the finished run until somebody closes its report - or null. Fires once immediately, then whenever a run starts, moves on or ends - it is what the header chip and the wizard's panel read. Deliberately NOT gated on create_projects: "somebody is moving a platform into this team right now" is a warning every member needs.
subscription {
activeMigration {
id
status
phase
created
failed
}
}Moving data
planMigrationDataMove
Needs create_projects. Returns [MigrationDataService!].
The services THIS RUN imported whose DATA can still be moved, with each volume paired to the Deplo one that would receive it (paired by container path, the only identity the two platforms share). Reads both sides and writes nothing. Scoped to the run because the copy WIPES its target before writing: what a service became is a fact the run recorded, never a name that happens to match.
| Argument | Type | Description |
|---|---|---|
input | MigrationSourceInput! | |
input.apiKey | String! | The panel's API key or token. Dokploy: Settings -> Profile -> API/CLI. Coolify: Keys & Tokens -> API tokens, with deploy ticked first, then read and read:sensitive - without deploy the data step cannot stop a service, without read:sensitive the values and database passwords arrive empty. Use an owner's or admin's either way: a plain member's is refused. Never stored. |
input.kind | MigrationPlatform | Read the panel as this product. Omit it and Deplo works out which it is from the address and the token. One of coolify, dokploy. |
input.url | String! | The panel's address. Deplo appends the API path itself, so paste the address you open in a browser. |
runId | String! |
mutation {
planMigrationDataMove(
input: { url: "https://dokploy.example.com", apiKey: "<source panel api key>" }
runId: "brun_9f1c2ab7d3e4f5a6"
) {
sourceName
targetName
sourceReachable
volumes {
mountPath
}
}
}moveMigrationServiceData
Needs create_projects. Returns MigrationDataMoveResult.
Cut ONE service's data over: STOP it on the panel (and leave it stopped - a volume read while its container writes cannot be trusted), then copy every paired volume into the app or database imported from it. A database is started again afterwards and checked, so the report says the engine reads the copied data rather than only that bytes moved. Additionally gated on restore_backups on the target, which is what overwriting a resource's data already requires. NEITHER side is taken from the caller: the volumes are derived from the service and the app, and the host the data is read from is derived from that machine's address - naming either would be an instruction to copy any volume on any host over any other one.
| Argument | Type | Description |
|---|---|---|
input | MigrationSourceInput! | |
input.apiKey | String! | The panel's API key or token. Dokploy: Settings -> Profile -> API/CLI. Coolify: Keys & Tokens -> API tokens, with deploy ticked first, then read and read:sensitive - without deploy the data step cannot stop a service, without read:sensitive the values and database passwords arrive empty. Use an owner's or admin's either way: a plain member's is refused. Never stored. |
input.kind | MigrationPlatform | Read the panel as this product. Omit it and Deplo works out which it is from the address and the token. One of coolify, dokploy. |
input.url | String! | The panel's address. Deplo appends the API path itself, so paste the address you open in a browser. |
runId | String! | |
sourceId | String! | |
sourceKind | String! |
mutation {
moveMigrationServiceData(
input: { url: "https://dokploy.example.com", apiKey: "<source panel api key>" }
runId: "run_9f1c2ab7d3e4f5a6"
sourceKind: "postgres"
sourceId: "<service id from the scan>"
) {
moved
failed
notes
}
}dataRecopySource
Needs restore_backups. Returns MigrationRecopySource.
Where a workload whose data did not come across was imported from, so the copy can be run again from its own page. Null when nothing here came from a migration.
| Argument | Type | Description |
|---|---|---|
id | String! | |
kind | String! |
query {
dataRecopySource(kind: "database", id: "db_9f1c2ab7d3e4f5a6") {
platform
sourceUrl
sourceName
runId
}
}deployWithoutMigratedData
Needs deploy_apps. Returns App.
Accept that the data a migration could not copy is not coming, and let this app deploy again: clears dataCopyError. The way out for an app whose source machine has since been turned off, which is how a migration normally ends. Recorded in Activity.
| Argument | Type | Description |
|---|---|---|
id | String! |
mutation {
deployWithoutMigratedData(id: "prj_9f1c2ab7d3e4f5a6") {
id
slug
name
status
productionUrl
}
}startWithoutMigratedData
Needs control_databases. Returns Database.
Accept that the data a migration could not copy is not coming, and let this database start again: clears dataCopyError. The engine will initialise an EMPTY database on the volume, which is exactly what the refusal exists to stop - so this is a decision, not a retry. Recorded in Activity.
| Argument | Type | Description |
|---|---|---|
id | String! |
mutation {
startWithoutMigratedData(id: "db_9f1c2ab7d3e4f5a6") {
id
name
type
version
status
host
port
}
}Source machines
The agent an import needs on the source machine is registered as a migration source: addServer(input: { importOnly: true }) on Servers. These two point a run at where such a machine really is, and hand the machines to another team.
setMigrationMachineAddress
Instance admin only. Returns String.
Point Deplo at where a machine of this panel really is, and remember it for the next attempt. The address is PROVED first - the agent must answer there, over the same pinned certificate - and only then written down, because a remembered address is used automatically and an unproven one would turn a single bad guess into a permanent one. Returns a warning to surface, or null. The source server row is removed at the end of every migration, which is why this is remembered against the SOURCE rather than against that row.
| Argument | Type | Description |
|---|---|---|
address | String! | |
serverId | String! | |
sourceId | String! | The panel's own machine id. Empty string for the host the panel itself runs on. |
url | String! |
mutation {
setMigrationMachineAddress(
url: "https://dokploy.example.com"
sourceId: ""
serverId: "srv_9f1c2ab7d3e4f5a6"
address: "203.0.113.20"
)
}handOverMigrationSources
Needs create_projects. Returns Int.
Point a migration at another team: the machines Deplo installed its agent on to READ the panel are granted to the team it now lands in. Called right after switching, since every lookup that reads a source is team-scoped. Refused while a run is in flight in the team being left. Returns how many machines moved.
| Argument | Type | Description |
|---|---|---|
fromTeamId | String! |
mutation {
handOverMigrationSources(fromTeamId: "team_9f1c2ab7d3e4f5a6")
}Takeover
A takeover runs after a migration finished on the same machine: the installer stops the other platform, inherits its certificates, moves the proxy onto 80/443 and removes the other platform. The installer's own endpoint is REST, see REST endpoints.
takeover
Instance admin only. Returns Takeover.
The takeover in progress, or null. The screen that replaces the dashboard reads its own state server-side; this is the poll behind the buttons, and only an instance admin has any.
query {
takeover {
state
platform
dataLoss
error
}
}takeoverPreflight
Instance admin only. Returns TakeoverPreflight.
Null when this panel's own machine is not a server Deplo knows about, which is the one case where none of it can be measured.
query {
takeoverPreflight {
agentReady
diskTight
diskFreeBytes
}
}requestTakeover
Instance admin only. Returns Takeover.
Hand the machine to Deplo. The installer is waiting for this: it stops the other platform, inherits its certificates, moves Traefik onto 80/443 and the dashboard onto 3000, and then takes the other platform off the disk for good.
| Argument | Type | Description |
|---|---|---|
acceptDataLoss | Boolean | The operator accepts that the services dataLoss names lose their data: their copy failed, and the cutover stops the old panel for good. Refused without it while dataLoss is not empty. |
discardData | Boolean | A clean install: nothing is brought across, and the other platform is deleted with everything on it. There is no run to check and nothing can be recovered. |
noOtherTeams | Boolean | The operator says this panel has no team left to bring over. Required only when the migration itself says one is still owed, because a token reads a single team and the cutover stops the panel for good. |
runId | String | The migration that finished. Required unless discardData says nothing is being brought across. |
mutation {
requestTakeover(runId: "run_9f1c2ab7d3e4f5a6") {
state
platform
dataLoss
}
}cancelTakeover
Instance admin only. Returns TakeoverCancelResult.
Back out. Starts again everything the migration stopped on the other platform, undoes what it created here, and tells the installer to uninstall Deplo. The panel's API token is wiped when a run ends, so one has to be handed over again.
| Argument | Type | Description |
|---|---|---|
apiKey | String |
mutation {
cancelTakeover {
restarted
left
}
}See also
Did this page help you?