DockMV

Move Docker containers — **and their data** — from one host to another, from a web UI, in a few clicks. It handles the whole container, not just the image: named volumes, anonymous volumes, bind mounts, user-defined networks, published ports, environment, capabilities, restart policy, healthchecks and resource limits. You pick what travels, per container and per mount. Two ways to move things: | Mode | What happens | When to use it | | --- | --- | --- | | **Host to host over SSH** | The source streams everything straight into the target's `docker cp` / `docker load`. Nothing touches disk in between. | The two hosts can reach each other. | | **Migration package** | Builds a self-contained folder or `.tar` holding the data, the images, and a plain-bash `install.sh`. Carry it on a disk, run the script on the target. | Air-gapped targets, or when you want the move reviewed and replayed later. | The target needs **nothing installed**: no agent, no Python, no Go — just `sshd`, `docker`, `bash` and `gzip`. The **source** is picked in the UI: the daemon DockMV runs next to, another daemon by address, or a remote host over SSH — which needs nothing installed either. One DockMV can therefore move containers between any two of your hosts. --- ## Install Run DockMV on the **source** host (the one holding the containers to move), or anywhere that can reach it — see [sources](#sources). ### Docker Compose — recommended ```bash git clone https://git.azuze.fr/kawa/DockMV.git dockmv && cd dockmv docker compose up -d docker compose logs dockmv # prints the URL, including the access token ``` Open the printed URL. It binds to `127.0.0.1` only; reach it from your laptop with a tunnel: ```bash ssh -L 8080:127.0.0.1:8080 you@source-host ``` Uses the published image `git.azuze.fr/kawa/dockmv:latest`. Pin a version with `VERSION=v1.2.0 docker compose up -d`, and set a fixed token with `DOCKMV_TOKEN` in the compose file to keep the same URL across restarts. ### Prebuilt binary Grab the archive for your platform from the [releases](https://git.azuze.fr/kawa/DockMV/releases) (`dockmv--.tar.gz`, `.zip` on Windows). The binary is static and embeds the web UI — nothing to install alongside it. ```bash tar xzf dockmv-linux-amd64.tar.gz ./dockmv-linux-amd64 serve ``` ### From source ```bash git clone https://git.azuze.fr/kawa/DockMV.git dockmv && cd dockmv make build # rebuilds the UI, then the binary ./dockmv serve ``` `go build .` alone also works — the built UI is committed, so the Node toolchain is optional. > DockMV needs access to the Docker socket on the source host: run it as a user in the `docker` > group, or as root. That is equivalent to root on that host, so keep the UI on loopback. --- ## Dependencies ### Source host — where the containers are | Requirement | Notes | | --- | --- | | Docker daemon + access to `/var/run/docker.sock` | reads containers and streams their data | | Docker Compose | only for the compose install | Nothing else. The binary is static: no libc, no runtime, no Python. A **remote** source needs the same as a target — `sshd` and a `docker` CLI of 18.09 or newer, since the Engine API is tunnelled through `docker system dial-stdio`. Nothing is installed there either. ### Target host — where containers land | Requirement | Why | | --- | --- | | `sshd`, reachable from the source | transport | | `docker` CLI + a working daemon | everything | | the login user can use docker | either in the `docker` group, or tick *run docker through `sudo -n`* | | `gzip` | compressed transfers; without it the tool falls back to uncompressed | | `bash` | only for the migration package installer | The **connect** button runs a preflight and tells you which of these are missing, plus the target's free disk space and architecture. ### Building from source | Tool | Version | | --- | --- | | Go | 1.25+ | | Node | 20+ (22 in CI) — only to rebuild the UI | | `make` | optional, wraps the two above | | PowerShell 7+ | only for `make publish` / `make release` | Libraries: [`docker/docker`](https://github.com/docker/docker) v28.3.3 and `golang.org/x/crypto` on the Go side; React 19, Vite 7 and TypeScript 5.9 on the UI side. That is the whole list. --- ## Using it 1. **Source host** — top of the right panel. Defaults to the daemon DockMV runs next to; pick another one to read a different host. See [sources](#sources). 2. **Containers tab** — everything on the source host, grouped by compose project. Tick what to move. 3. **Expand a row** (`▸`) for per-container details: target name, image pulled or transferred, networks and ports, and — per mount — **copy the data**, **create it empty**, or **do not mount it**. Bind mounts can be relocated; named volumes renamed. 4. **Apply to selected** does the same thing to every selected container at once. 5. **Right panel** — add the target host, *connect*, then **migrate over SSH** or **build a package**. 6. **Jobs tab** — live progress per container and per mount, with the full command log. Start with **dry run** ticked: it runs every check and prints every command without touching the target. **Preview the commands** shows the exact `docker` invocations that will run. Nothing is hidden. ### Sources Three kinds, all interchangeable once selected — the container list, the preview, the migration and the package build all read from whichever source is active: | Kind | How it is reached | Notes | | --- | --- | --- | | **this host** | the socket in `DOCKER_HOST`, or `--docker-host` | always present; cannot be edited or removed | | **docker address** | `tcp://host:2375`, or another `unix://` socket | TLS uses the certificates from `DOCKER_CERT_PATH` in DockMV's own environment. A plain `tcp://` daemon is unauthenticated — anyone who reaches that port is root on that host | | **ssh** | the remote host's own docker CLI, through `docker system dial-stdio` | host keys are verified and credentials handled exactly like a target's | The selected source is remembered in `/sources.json` and reselected on the next start; an explicit `--docker-host` on the command line overrides it for that run. Saved sources whose credentials you chose not to remember ask for them again after a restart. With a remote source the data relays through DockMV — source → this host → target — so it crosses the network twice. Running DockMV on the source host keeps it to one hop.
How the data is actually moved One mechanism for every kind of data location: ``` source daemon ──CopyFromContainer(/mount/path)──▶ tar stream ──gzip──▶ ssh ──▶ docker cp -a - ctr:/parent ``` The mount is read through the Docker archive API — the same thing `docker cp` uses. So: - named volumes, anonymous volumes and bind mounts are handled identically; - no helper image is pulled, and the image does not need `tar` inside it; - it works whether the container is running or stopped; - ownership, permissions, symlinks and hardlinks are preserved (`docker cp -a`). On the target the container is **created first, started last** — creating it is what makes Docker materialise the volumes and bind directories; data is copied into the stopped container, then it starts. Mounts declared **read-only** get a throwaway container (never started) with the volume attached writable, and it is removed straight after. **Faithfully reproduced:** image (pull or layer transfer), command, entrypoint, environment, labels, working directory, user, hostname, published and exposed ports, all mount types, user-defined networks with subnets and aliases, DNS, extra hosts, capabilities, devices, sysctls, ulimits, security options, restart policy, stop signal and timeout, healthcheck, log driver, memory/CPU/pids limits, privileged, read-only rootfs, init, and the PID/IPC/UTS/userns modes. Settings that come from the **image** are deliberately not re-emitted, so the recreated container carries only genuine run-time overrides and keeps working when the image is updated. **What it will not do:** - `--rm` is never reapplied — a container that deletes itself cannot be inspected. - `--volumes-from` and `--network container:other` are not reproduced; you are warned. - Swarm services are out of scope. Plain containers only. - **Live databases**: copying a running database's files is crash-consistent at best. The default stops the source container while copying — leave it on, or migrate a dump instead. - **Cross-architecture**: an `amd64` image will not run on `arm64`. The preflight shows the target's arch.
Safety The tool can stop containers and read every volume on the host, so it is treated as a privileged admin tool: - Binds to **`127.0.0.1` by default**. Binding elsewhere auto-generates an access token and prints it. - **SSH host keys are verified** like OpenSSH, for sources as well as targets. An unknown key is refused until you approve the fingerprint in the UI; a *changed* key is refused outright. Trusted keys go to `/known_hosts`. - **Credentials are not persisted unless you ask.** *Remember* writes them to `/connections.json` for targets and `/sources.json` for sources, mode `0600`. - **Nothing on the target is overwritten by default.** An existing container name fails the item; you pick *skip*, *rename* or *replace*. An existing volume is reused and merged into, never silently deleted, unless you pick *replace*. - Every command that runs on the target is echoed into the job log. - Bind mounts of `/var/run/docker.sock`, `/proc`, `/sys`, `/dev` and `/` are flagged; a mount at `/` is refused.
The migration package `build package` produces: ``` shop-migration/ install.sh self-contained bash; read it, it is the whole contract manifest.json machine-readable description of everything inside README.txt instructions for whoever runs it images/ docker image archives (.tar.gz) data/ one archive per mount, per container ``` On the target: ```bash ./install.sh --dry-run # print every command, change nothing ./install.sh # restore ``` The installer never parses the manifest — every command is written out literally, so it can be audited before running. It checksums each payload, and supports: ``` --dry-run print every command without changing anything --yes do not ask for confirmation --no-start create the containers but leave them stopped --conflict MODE fail (default) | skip | replace | rename --rename-suffix S suffix used by --conflict rename --skip-verify do not checksum the payloads --only NAME[,NAME...] restore only these containers --docker CMD docker command to use --sudo prefix docker with sudo -n ```
Command line and HTTP API ``` dockmv [serve] [flags] start the web interface (default) dockmv inspect [flags] print the source inventory as JSON dockmv version ``` `serve` flags: ``` --addr string address to listen on (default "127.0.0.1:8080") --token string require this token on every request; "auto" generates one --data-dir string sources, connections and trusted host keys (default: OS config dir) --package-dir string where migration packages are written (default /packages) --docker-host string local source docker daemon (default: the DOCKER_HOST environment); given explicitly, it overrides the remembered source -v verbose logging ``` `inspect` is handy for scripting and for reporting bugs: ```bash dockmv inspect --sizes | jq '.containers[] | {name, image, mounts}' ``` Everything the UI does is available over HTTP. Pass the token as `X-Auth-Token` when one is set. ``` GET /api/health GET /api/source inventory + default selections GET /api/source/sizes volume sizes (slow) GET /api/sources known sources + which one is selected POST /api/sources DELETE /api/sources/{id} POST /api/sources/{id}/select switch the source everything reads from POST /api/sources/{id}/probe read the SSH host key fingerprint POST /api/sources/{id}/trust approve that fingerprint GET /api/connections POST /api/connections DELETE /api/connections/{id} POST /api/connections/{id}/probe read the SSH host key fingerprint POST /api/connections/{id}/trust approve that fingerprint POST /api/connections/{id}/test preflight the target GET /api/connections/{id}/inventory what is already on the target POST /api/plan/preview render the commands, run nothing POST /api/migrate/ssh start a host-to-host migration POST /api/migrate/package start a package build GET /api/jobs, /api/jobs/{id} GET /api/jobs/{id}/events server-sent events, live progress POST /api/jobs/{id}/cancel GET /api/packages, /api/packages/{name}/download ```
Development ``` web/ React + TypeScript UI (vite) internal/spec/ the transport model: a container, and how to render it back into docker flags internal/dkr/ source Docker daemon: inventory, archive streams, image save internal/sshx/ SSH transport, host key trust, driving the target's docker CLI, and tunnelling a remote source's API through `docker system dial-stdio` internal/store/ saved sources and target connections, and which source is selected internal/migrate/ the two engines: SSH streaming, and package + installer generation internal/job/ progress tracking for long-running work internal/api/ HTTP handlers and SSE internal/webui/ the built UI, embedded into the binary ``` ```bash make test # go test ./... make vet make ui # rebuild the embedded UI cd web && npm run dev # UI dev server on :5173, proxying /api to :8080 ``` `internal/webui/dist` is committed so that a plain `go build .` produces a working binary without a Node toolchain. Rerun `make ui` after changing anything under `web/`. End-to-end tests, on a Linux host with Docker (a VM is fine): ```bash go test ./... # unit tests: command rendering, plan resolution, # and the generated installer, checked with bash go test -tags e2e ./test/... -v # end to end, against the real daemon ``` - `TestPackageRoundTrip` — builds a package and runs the generated `install.sh` for real. - `TestSSHMigration` — drives the host-to-host engine over a genuine SSH connection to `127.0.0.1`, exercising the whole transport. Needs `DM_SSH_HOST`, `DM_SSH_USER` and `DM_SSH_KEY`, skips without them: ```bash ssh-keygen -t ed25519 -N '' -f ~/.ssh/dm_loop cat ~/.ssh/dm_loop.pub >> ~/.ssh/authorized_keys DM_SSH_HOST=127.0.0.1 DM_SSH_USER=root DM_SSH_KEY=~/.ssh/dm_loop \ go test -tags e2e ./test/... -run TestSSHMigration -v ``` They restore onto the same daemon under a suffixed name and clean up, so one machine is enough. Both were run against Debian 13 with Docker 29.7.2, alongside a browser pass over the web UI.