Migration engine
How the Coolify→HostSSH container migrator works — the single-container mover (M1), multi-container apps (M4), and the staged DNS cutover with health-verify + auto-rollback. Schematics + code map.
Migration engine
The migrator is HostSSH's "move any running workload onto the fleet" primitive. It reads a
container's live config off a source box over SSH, physically moves its image (Coolify
images live in no registry), and recreates it here through the normal deploy pipeline —
without cutting DNS. DNS is a separate, deliberate flip. Code: agent/internal/migrate
(engine), control-plane/web/lib/fleet/coolify-*.ts + cutover.ts (dashboard side).
Mental model: inspect → move image (+volumes) → deploy + verify → (separately) cut over. Landing a copy and flipping traffic are two different acts, on purpose.
M1 — single container
source box (.207, Coolify) THIS node (HostSSH)
┌───────────────────────┐ ┌───────────────────────┐
│ docker inspect <ctr> │ ── ssh ──▶ config │ │
│ docker save <image> │ ══ image stream ══▶ │ docker load │
│ (volume tars, opt-in) │ ══ volume data ═══▶ │ restore named volumes │
└───────────────────────┘ │ deploy.Deploy(spec) │
│ build=image → run → │
│ route(Traefik+ACME) │
│ → verify.Confirm │
└───────────────────────┘
DNS NOT cut over ✋
migrate.Run(RunOptions) (run.go): ssh source docker inspect → runtime.ParseInspect →
SpecFromInspect (maps the live config to a deploy.Spec, Builder=image, derives the port,
fails closed on >1 exposed port, named volumes only — binds surfaced as warnings) →
streamImage (docker save | docker load, because a Coolify-built image is a local
<uuid>:<sha> ref in no registry) → optional streamVolume per named volume → deploy.Deploy.
ResolveContainer lets a caller pass a name prefix (the dashboard only knows a Coolify app's
uuid; the running container is <uuid>-<suffix>) — resolved on-box against docker ps,
fail-closed on ambiguity. CLI: hostssh migrate <ctr> --source-ssh root@host --app <name> [--volumes] [--dry-run].
Every value interpolated into a remote shell line is safeRef-guarded (ssh joins argv into one
string run by the remote shell).
M4 — multi-container apps (migrate.RunMulti)
Many workloads are several containers (web + worker + redis; CoreReflex's render + clip + redis;
usermails' api + web + workers + mta). RunMulti (multi.go) migrates a set together onto the
shared network so they stay reachable by name (Docker's embedded DNS resolves container names
on a user network).
hostssh migrate --source-ssh root@.207 \
--containers reflexware-redis,corereflex-render-worker,corereflex-clip-proxy
for each, IN ORDER (dependencies first):
migrate.Run(container → app=<name>, network=hostssh) ← reuses M1 per container
│
▼
┌──────────── network: hostssh ────────────┐
│ reflexware-redis ◀── corereflex-render │ name-reachable
│ ◀── corereflex-clip │ (docker DNS)
└───────────────────────────────────────────┘
- Order matters — put dependencies (a redis, a database) first so they are up before the services that need them.
- Fail-closed on a real run — a half-migrated app is incoherent, so the first failure stops
and returns; the
MultiReportstill lists what landed, so the operator can clean up or resume. - Dry-run reports every container's plan without stopping.
- The per-container migrator is an injectable package var (
migrateOne), so the orchestration (order, shared network, fail-closed) is unit-tested without a real SSH/Docker move.
Staged DNS cutover (lib/fleet/cutover.ts)
The deliberate flip. Order of operations is the whole safety story:
1. PRE-FLIGHT probe the DESTINATION origin directly (SNI/Host = domain) ── fail → abort, DNS untouched
2. SNAPSHOT read the current A-record (rollback target)
3. FLIP upsert A-record → destination IP (Cloudflare connector)
4. VERIFY poll the domain through its public edge (retries) ── fail ┐
5. ROLLBACK restore the snapshot automatically ◀───────┘
runStagedCutover never throws for an operational failure — the report's outcome
(cut_over | aborted | rolled_back | rollback_failed) + per-step log carry what happened
(a throw would hide a half-done flip). Deps are injectable, so the state machine is unit-tested
without a network. Server action cutoverDomainAction gates on provisioning.manage and a
typed-back domain confirmation. Dashboard: the "Cut over DNS" drawer on /deploy.
Dashboard-driven path
The importer (coolify-import.ts) reads a Coolify box's apps into reviewable candidates;
migrateContainerAction enqueues a migrate job pinned to the destination Node's agent (which
must have SSH access to the source). Each candidate carries the Coolify uuid as a containerHint
that the agent resolves to the live container name. COOLIFY_SOURCE_SSH prefills the source
target in the "Migrate live" drawer.
What the migrator does NOT do
- No DNS cutover inside a migrate — always the separate staged step above.
- Bind mounts aren't auto-moved (surfaced as warnings) — only named volumes.
- girard-media (revenue) migrates LAST. Status: engine + dry-runs proven; the first real
production cutover is the next milestone (see
planning/SESSION-HANDOFF-2026-07-12.md).