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 inspectruntime.ParseInspectSpecFromInspect (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 MultiReport still 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).