Skip to main content
Use this when you have an SSH login on the old server but no platform API token, or you do not want to install anything there. The control plane opens an SSH connection, uses it only to reach the old host’s Docker socket, and reads containers, volumes and images through the Docker Engine API. It never runs a command on the old host. For the token based importer, see Migrating from Coolify, Dokploy, or CapRover.

How it works

  • SSH is a transport only. The control plane opens one direct-streamlocal@openssh.com channel to the Docker socket (default /var/run/docker.sock) and speaks HTTP over it.
  • There is no SSH session, no shell, no exec, no PTY, no port forwarding and no SFTP. A test fails the build if the code that does this references any of them.
  • The old host’s SSH server does the connecting to the socket, as the SSH user. Nothing is installed there.

What it reads

Every request is checked on the control plane before it is sent. Anything off this list is refused with a typed error and never leaves the process.

What it never does

  • Any request that is not a GET: no create, start, stop, remove, exec, attach, build, commit, image pull or load, auth or session call.
  • Logs, events, stats, exports and archives of paths that are not a mount of the container.
  • Write to the old host in any way. Stopping apps there, changing DNS and removing the old platform stay your decision.
  • Store the SSH key or password. They are held in the control plane process memory for the session (up to two hours by default, APP_MIGRATE_HUB_PASSWORD_TTL), never written to the database, settings or logs, never returned by the API, and zeroed when the session is forgotten or expires. Anyone who can read that process memory during the session can read them.
  • Read the old platform’s stored config. Env and settings come from the running containers only. Coolify’s settings database, CapRover’s data folder and Dokku’s app folder are not read. Domains that live only in that stored config (for example a Dokku nginx file) have to be added after the plan.

Connect

  1. Open Migrate, choose Import apps, then the Connect over SSH tab.
  2. Enter the address, SSH user and the private key (paste it or load a file). A passphrase or a password is optional.
  3. Press Read host key. The control plane connects without logging in and shows the host keys the server presents.
  4. Compare one fingerprint with what the old server reports, pick it and tick the confirmation. Connecting is refused until a fingerprint is confirmed.
  5. Press Test connection. It shows exactly what will be read (“read only: lists containers, volumes and images”) and the counts it found.
  6. Press Read inventory. From here the flow is the same as any import: plan, stage, images, verify, volumes, cutover, rollback.
CLI equivalent:
The passphrase comes from APP_IMPORT_SSH_PASSPHRASE and a password from APP_IMPORT_SSH_PASSWORD. --insecure-host-key skips host key verification. It is warned about in the CLI, the dashboard and the session view, and it exposes the login to anyone on the path, so use it only on a network you control.

Images and volumes

  • Images. Host built images are streamed through the same tunnel (GET /images/{name}/get), loaded on the target node and verified by content and image ID. No docker save runs on the old host, and the Move images step needs no second login.
  • Volumes. For a running container, each named volume is streamed with the container archive endpoint and unpacked into the staged app’s volume. Extraction refuses names that leave the volume, never writes through a symlink, skips symlinks that point outside, hard links and device nodes (reported per volume), and stops at a size and file count cap (APP_MIGRATE_VOLUME_MAX_BYTES, default 10 GiB, and APP_MIGRATE_VOLUME_MAX_FILES, default 500000). Run the copy again right before the DNS switch to catch the last changes.
  • Fallback. Stopped containers and bind mounts are not copied through the tunnel. The Volumes step keeps the printed rsync commands for them.
The tunnel copy writes to the volume directory on the control plane node, so it works for apps placed on that node. Apps placed on another node use the rsync commands.

Failures and their fixes

The Test connection result names the cause and offers the fix as a button where the control plane can do it.

Hardening the old host (optional)

Whoever holds the SSH key has the same power as the SSH user on the old host. The Docker socket is root equivalent, so holding the key means root on that host. The allowlist protects what this control plane sends, not the host from the key. Use a key made for this move and remove it from authorized_keys when you finish. If you want sshd to restrict the key to the socket, a line like this is worth trying:
restrict turns off everything not re-enabled, and permitopen limits what the key may forward to. How permitopen behaves for Unix sockets (direct-streamlocal) varies between OpenSSH versions and this has not been verified against a live host here. Test it: run Test connection with the restricted line in place, and loosen it if the connection is refused. A restriction that breaks the connection is safe, one that is silently ignored is not, so also confirm that the key cannot do more than you intend.

API

All of them need the write:sensitive ability (or root) except the GET status. The ssh object takes host, port, user, private_key, passphrase, password, host_key_fingerprint, insecure_host_key and socket_path. Secrets are accepted in the request body only and are never echoed.
Last modified on October 20, 2026