- Import the apps and databases (empty).
- Copy the data in and verify row counts.
- Copy volumes, then run the app here in parallel with the source.
- Check the cutover and switch DNS one domain at a time.
- Dashboard: Settings, then Import from another platform.
- CLI:
levelrail-cli import platform coolify|dokploy|caprover --url URL. - API:
POST /api/v1/imports/platform/discoverand/apply(abilitywrite:sensitive, source token in the request body only).
Quick start
1
Get a source credential
What the token is, per platform:
- Coolify
- Dokploy
- CapRover
An API token (Keys and tokens). Use
read:sensitive to get real env values. It is sent as Authorization: Bearer.2
Dry run
3
Apply, optionally for a subset
APP_IMPORT_SOURCE_TOKEN or --token-stdin: --token works but warns, because it lands in shell history and the process list.
Flags
The command exits non-zero when any item failed, so a script can retry.
Reaching a source on a private network
Sources usually live on your own network, often on a private address. The control plane refuses those by default, because an import URL is an outbound request an operator controls. Allowing it is an explicit, double opt-in:- The operator sets
APP_IMPORT_ALLOW_PRIVATE_NETWORKS=true(orAPP_IMPORT_ALLOW_LOOPBACK=truefor a source on the same host) in the control plane’s environment. - The request also sets
allow_private(orallow_loopback), which the CLI exposes as--allow-privateand--allow-loopbackand the dashboard as a checkbox.
169.254.0.0/16 including 169.254.169.254, fe80::/10, fd00:ec2::/32, 100.100.100.200) and unspecified or multicast addresses are blocked unconditionally, even with both opt-ins, because there is no legitimate reason to point an importer at an instance metadata service. The check runs on the resolved IP at dial time and after redirects, so a hostname that re-resolves to an internal address is still caught. Proxies are ignored for the same reason.
Other limits: 30 second request timeout, 8 MiB per response, bounded project and page walks, TLS verification on unless --insecure-tls is set, credentials embedded in the URL are rejected.
No API token: claim containers from a Docker snapshot
When you cannot or do not want to hand over a Coolify, Dokploy or CapRover token, the Docker host itself is enough. On the old server run:COOLIFY_* variables are left out. The
snapshot holds environment variables including secrets, so delete the file
once the session is staged.
Move images built on the old host
Coolify builds most apps on the server itself, so their images exist nowhere else. After staging and before verify, the wizard’s Move images step (or the CLI) copies them for you:docker save <image> there and loads the stream into the target node through
the Docker Engine API. The check is by content, not by image ID alone: the
stream is read as it passes, and the loaded image’s layers must equal the
layers listed in the saved image config, in order. The snapshot’s image ID
must then match either the config digest of the stream or the loaded image
ID. A wrong or partial image is reported as failed, not trusted.
Image IDs are not portable between Docker image stores. The classic overlay2
store uses the hash of the image config as the ID, while the containerd image
store uses the manifest digest, so the same image can show two different IDs
on the old host and the new node. Comparing layers and the config digest
keeps that case from failing a correct move. A re-run skips an image only
when the target reports the snapshot’s exact ID, so a cross-store image is
copied again on a re-run.
- Only
docker savewith an image reference from the session’s own plan is ever run on the old host. References and the SSH login are validated strictly, no shell orsshbinary runs on the control plane, and the login must be able to rundocker(root or thedockergroup). - The private key (or
--ssh-agent, the control plane’s own agent) is held in memory for the session and never stored or logged. Its passphrase comes fromAPP_IMPORT_SSH_PASSPHRASE. - The old host’s key is trusted on first use and pinned in
import-known-hostsin the data directory. A changed key is refused. - One failing image does not stop the others. A re-run skips images that are already verified on the target, and the list shows them as verified after a control plane restart.
- Limits:
APP_MIGRATE_IMAGE_MAX_BYTES(default 20 GiB per image) andAPP_MIGRATE_IMAGE_TIMEOUT(default1hper image). - The target must be the control plane node for now. Apps placed on a remote
agent node report that its agent cannot load images yet; move those by hand
with
docker save IMAGE | ssh TARGET docker load.
What the report tells you
Every discovered item gets one row with a status:
Apply is idempotent and resumable. Every imported app carries the labels
import/source-id (<platform>:<source id>), import/source-platform and import/source-name. A re-run finds apps by that label and skips them, so a partial failure is fixed by running the same command again: finished items are skipped and only the rest are attempted. Databases have no labels, so a re-run treats an existing database with the same name, engine and version as already imported.
Mapping tables
Apps
Secret-looking names are values whose key matches password, secret, token, key, credential, auth, DSN or a common database URL name. Those values go through the secrets manager (envelope encrypted, bound to the app), never into plain env. A secret variable that came back empty (Coolify redacts values without
read:sensitive) is skipped and called out in the report.
Databases
A database whose engine or version cannot be determined is reported as unsupported.
Compose files and Dokku
The importer package can also map a plain Docker Compose file (one app per service with an image, database-looking images as managed databases) and adokku config:export style environment dump, without contacting anything. These two file sources are not yet exposed through the API, CLI or dashboard, so today they are only reachable from Go code. Live Dokku over SSH is not supported.
Step 2: copy database data
The import creates each database empty. The data copy step fills it from the live source and proves it arrived. It works for PostgreSQL, MySQL, MariaDB, MongoDB and Redis.- The control plane starts a short-lived helper container on the database’s node, from the same engine image. It streams
pg_dump,mysqldump/mariadb-dump,mongodumpor an RDB snapshot from the source and pipes it straight into the managed database. Nothing is written to disk on the control plane and nodockerCLI is involved. - The source is only read. Source credentials are held in memory for the copy and passed to the helper as environment variables. They are never stored, logged or returned, and any error text is scrubbed of the password.
- After the restore, row counts per table (collection counts for MongoDB, key count for Redis) are compared between source and target. The status is
verifiedonly when every source table matches, otherwisefailedwith the tables that differ. - It is idempotent. The restore replaces the target’s contents in one transaction, so a failed or interrupted copy is fixed by running the same command again. A copy that was started and never finished (for example the control plane restarted) can be retried after
APP_MIGRATE_COPY_TIMEOUT(default2h). - Per-database status is
pending,copying,verified,failed(with the reason) orunsupported(with the next step).
- Create the target with the same or a newer major version than the source.
pg_dumprefuses to read a server newer than itself. - The dump is a point in time. For a database that keeps taking writes, copy once now to prove the path, then again right before the DNS switch (see cutover), ideally with the source app stopped or read-only for that last run.
- The source host must be reachable from the node that runs the database. Link-local and metadata addresses are refused.
- ClickHouse, KeyDB and Dragonfly are reported
unsupported. Take a dump with the source’s own tools and restore it with the backup and restore tools. - Redis is copied as an RDB snapshot and the target restarts to load it. Keys that expire during the copy can make the key counts differ slightly, in which case the status is
failedwith both numbers shown. Re-run it.
Migrating a whole database server (Migration hub)
When the source is a database server with many databases, use the Migration hub under Settings, Import from another platform. It is one guided run: Connect, Inventory, Preflight, Copy, Verify, Cutover. The plan is stored on the control plane, so you can leave and come back.- Connect: host, port, user and password once. The password is held in
memory for the session (
APP_MIGRATE_HUB_PASSWORD_TTL, default 2h) and is never stored or logged. Postgres, MySQL, MariaDB and MongoDB are supported. - Inventory: every database with size, table count and extensions. Choose which to copy and edit the name of each new managed database.
- Preflight: nothing is written until it passes. It checks extensions
against what the managed image provides (an extension no image ships blocks
that database, it is never dropped silently), major version, free disk with a
margin (
APP_MIGRATE_DISK_MARGIN, default 1.5), name collisions and an estimated copy time (APP_MIGRATE_ASSUMED_MBPS). - Copy: one managed database per selected source database, copied with
bounded concurrency (
APP_MIGRATE_HUB_CONCURRENCY, default 2). One failure never stops the others and repeating a copy is safe. - Verify: exact per-table row counts. A receipt (JSON, no secrets) records each verified database and that the source was only read.
- Cutover: the connection details and environment variables each app needs. The secret is hidden until you click reveal, which is audited.
default_transaction_read_only=on
and only dump tools and catalog queries.
The same flow from the CLI:
get_migration_plan read-only. Starting a copy needs
the source password and is deliberately not available to agents.
Step 3: copy volumes
Volume contents are not moved by the control plane, because that needs root on the source host. The CLI and the dashboard print the exact command per volume instead:rsync -aHAX --numeric-ids --delete over SSH. Run it once while the source is live to move the bulk, then again right before the DNS switch to pick up the last changes. A bind mount uses the same host path on both sides.
Step 4: cutover
Do not point DNS at this platform until the app is healthy here. The cutover check reads app status and public DNS and gives a go or no-go per domain. It changes nothing.go or switched, so a script can gate on it.
For each domain of each imported app it checks:
The verdicts are:
DNS TTL guidance
- A day or more before the switch, lower the TTL of each domain’s record to 300 seconds (60 if your provider allows) at your DNS provider. Leave the value alone for now.
- Wait at least the old TTL. If it was 3600, wait an hour. Run
migrate cutoveragain: the TTL check turns to pass when the resolvers see the low value. The TTL shown is read from the domain’s authoritative servers, and falls back to a caching resolver (marked as such) if they cannot be reached. - Only then change the record. The check prints it exactly, for example
set A app.example.com to 203.0.113.10 (TTL 300). If the name currently holds a CNAME, remove it first: a name cannot hold a CNAME and an address. - After the move is verified and stable, raise the TTL back up.
Parallel run
The source keeps serving until you change DNS, and keeps serving the clients whose resolvers still cache the old address after you do. Use that:- Run the imported app here with the data copied in and test it directly, for example
curl --resolve app.example.com:443:203.0.113.10 https://app.example.com/healthz. - Do the final data and volume copy, then switch one domain at a time.
- Run
migrate cutover --verify. It checks, against this node with the domain as the TLS server name, that a trusted certificate is served and that the app’s health path answers 200, and separately that public DNS resolves to this node. The certificate is issued on first use, so allow a minute after the first request. - To roll back, point the record at the source again. Because the TTL is low, that takes minutes. Keep the source running for at least a full traffic cycle before stopping it.
What does not migrate
- Databases the copy step does not support (ClickHouse, KeyDB, Dragonfly), and any database you did not run the copy for. They stay empty.
- Volume contents are only moved by running the commands from step 3. Bind mounts need the same host path on the target node and the root ability; without root they are skipped and reported.
- Git-built apps do not build automatically. They are created with a pending image so nothing runs, and the report names the repository and branch. Connect the repository (with credentials for a private one) and run a build.
- CapRover apps have no recoverable image, because CapRover builds locally. Connect a repository or push an image to a registry, then set it as the source.
- Compose stacks, Coolify one-click services and Dokploy compose apps. They are multi-service and are reported as unsupported, deploy their compose file with the compose deploy command.
- Dokploy apps uploaded as an archive (
drop), and file mounts. Unsupported. - Health checks that are shell commands, pre and post deploy commands, extra ports and raw host port mappings, Coolify preview variables. Called out as needs attention where relevant.
- TLS certificates, DNS records, deploy history, logs and metrics. Certificates are issued here on first use. Re-point DNS only after the cutover check says
go. - Cron jobs are reported with the schedule and command so you can recreate them as scheduled tasks.
Rollback
The importer only ever adds resources here and never touches the source, so the source keeps running throughout. To undo an import:- Find what it created: the apply report lists every created app and database by name, and each app carries the label
import/source-id(visible inlevelrail-cli apps get <name> --json). - Delete those apps (
levelrail-cli apps delete <name>) and the databases the report listed. - Delete any projects the import created if they are empty.
go. If you already switched a domain, point its record back at the source (the check printed the old value as “replaces”).
Manual checklist after an import
- Read every “needs attention” and “not supported” row and its suggested step.
- Run
migrate db-copyfor every database and confirm each isverified. - Run the
migrate volumescommands and copy volume data. - Update each app’s connection settings to the managed database.
- Connect repositories and run builds for git-based apps.
- Confirm secrets landed (the Secrets card on each app) and re-enter any that were skipped.
- Lower DNS TTLs to 300 and wait out the old value.
- Run
migrate cutoverand switch only domains that arego. - Run
migrate cutover --verifyafter each switch. - Re-add health checks and cron jobs the report listed.
The older migrate commands
levelrail-cli migrate coolify|dokploy|caprover predates the importer. It reads the same sources but writes app.yaml files (or creates apps one by one with --apply) instead of talking to the server-side importer, and it does not handle databases, volumes as resources, idempotent re-runs or the network opt-in. Use import platform for new work.
See also
- Getting started: deploying your first app
- App spec reference: full app.yaml schema
- Backups and storage: dump and restore tools for engines the copy step does not cover
- Comparison: architectural differences between platforms
- Coolify alternative and Dokploy alternative: why people switch
Guided app import from Coolify
Settings, Import from another platform, “Move apps from Coolify” walks every application through plan, stage, verify, volumes and cutover. The CLI mirrors it:- Coolify is only read. Every request to it is a GET, and the token lives in memory for the session and in the request body only.
- Staged apps are created stopped and not routed. Variables and secrets are imported encrypted, domains are held back until you enable routing.
- Each app gets a verdict (ready, ready with notes, needs attention, unsupported) with the reason and next action. Docker Compose apps and one-click services are listed as unsupported with the manual path.
- Old database hostnames are only rewritten through a mapping table you confirm, with a masked before and after diff. Suggestions come from databases you already moved. When the target is a managed database, the staged app also gets a connection variable so it joins that database’s network.
- Verify builds or pulls the app, waits for its own health check and stops it again until cutover.
- Volume data is copied by you with a generated rsync command per volume, and you confirm it. Volume sizes are shown only when Coolify reports them.
- Rollback removes only apps carrying this session’s label. Volumes are kept on disk.
- The receipt is a JSON download with no secret values. The MCP tool
get_app_import_planis read-only.
read:sensitive ability, secret values come back empty and the app is flagged so you can set them by hand.
Guided cutover of one staged app
Once an app is verified, the Cutover step of the import wizard (andlevelrail-cli import cutover) moves its traffic in one reversible flow. It never writes to the source platform and never touches the source server: it only starts the staged app here and changes the domain’s DNS record or proxy route.
The readiness plan
The plan is computed on the server, and each item ispass, warn or block, with a fix when it is not a pass. A block stops the run.
Warnings do not stop a dry run. A switch needs
--accept-warnings (the checkbox in the dashboard) when any remain.
What a run does
- Dry run. Starts the app here, waits until it is healthy, then sends a request with the domain’s Host header through this node’s own ingress listener. It stops the app again and changes no record. Run it as often as you like.
- Switch. Needs the app name typed as confirmation. It repeats the dry run, then switches each domain by the first method that applies:
- DNS provider: with Cloudflare or Route 53 connected and a root credential, the record is replaced and the previous value is stored in the run before the write, so a rollback restores it exactly.
- Managed proxy: with proxy coexistence active, the route is written to the proxy and no DNS record changes.
- Manual: otherwise the run pauses and prints the exact record. Set it, then press “I changed it” (or
import cutover confirm-dns).
- Verify. The public name is checked with the domain doctor, polling until it passes or
APP_CUTOVER_VERIFY_TIMEOUT(5 minutes) passes. If it never passes, the run rolls itself back: DNS records are restored and the staged app is stopped.
import cutover rollback) at any point after the switch. It is safe to repeat after a partial failure. A run interrupted by a restart resumes within APP_CUTOVER_RESUME_MAX_AGE (30 minutes), or is rolled back if it is older.
A rollback restores the DNS record’s value and type, not a Cloudflare proxy (orange cloud) flag the old record carried, so check that setting after rolling back a proxied record.