feat(servers): add cross-instance server migration (#11075)

This commit is contained in:
Andras Bacsai authored and GitHub committed 2026-08-07 23:07:55 +02:00
1 parent c15e3b35fd
commit f9caa5250d
36 files changed
+7675 -15

No files matched your search

@@ -141,6 +141,14 @@
['label' => 'Terminal Access', 'route' => 'server.security.terminal-access', 'active' => request()->routeIs('server.security.terminal-access'), 'icon' => 'browser-terminal', 'navigate' => false],
],
],
[
'label' => 'Transfer',
'route' => 'server.transfer',
'active' => $activeMenu === 'transfer',
'icon' => 'arrow-right',
'group' => 'Operations',
'visible' => isDev() && ! $server->isLocalhost() && auth()->user()?->can('view', $server),
],
[
'label' => 'Danger',
'route' => 'server.delete',
@@ -5,27 +5,48 @@
<div class="mb-5 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h1 class="min-w-0 text-[24px]! leading-7! font-semibold! tracking-tight!">Servers</h1>
@can('createAnyResource')
<a href="{{ route('server.create') }}" {{ wireNavigate() }}
class="button w-fit shrink-0 whitespace-nowrap button-highlighted">
<x-reicon name="plus" class="size-3.5" />
New server
</a>
@endcan
<div class="flex flex-wrap items-center gap-2">
@if (isDev())
@can('create', App\Models\Server::class)
<a href="{{ route('server.transfer.import') }}" {{ wireNavigate() }}
class="button w-fit shrink-0 whitespace-nowrap">
<x-reicon name="upload" class="size-3.5" />
Import transfer
<x-status-badge label="Dev" />
</a>
@endcan
@endif
@can('createAnyResource')
<a href="{{ route('server.create') }}" {{ wireNavigate() }}
class="button w-fit shrink-0 whitespace-nowrap button-highlighted">
<x-reicon name="plus" class="size-3.5" />
New server
</a>
@endcan
</div>
</div>
@php
$serverRows = $servers->map(function ($server) {
$isTransferredAway = $server->isTransferredAway();
$isReady = $server->settings->is_reachable
&& $server->settings->is_usable
&& ! $server->settings->force_disabled;
&& ! $server->settings->force_disabled
&& ! $isTransferredAway;
$status = match (true) {
$isTransferredAway => 'Transferred away',
$server->settings->force_disabled => 'Disabled',
$isReady => 'Ready',
default => 'Validation required',
};
return [
'uuid' => $server->uuid,
'name' => $server->name,
'description' => $server->description ?: 'No description',
'href' => route('server.show', ['server_uuid' => $server->uuid]),
'status' => $isReady ? 'Ready' : ($server->settings->force_disabled ? 'Disabled' : 'Validation required'),
'status' => $status,
'statusType' => $isReady ? 'success' : 'error',
'ready' => $isReady,
];
+17 -3
View File
@@ -89,8 +89,12 @@
</x-forms.button>
@endif
@endif
<x-status-badge :label="$server->isFunctional() ? 'Ready' : 'Validation required'"
:type="$server->isFunctional() ? 'success' : 'warning'" />
@if ($server->isTransferredAway())
<x-status-badge label="Transferred away" type="warning" />
@else
<x-status-badge :label="$server->isFunctional() ? 'Ready' : 'Validation required'"
:type="$server->isFunctional() ? 'success' : 'warning'" />
@endif
</x-slot:actions>
<div class="flex items-start gap-3">
@@ -103,7 +107,9 @@
{{ $server->name }}
</p>
<p class="mt-1 text-xs leading-5 text-neutral-500 dark:text-fg-dim">
@if ($server->isFunctional())
@if ($server->isTransferredAway())
This server was migrated away from this Coolify instance and cannot be managed here.
@elseif ($server->isFunctional())
The server is reachable, validated, and ready to host resources.
@else
Validate the SSH connection before using this server.
@@ -206,6 +212,14 @@
</x-process-dialog>
</x-slot:actions>
@if ($server->isTransferredAway())
<x-callout type="warning" title="Transferred to another instance" class="mb-4">
This server was migrated away from this Coolify instance. It cannot be revalidated or
managed here. Use the target instance, or delete this server when you no longer need the
archive.
</x-callout>
@endif
@if ($this->limaStartCommand)
<x-callout type="info" title="Start this Lima VM locally" class="mb-4">
<code
@@ -0,0 +1,75 @@
<div>
<x-slot:title>
Import server transfer | Coolify
</x-slot>
<div class="flex flex-col gap-6">
<div class="flex flex-wrap items-center gap-2">
<h1>Import server transfer <x-status-badge label="Dev" /></h1>
<a href="{{ route('server.index') }}" {{ wireNavigate() }}>
<x-forms.button>Back to servers</x-forms.button>
</a>
</div>
<div class="subtitle">
Paste or upload a transfer bundle exported from another Coolify instance. This creates the server and its
resources under the current team and <strong>claims</strong> the host for this instance
(control-plane only — host data stays on the machine).
</div>
<div class="flex flex-col gap-4 rounded-lg border border-neutral-200 p-4 dark:border-coolgray-200">
<x-forms.input type="file" id="bundleFile" label="Bundle file (.json)" accept=".json,application/json" />
<x-forms.textarea id="bundleJson" label="Or paste bundle JSON" rows="14" placeholder='{"schema_version":1,...}' />
<x-forms.input id="passphrase" type="password" label="Passphrase (if encrypted)" placeholder="Optional" />
<div class="flex flex-col gap-2">
<x-forms.checkbox id="preserveUuids" label="Preserve UUIDs from the source instance" />
<x-forms.checkbox id="adoptMode" label="Adopt mode (keep statuses; do not force exited redeploy)" />
<x-forms.checkbox id="writeRemote" label="Also write ownership file on the host via SSH (optional)" />
</div>
<div class="flex flex-wrap gap-2">
<x-forms.button wire:click="dryRun" wire:loading.attr="disabled" wire:target="dryRun,importBundle">
<span wire:loading.remove wire:target="dryRun">Dry run</span>
<span wire:loading wire:target="dryRun">Checking…</span>
</x-forms.button>
<x-forms.button wire:click="importBundle" wire:loading.attr="disabled"
wire:target="dryRun,importBundle"
wire:confirm="Import this server into the current team?">
<span wire:loading.remove wire:target="importBundle">Import server</span>
<span wire:loading wire:target="importBundle">Importing…</span>
</x-forms.button>
</div>
</div>
@if (count($lastWarnings) > 0)
<div class="rounded-lg border border-warning/40 bg-warning/10 p-3 text-sm">
<div class="mb-1 font-semibold text-warning">Warnings</div>
<ul class="list-disc space-y-1 pl-5">
@foreach ($lastWarnings as $warning)
<li>{{ $warning }}</li>
@endforeach
</ul>
</div>
@endif
@if ($lastResult)
<div class="rounded-lg border border-neutral-200 p-4 dark:border-coolgray-200">
<div class="mb-2 font-semibold">
{{ data_get($lastResult, 'dry_run') ? 'Dry-run result' : 'Import result' }}
</div>
@if ($importedServerUuid)
<div class="mb-3 flex flex-wrap items-center gap-2 text-sm">
<span>Server UUID: <code class="font-mono">{{ $importedServerUuid }}</code></span>
@if (data_get($lastResult, 'claimed'))
<span class="text-success">Claimed</span>
@endif
<a href="{{ route('server.show', ['server_uuid' => $importedServerUuid]) }}" {{ wireNavigate() }}>
<x-forms.button>Open server</x-forms.button>
</a>
<a href="{{ route('server.transfer', ['server_uuid' => $importedServerUuid]) }}" {{ wireNavigate() }}>
<x-forms.button>Transfer details</x-forms.button>
</a>
</div>
@endif
<pre class="max-h-80 overflow-auto rounded-lg bg-neutral-100 p-3 text-xs dark:bg-coolgray-100">{{ json_encode($lastResult, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}</pre>
</div>
@endif
</div>
</div>
@@ -0,0 +1,146 @@
<div>
<x-slot:title>
{{ data_get_str($server, 'name')->limit(10) }} > Transfer | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div
class="server-settings-workspace application-settings-workspace mt-4 grid w-full max-w-[1180px] min-w-0 gap-8 lg:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
<x-server.sidebar :server="$server" activeMenu="transfer" />
<div class="application-settings-form flex w-full flex-col gap-6">
@if ($this->isLocalhost)
<x-application.settings-section id="server-transfer-section" title="Transfer server (Dev)"
helper="Move this server’s control-plane config to another Coolify instance (same physical host).">
<x-callout type="warning" title="Localhost cannot be transferred">
The Coolify host (localhost) cannot be transferred between instances.
</x-callout>
</x-application.settings-section>
@else
<x-application.settings-section id="server-transfer-section" title="Transfer server (Dev)"
helper="Move this server’s control-plane config to another Coolify instance (same physical host).">
<x-slot:actions>
<x-status-badge
:label="$this->transferStatus ?: 'ready'"
type="neutral" />
@if ($exportId)
<span class="text-[11px] text-neutral-500 dark:text-fg-faint">export {{ $exportId }}</span>
@endif
</x-slot:actions>
<div class="flex flex-col gap-4">
<div>
<h3 class="text-sm font-semibold text-neutral-950 dark:text-fg">Transfer to another instance
</h3>
<p class="mt-1 text-xs leading-5 text-neutral-500 dark:text-fg-dim">
Enter the target Coolify URL and an API token from that instance (root recommended).
This exports the server, imports and claims it on the target, then disables automations
here.
</p>
</div>
<div class="flex flex-col gap-3 md:max-w-xl">
<x-forms.input id="targetUrl" label="Target instance URL" required
placeholder="http://localhost:8001"
helper="Base URL of the other Coolify instance (no trailing path)." />
<x-forms.input id="targetToken" type="password" label="Target API token" required
placeholder="Paste token from target instance" autocomplete="off"
helper="Create a token on the target with root (or write + create servers). It is only used for this request." />
<x-forms.checkbox id="writeRemote"
label="Write ownership file on the host via SSH (optional)" />
</div>
<div>
<x-forms.button canGate="update" :canResource="$server" wire:click="migrateServer"
wire:loading.attr="disabled"
wire:confirm="Transfer this server to the target instance? Automations will be disabled here.">
<span wire:loading.remove wire:target="migrateServer">Transfer server</span>
<span wire:loading wire:target="migrateServer">Transferring…</span>
</x-forms.button>
</div>
@if (count($lastWarnings) > 0)
<x-callout type="warning" title="Warnings">
<ul class="mt-2 list-disc space-y-1 pl-5 text-sm">
@foreach ($lastWarnings as $warning)
<li>{{ $warning }}</li>
@endforeach
</ul>
</x-callout>
@endif
@if ($lastResultJson)
<div>
<div class="mb-1 text-sm font-semibold">Result</div>
<pre
class="max-h-64 overflow-auto rounded-lg bg-neutral-100 p-3 text-xs dark:bg-coolgray-100">{{ $lastResultJson }}</pre>
</div>
@endif
</div>
</x-application.settings-section>
<x-application.settings-section id="server-transfer-advanced-section" title="Advanced"
helper="Manual export, complete-only, and re-claim helpers for air-gapped or partial transfers.">
<div class="flex flex-col gap-6" x-data="{ open: @entangle('showAdvanced') }">
<button type="button"
class="flex w-full items-center justify-between rounded-lg border border-neutral-200 px-4 py-3 text-left dark:border-white/[0.08]"
@click="open = !open">
<span class="text-sm font-semibold">Show advanced options</span>
<span class="text-xs text-neutral-500 dark:text-fg-faint"
x-text="open ? 'Hide' : 'Show'"></span>
</button>
<div class="flex flex-col gap-6" x-show="open" x-cloak>
<div>
<h4 class="text-sm font-medium">Download bundle</h4>
<p class="mb-3 text-xs leading-5 text-neutral-500 dark:text-fg-dim">
Manual / air-gapped transfer. Import on the target via Servers → Import transfer.
</p>
<div class="mb-3 flex flex-col gap-3 md:max-w-xl">
<x-forms.checkbox id="encryptBundle" label="Encrypt with passphrase" />
<x-forms.input id="passphrase" type="password" label="Passphrase"
placeholder="Used when encrypt is checked" autocomplete="new-password" />
</div>
<div class="flex flex-wrap gap-2">
<x-forms.button canGate="view" :canResource="$server" wire:click="exportBundle"
wire:loading.attr="disabled">
<span wire:loading.remove wire:target="exportBundle">Download JSON</span>
<span wire:loading wire:target="exportBundle">Exporting…</span>
</x-forms.button>
<a href="{{ route('server.transfer.import') }}" {{ wireNavigate() }}
class="button">
Import page (this instance)
</a>
</div>
</div>
<div>
<h4 class="text-sm font-medium">Complete only</h4>
<p class="mb-3 text-xs leading-5 text-neutral-500 dark:text-fg-dim">
Disable automations after a manual import on the target.
</p>
<x-forms.button canGate="update" :canResource="$server" wire:click="completeTransfer"
wire:loading.attr="disabled"
wire:confirm="Disable automations on this server?">
Mark transferred & disable automations
</x-forms.button>
</div>
<div>
<h4 class="text-sm font-medium">Re-claim only</h4>
<p class="mb-3 text-xs leading-5 text-neutral-500 dark:text-fg-dim">
Retry claim on this instance (after a local import).
</p>
<div class="mb-3 flex flex-col gap-2">
<x-forms.checkbox id="writeRemoteOnClaim" label="Write ownership file via SSH" />
<x-forms.checkbox id="rebindSentinelOnClaim" label="Rebind Sentinel" />
</div>
<x-forms.button canGate="update" :canResource="$server" wire:click="claimServer"
wire:loading.attr="disabled">
Re-claim
</x-forms.button>
</div>
</div>
</div>
</x-application.settings-section>
@endif
</div>
</div>
</div>