WP Manifestindependent plugin directory
manifest / updates / wp-migrator-app

WP-CLI migrate_app

Imports an uploaded WordPress package (Duplicator or plain) into this installation, rewrites URLs, and merges themes/plugins/uploads.

★ 0stars

Install

Download source zip No release zip yet

This is the repository archive. It installs, but the folder name will carry the branch suffix and updates will not flow.

Install straight from GitHub with WP-CLI:

wp plugin install https://github.com/dgaitan/wp-migrator-app/archive/refs/heads/main.zip
No built-in updater. New versions won't appear under Dashboard → Updates. Install new releases yourself, or let Git Updater track the repository.

wp migrate_app

Move a WordPress site into an existing, working WordPress installation — rewriting URLs safely and merging the source's themes, plugins and uploads into the site that is already there.

Everything reduces to two steps.

# 1. Get the site into a folder.
wp migrate_app_pull ./old-site --from=@old      # ...from a server you can SSH to
                                                 # ...or just unzip a Duplicator package

# 2. Install that folder.
wp migrate_app ./old-site                        # ...into WordPress on this machine
wp migrate_app_remote ./old-site --to=@new       # ...into WordPress on another server

A folder is the only thing the two steps share. Step one knows nothing about installing; step two knows nothing about where the folder came from. That is what makes the combinations free: server-to-server migration is step one against A, then step two against B. There is no third command for it, and there does not need to be one.

Step 1 — get the site into a folder

wp migrate_app_pull <folder> --from=<origin> You have SSH to the source server. It brings the database and wp-content home and writes the manifest for you.
Unzip a Duplicator package You have no access to the source at all — only the export. Extract it and run --generate-config. See Preparing the package. On a machine with no WordPress of its own, that is wp migrate_app_remote <folder> --generate-config.
Unzip a Fiction Drafts export Same, for a site running that plugin. Its manifest.json is read directly. See Fiction Drafts exports.

All three produce the same thing: a folder with a migration.yaml, a .sql dump, and wp-content/.

Step 2 — install that folder

migrate_app migrate_app_remote
You run it on the destination server on your laptop
Needs WordPress where you type it yes no
Gets the folder there you do, by SFTP or File Manager it does, over rsync
Folder ends up in the webroot, publicly reachable until you delete it outside the webroot, never web-reachable
Backup ends up on the destination server on your machine, before the import starts
Needs SSH no yes, key-based

If you can SSH to the destination, prefer migrate_app_remote — fewer manual steps, and it never leaves a copy of your database sitting under a live webroot.

Which combination is yours?

Your situation The two commands
Duplicator export, destination you can SSH to unzip, then migrate_app_remote ./pkg --to=@new
Duplicator export, you are already on the destination unzip into the webroot, then migrate_app ./pkg
Server to server migrate_app_pull ./site --from=@old, then migrate_app_remote ./site --to=@new
Production down to your laptop migrate_app_pull ./site --from=@prod, then migrate_app ./site

migrate_app_pull needs SSH to the source. If you only have access to the destination — the case this tool was originally built for — you cannot pull. Use a Duplicator export as step one instead. Everything from step two onward is identical either way.


Contents

Start here — read this and nothing else on your first run

Step 1 — getting the site into a folder

Step 2 — installing the folder

Reference

When it is done, or when it goes wrong

Other


Two scenarios, start to finish

Almost everything in this README is detail on one of these two. Find the one that matches where you are sitting, follow it top to bottom, and ignore the other.

A — the destination is this machine B — the destination is another server
Where you type the commands on the destination itself on your own machine
Is WordPress installed where you type? yes — that is the site being migrated into no — and it does not need to be
Command you use wp migrate_app wp migrate_app_remote
How the package gets there you put it there (SFTP, File Manager, git) the command uploads it over rsync
Do you need SSH? no yes, key-based
Where the safety backup lands on the destination on your machine, before anything is touched

If you can SSH to the destination, prefer B even when A is possible. Fewer manual steps, the backup comes home before the risky part, and the package never sits under a live webroot.


Before either scenario: install the tool

Pick whichever fits the machine you will be typing on. For A that is the destination server; for B it is your own machine.

# Best default — makes `wp migrate_app` available everywhere on that machine.
wp package install /path/to/wp-cli-migrate-app
# No install — point at the bootstrap each time. Handy on a host you just uploaded to.
wp --require=/path/to/wp-cli-migrate-app/migrate-app.php migrate_app ./my-site

Confirm it is there before going further:

wp help migrate_app          # scenario A
wp help migrate_app_remote   # scenario B

Full details, including the plugin-folder route: Install.


Scenario A — importing into WordPress on this machine

You are on the destination server. WordPress is installed and working there, and you want the old site merged into it.

1. Put the package folder in the WordPress root, next to wp-admin/. However you like — SFTP, File Manager, unzip. You should end up with:

/var/www/html/            ← the working WordPress
├── wp-admin/
├── wp-content/
├── wp-config.php
└── my-site/              ← the package you just put there
    ├── database.sql
    └── wp-content/

2. Write the config:

cd /var/www/html
wp migrate_app ./my-site --generate-config

This reads the package and writes my-site/migration.yaml. Because WordPress is loaded around it, it fills target_url with this site's own URL.

3. Read the file it wrote. Genuinely read it — it is seven lines and it decides what happens:

cat my-site/migration.yaml

4. Rehearse. Nothing is written:

wp migrate_app ./my-site --dry-run

5. Do it:

wp migrate_app ./my-site --yes

A backup is exported first, to the WordPress root. 6. Then delete the package folder — it is sitting in your webroot where the internet can read it:

rm -rf my-site   # or run step 5 with --cleanup

Scenario B — importing into WordPress on another server

You are on your own machine. It does not need WordPress, PHP-with-a-database, or anything but WP-CLI, rsync and an SSH key. The destination has the WordPress.

1. Tell WP-CLI about the server, once. This is the step that answers "how does it know the server?" — you name it here, in WP-CLI's own alias file, ~/.wp-cli/config.yml:

@new:
  ssh: deploy@example.com:22/home/deploy/public_html
  key: ~/.ssh/id_rsa

Read that as <user>@<host>:<port>/absolute/path/to/the/wordpress/root — substitute your own. The port is optional, and so is key:, which you can leave out if the key is already in your ssh-agent or ~/.ssh/config.

Check the connection works before going any further, with plain ssh:

ssh deploy@example.com 'ls /home/deploy/public_html/wp-config.php'

If that prints the path, you are good. If it asks for a password, stop and fix your keys — this tool will not accept one.

Do not test with wp --ssh=@new. It looks equivalent and is not: WP-CLI's own --ssh resolves aliases differently and may fail on an alias this tool reads perfectly well. --to=@new is what matters here, and step 5's --dry-run is the real test of it.

2. Have the package folder anywhere on your machine. Any folder, not a webroot:

~/Sites/my-site/
├── database.sql
└── wp-content/

3. Write the config — no server involved yet:

wp migrate_app_remote ./my-site --generate-config

Note there is no --to here, and that is not an oversight. This step only reads the package on your disk. The file it writes describes the source site — its old URL, its table prefix, which folders to bring — and contains nothing at all about the destination. So there is no server for it to know about yet.

target_url is left empty on purpose, and that is the piece worth understanding: at import time the destination fills in its own URL, read live from the far end. That is why the same package can be installed anywhere without editing it, and why a hardcoded value would be a bug waiting to happen.

4. Read the file it wrote:

cat my-site/migration.yaml

5. Now bring in the server. Rehearse first — this connects, checks the far end and reports, but transfers nothing and changes nothing:

wp migrate_app_remote ./my-site --to=@new --dry-run

Read the summary it prints. It resolves @new to a real host, path and home URL — check they are the ones you meant.

6. Do it:

wp migrate_app_remote ./my-site --to=@new

It uploads to a staging directory outside the webroot, takes a database backup and pulls it home to your machine before anything destructive, then runs the import on the far end.

7. Remove the staging copy once you are happy — it holds a dump of the old database:

wp migrate_app_remote ./my-site --to=@new --cleanup-only

The one thing that trips people up

migration.yaml describes the package, never the destination.

generate-config  ──reads──>  the folder on your disk        (no server, no --to)
                 ──writes─>  migration.yaml

the migration    ──reads──>  migration.yaml  +  --to=@new   (server named here)

That separation is why step 3 needs no --to and step 6 does. It is also why one package folder can be installed on staging, then on production, with no edits in between.


Requirements

WordPress An already installed and working single-site install at the destination. Not multisite.
WP-CLI 2.5+ (developed and tested against 2.12).
PHP 7.4+ (linted and unit-tested under real 7.4).
Database user Needs DROP, CREATE, INSERT on the destination schema.
Disk About 3× the dump size free — the dump, a prefix-rewritten copy, and the backup.
Optional rsync for faster merges. Without it a PHP walk is used, with identical results.
Remote mode See Running it against a remote server. Your machine needs WP-CLI and SSH; it does not need WordPress.

No Composer install is needed at runtime. WP-CLI is the only dependency.


Install

As a WP-CLI package — the command is then available everywhere:

wp package install /path/to/wp-cli-migrate-app

Without installing — point at the bootstrap directly. Usually the easiest option on a host where you just uploaded the folder over SFTP:

wp --require=/path/to/wp-cli-migrate-app/migrate-app.php migrate_app my_site_to_migrated

To make that permanent, add it to the site's wp-cli.yml in the WordPress root:

require:
  - wp-cli-migrate-app/migrate-app.php

As a plugin — drop the folder into wp-content/plugins/. It registers nothing outside WP-CLI, so it stays inert for web requests and does not need activating.

Confirm it loaded:

wp help migrate_app

Preparing the package

The command expects an already-extracted folder sitting in the destination's webroot, beside wp-admin and wp-config.php:

public_html/
├── wp-admin/
├── wp-content/          ← the LIVE site's content; will be merged into, never replaced
├── wp-includes/
├── wp-config.php        ← never touched
├── ...
└── my_site_to_migrated/     ← the uploaded package
    ├── migration.yaml       ← written for you by --generate-config
    ├── dup-installer/
    │   ├── dup-archive__abc1234.txt    (Duplicator's manifest — JSON)
    │   └── dup-database__abc1234.sql   (the dump)
    └── wp-content/
        ├── themes/
        ├── plugins/
        └── uploads/

To get there from a Duplicator archive:

mkdir -p ~/public_html/my_site_to_migrated
cd ~/public_html/my_site_to_migrated
unzip /path/to/20260821_yoursite_abc1234_archive.zip     # or: unzip *.daf

If you only have SFTP, unzip locally and upload the extracted folder.

The folder name is yours to choose — it is the argument you pass to the command.

Do not run Duplicator's own installer.php. This command replaces it. Delete installer-backup.php and installer.php from the webroot if they are there; they are publicly reachable and are a remote-code-execution surface.


Pulling a site from a server

wp migrate_app_pull is step one when you can SSH to the source. It brings the database and wp-content home into a folder, writes the migration.yaml, and stops. It installs nothing.

wp migrate_app_pull ./old-site --from=@old --dry-run   # measure first
wp migrate_app_pull ./old-site --from=@old

--from takes the same target grammar as --to: a WP-CLI alias, or [<user>@]<host>[:<port>][<path>]. See Naming the target.

What you get

old-site/
├── migration.yaml
├── old.example.com-20260828-011455.sql
└── wp-content/
    ├── themes/your-theme/
    ├── plugins/
    └── uploads/

That folder is a complete, installable package. Nothing needs editing before step two.

The origin is read-only

The pull runs wp db export and reads a handful of options. It never imports, never rewrites a URL, never writes a row. The one file it creates on the far end is a temporary dump in /tmp, removed on the way out — including when the run fails, because a forgotten dump is a full copy of the database sitting on a server nobody is looking at.

What it deliberately does not bring

Left behind Why
wp-config.php Database credentials and salts. It is not excluded by pattern — the pull addresses wp-content subdirectories by name, so it cannot be reached at all.
updraft/, ai1wm-backups/, *.wpress, backwpup-* Backup-plugin archives. Routinely tens of gigabytes, and they usually contain another full copy of the database.
cache/, et-cache/, debug.log, node_modules/ Regenerable. Costs transfer, buys nothing.

Add your own with --exclude=pattern1,pattern2.

target_url is written empty, on purpose

A pull cannot know where the package will eventually land, so it declines to guess. Both install commands fall back to the destination's own home_url() when the value is blank — which in step two is exactly right. See Which migration.yaml is used.

Files first, database last

A live site changes while you are copying it. Of the two possible inconsistencies, an uploaded file that no row references yet is harmless; a row pointing at a file that was never copied is a broken page. So the files go first and the dump is taken last, making the database the newest thing in the package.

For a site taking orders, this is still a window. Put the origin in maintenance mode yourself if the gap matters.

Proving the dump arrived whole

A truncated dump is worse than no dump: it imports without complaint and loses data silently. The head of a cut-off dump looks perfect, so the head is not what gets checked.

Route Proof
Default The dump is written to a temp file on the origin, rsynced home (resumable), and the byte counts on both ends must match exactly.
--stream-db No file, no size to compare — so the dump's own trailing Dump completed marker must be present. Cannot resume.

Use --stream-db only when the origin has nowhere to write. It is marked experimental for a reason: it has been unit-probed but never run against a live origin, and the marker check is a smoke test where the byte comparison is a proof.

The capture window is recorded, not hidden

The manifest carries both timestamps, and the run prints them:

Capture window:
    files      2026-08-28 01:22:14 UTC
    database   2026-08-28 01:24:02 UTC
    Anything written to the origin after the database timestamp — orders, comments,
    uploads — is not in this package. The gap widens for as long as you wait to install.

That last line is the one that matters. A package pulled on Monday and installed on Friday is missing five days of the origin's writes. Nothing warns you at install time, so check the timestamps.

The folder is production data

It holds a full copy of the origin's database — users, hashed passwords, whatever a plugin parked in the options table — and the whole uploads tree. So:

  • the folder is created 0700 and the dump 0600, because on a shared machine the default umask hands all of it to every other account;
  • .gitignore covers pulled packages, not just *.sql;
  • delete it once the migration is done and verified. Nothing deletes it for you.

wp-config.php is never pulled. Not as an exclusion you could switch off — the pull addresses wp-content subdirectories by name, so the file is unreachable by construction.

Pull flags

Flag Effect
--from=<target> Required. Alias or connection string for the origin.
--remote-path=<path> WordPress root on the origin, overriding the target's path.
--identity=<file> SSH private key. Defaults to your agent / ~/.ssh/config.
--proxyjump=<spec> Passed to ssh as -J.
--wp-binary=<command> How to run WP-CLI on the origin when plain wp is not on its PATH.
--skip-uploads Leave the media library behind.
--skip-files Database only.
--skip-db Files only.
--stream-db Experimental. Export down the pipe instead of via a temp file. Weaker integrity check, no resume, not yet run against a live origin.
--exclude=<patterns> Extra comma-separated rsync excludes.
--dry-run Measure and report. Transfers nothing, creates nothing — not even the folder.
--yes Skip the confirmation.
--force Write into a folder that already holds a package.

What the run shows you

Origin:      ssh:deploy@old.example.com/home/deploy/public_html
Into:        /Users/you/old-site

Checking the origin...

About to pull:
    host      old.example.com
    path      /home/deploy/public_html
    home URL  https://old.example.com
    prefix    wp_
    theme     mytheme (child of storefront)
    themes    24.1 MB
    plugins   118.4 MB
    uploads   2.1 GB
    ------------------------------
    transfer  2.2 GB  (plus the database dump)
    into      /Users/you/old-site
    free here 407.8 GB

The origin is read-only in this operation. Nothing is imported and no row is written there.

Pull this site? [y/n]

Fiction Drafts exports

Fiction Drafts is an export-only backup plugin — its README says plainly that it does not restore, migrate or rewrite URLs, and never will. This tool only imports. They are the two halves of the same job, so an unzipped Fiction Drafts export is a first-class package here.

unzip fiction-drafts-backup-1.zip -d ./old-site
wp migrate_app ./old-site --generate-config
wp migrate_app ./old-site --dry-run

Migrating to a remote server from a machine with no WordPress on it? Generate the config with the remote command instead — same derivation, no bootstrap:

wp migrate_app_remote ./old-site --generate-config
wp migrate_app_remote ./old-site --to=@prod --dry-run

What gets read

An extracted export has manifest.json and database.sql at its root, with everything else relative to the WordPress root:

old-site/
├── manifest.json
├── database.sql
└── wp-content/…

--generate-config reads that manifest the same way it reads Duplicator's, and fills in:

From the manifest Into migration.yaml
home_url origin_url (the dump is still preferred — it carries the scheme)
table_prefix table_prefix
multisite refuses the migration outright when true
wp_version, php_version, mysql_version reported, and warned about when this server is older
includes_wp_config warned about — see below
profile_areas warned about when the export is partial

The active theme is deliberately not taken from the manifest. Fiction Drafts records the stylesheet only, whereas scanning the dump recovers both the stylesheet and its template — so a child theme keeps its parent.

Three things worth knowing

A full profile is not a whole WordPress. Fiction Drafts backs up what you asked for. Migrating a database_only or files_no_media export into a live site is legitimate, but it is a partial merge, and preflight now says so rather than letting you find out afterwards.

If the export includes wp-config.php, the folder holds the origin's credentials. That is a per-job choice in Fiction Drafts, off by default, and the manifest records it. The file is never merged into your site — migration.yaml addresses wp-content paths only — but it is sitting in the folder. Preflight warns; delete the folder when you are done.

Version skew is reported, not enforced. Fiction Drafts requires PHP 8.1+ and WordPress 6.4+, so a site running it is likely newer than an old destination. If the source ran a newer PHP than this server, preflight warns — migrated plugin code written for 8.1 can fatal on 7.4, which surfaces as permalinks not flushing.

Pulling from a site that runs Fiction Drafts

Its archives live in wp-content/fiction-drafts-<32 hex>, a sibling of themes, plugins and uploads — which migrate_app_pull never walks, so they are excluded by construction. If you have relocated storage with FICTION_DRAFTS_STORAGE_DIR to somewhere inside uploads, the fiction-drafts-* exclude pattern catches it there too. Either way you will not accidentally drag a pile of full-site archives across the wire.


Usage — the three-step flow

Step two, once you have a folder from either route above.

Running this from your own machine instead? Everything below still happens — it just happens over SSH. Skip to Running it against a remote server.

1. Let it write the config

wp migrate_app my_site_to_migrated --generate-config

It reads Duplicator's manifest and the dump header, and writes a migration.yaml filled in with the origin URL, the source table prefix, the database path, and both the active theme and its parent if the source runs a child theme.

Read the file it wrote. Fix anything it guessed wrong. In particular check target_url, which defaults to this site's current home_url().

This form needs a working WordPress around it — migrate_app is registered after_wp_load, so on a machine that has none, WP-CLI never dispatches it and you get a bootstrap error. Use wp migrate_app_remote my_site_to_migrated --generate-config there; it derives the same file from the package alone and leaves target_url empty for the destination to fill.

2. Dry run

wp migrate_app my_site_to_migrated --dry-run

Every check runs and every action is reported. Nothing is written — no backup, no dropped tables, no copied files. Read the step table before continuing.

3. Migrate

wp migrate_app my_site_to_migrated

It shows you a summary, warns you what it is about to replace, and asks for confirmation. Add --yes to skip the prompt in a script.


A complete worked run

$ wp migrate_app my_site_to_migrated --generate-config

Source folder: /home/user/public_html/my_site_to_migrated

# migration.yaml — generated by `wp migrate_app --generate-config`
# Read every line before running the migration. Paths are relative to this folder.
# Source: Old Site (Duplicator 1.5.16.1, WP 7.1, PHP 8.1.34)
# table_prefix is the SOURCE prefix; this site's own prefix is left untouched.

origin_url: "https://old-site.com"
target_url: "https://new-site.test"
theme_path:
  - wp-content/themes/recap
  - wp-content/themes/recap-child
plugin_path: wp-content/plugins
uploads_path: wp-content/uploads
database: dup-installer/dup-database__abc1234-20260821.sql
table_prefix: wp_

Detected theme: recap
Detected theme: recap-child
Success: Wrote .../migration.yaml — review it, then run: wp migrate_app my_site_to_migrated --dry-run
$ wp migrate_app my_site_to_migrated --yes

Source folder: /home/user/public_html/my_site_to_migrated
Config:        /home/user/public_html/my_site_to_migrated/migration.yaml (yaml backend: spyc)

Preflight
  ✓ database reachable (wptest, prefix `dst_`)
  ✓ wp-content writable
  ✓ dump found: dup-database__abc1234-20260821.sql (2.4 MB)
  ✓ disk space: 251.4 GB free
  ✓ collation utf8mb4_unicode_520_ci supported
  ✓ prefix rewrite queued: `wp_` -> `dst_`
  ✓ configured source paths exist

setting         value
source site     Old Site
origin_url      https://old-site.com
target_url      https://new-site.test
database        dup-database__abc1234-20260821.sql
source prefix   wp_
target prefix   dst_
themes          wp-content/themes/recap, wp-content/themes/recap-child
plugins         wp-content/plugins
uploads         wp-content/uploads

Warning: This REPLACES the contents of database `wptest` (tables prefixed `dst_`) ...

Backing up the current database...
Success: Backup written to /home/user/public_html/migrate-app-backup-20260827-205923.sql

Dropping 12 existing `dst_` tables...
Rewriting table prefix `wp_` -> `dst_`...
Importing database...
Success: Imported 31 tables.

Rewriting URLs...
Success: Made 50 replacements.

Merging files...
  + theme: recap (163 files)
  + theme: recap-child (3 files)
  + plugin: contact-form-7 (143 files)
  + uploads (825 files)

step                            result   detail
backup                          ok       .../migrate-app-backup-20260827-205923.sql (87.6 KB)
drop tables                     ok       12 dropped
prefix rewrite                  ok       `wp_` -> `dst_`
import                          ok       31 tables (mysql < dump)
assert tables                   ok       31/31
assert admin                    ok       1 administrator(s)
search-replace (canonical)      ok       https://old-site.com  ->  https://new-site.test
search-replace (json-escaped)   ok       https:\/\/old-site.com  ->  https:\/\/new-site.test
search-replace (protocol-rel)   ok       //old-site.com  ->  //new-site.test
siteurl/home                    ok       https://new-site.test
theme: recap                    ok       163 files -> .../wp-content/themes/recap (rsync)
uploads                         ok       825 files -> .../wp-content/uploads (rsync)
flush                           ok       rewrite rules + object cache
verify home                     ok       https://new-site.test

Warning: This site's user table now comes from the source. Log in with a SOURCE account:
admin@old-site.com
Warning: The source folder is still in your webroot and is publicly reachable ...
Rollback if needed: wp db import /home/user/public_html/migrate-app-backup-20260827-205923.sql
              or: mysql -hlocalhost -uwpuser -p wptest < /home/user/.../migrate-app-backup-...sql
Success: Migration complete. Visit https://new-site.test

Running it against a remote server

Everything above assumes you are sitting on the destination server. You do not have to be.

wp migrate_app_remote runs on your own machine. It checks the far end can do the job, uploads the package to a directory outside the remote's webroot, brings a verified database backup home before anything destructive happens, and then runs the same wp migrate_app over there. The migration itself is byte-identical to a local run — same sequencer, same prefix reconciliation, same serialization-safe replacement.

It is also the safer way to do this. Uploading by hand puts dup-installer/dup-database__*.sql inside your webroot, where anyone who guesses the folder name can download your whole database over HTTP. Remote mode stages it in $HOME/.migrate-app, where the web server cannot serve it at all.

Setup, once

# 1. Make the command available on your machine.
wp package install /path/to/wp-cli-migrate-app

# 2. Make sure SSH works on its own. Fix it here, not mid-migration.
ssh deploy@example.com          # accept the host key if asked
ssh-copy-id deploy@example.com  # only if it asked for a password

Then name the destination once, in ~/.wp-cli/config.yml:

@prod:
  ssh: deploy@example.com:22/home/deploy/public_html
  key: ~/.ssh/id_ed25519

That is a standard WP-CLI alias, so wp @prod plugin list and friends start working too.

Then, every migration

cd ~/packages   # wherever your extracted package folder is

# 1. Look before you leap. Transfers nothing, changes nothing.
wp migrate_app_remote ./my_site_to_migrated --to=@prod --dry-run

# 2. Upload. Safe to interrupt and repeat — rsync resumes where it stopped.
wp migrate_app_remote ./my_site_to_migrated --to=@prod --push-only

# 3. Migrate what you uploaded.
wp migrate_app_remote ./my_site_to_migrated --to=@prod --skip-push

# 4. Get the source database dump off the server.
wp migrate_app_remote ./my_site_to_migrated --to=@prod --cleanup-only

Steps 2 and 3 collapse into one command if you drop both flags. Split them for anything large: a multi-gigabyte uploads directory is hours of transfer, and a dropped upload should not also mean re-running the decision to migrate.

Step 4 is not optional housekeeping. Until you run it, a full copy of the source database is sitting on the server.

Which migration.yaml is used

The one in your local package folder. It is not read on your machine — it is uploaded with everything else, and the remote reads the copy that lands in staging.

Three consequences worth knowing:

  • It must exist locally before anything starts. The command refuses immediately if ./my_site_to_migrated/migration.yaml is missing, rather than uploading gigabytes and then discovering there is nothing to act on.

  • --skip-push uses the copy already on the server. Edit your local file after pushing and the next --skip-push run will not see the change. Re-push, or edit the staged copy directly at $HOME/.migrate-app/package/<folder>/migration.yaml.

  • target_url must be the remote site's URL, or empty. Every URL in the database gets rewritten to whatever it says. Leave the value blank and the migration uses the destination's own home_url(), which in remote mode is almost always what you want — blank is a better default than a guess. A stale value copied from another run is the dangerous case, so the confirmation now prints the planned rewrite and warns when target_url does not match the site it is about to change:

        URLs      https://old-site.com  ->  https://WRONG-SITE.example
    
    Warning: target_url in migration.yaml is https://WRONG-SITE.example, but this site's home URL is
    https://new-site.com. Every URL in the database will be rewritten to WRONG-SITE.example, not to
    new-site.com. Fix target_url, or delete the value entirely and it will use this site's own home URL.

--config=<path> overrides which file is read, but it is resolved on the remote — an absolute remote path, or one relative to the remote WordPress root. It is not resolved against your machine or against the staged package.

migrate_app_remote has its own --generate-config, and on an operator's machine it is the one you want:

wp migrate_app_remote ./my_site_to_migrated --generate-config

It reads the package and nothing else — no local WordPress, no network, and --to is not required. That matters because the local command cannot do this job here. migrate_app is registered after_wp_load, so on a machine with no WordPress installed WP-CLI never dispatches it; you get a bootstrap error rather than a config, and no combination of flags gets you past it. The old advice was to borrow an unrelated install with --path=/path/to/any/wordpress, which works and reads like an apology.

target_url is written empty, which is what remote mode wants anyway — the destination's own home_url() is used at import time, so the file cannot go stale by naming a host it later stops being installed on. That is also why the command does not SSH anywhere to fill it in: an accurate value today is a liability the day the package is installed somewhere else.

It refuses to overwrite an existing migration.yaml unless you pass --force, and --dry-run prints the file it would write without creating it.

You can still write the file by hand from migration.example.yaml.

What the run shows you

A dry run, and the same summary a real run opens with:

Local package: /Users/you/packages/my_site_to_migrated
Remote target: ssh:deploy@example.com:22/home/deploy/public_html

Checking the remote...
+--------------+----+-------------------------------------+
| step         | ok | detail                              |
+--------------+----+-------------------------------------+
| wordpress    | ok | /home/deploy/public_html            |
| wp-cli       | ok | WP-CLI 2.12.0                       |
| php          | ok | 8.1.34                              |
| open_basedir | ok | unrestricted                        |
| transfer     | ok | rsync (resumable)                   |
| disk         | ok | 41.2 GB free, 185.2 MB to send      |
+--------------+----+-------------------------------------+

This will overwrite the database and wp-content of:
    host      deploy@example.com
    path      /home/deploy/public_html
    home URL  https://new-site.com
    database  84.1 MB
    from      /Users/you/packages/my_site_to_migrated (185.2 MB)

The prompt names the resolved host, path and home URL rather than the alias, because a mistyped alias is exactly what it is there to catch.


Everything from here down is reference — read it when something needs it.

What you need

On your machine On the remote
WP-CLI 2.5+ and PHP — no WordPress required WordPress, installed and working
rsync (optional but strongly recommended) WP-CLI on PATH, or a phar you point at
SSH access via key or ssh-agent PHP 7.4+, rsync if you want resumable uploads

SSH keys and ssh-agent only — there is no password field anywhere, by design. If you normally type a password, run ssh-copy-id user@host once first. Host key checking is never disabled: if the key is unknown, ssh user@host on its own once and accept it.

This command has to load before WordPress does, so dropping the tool into wp-content/plugins will not register it. wp package install (as in the setup above) or an explicit wp --require=/path/to/wp-cli-migrate-app/migrate-app.php migrate_app_remote ... are the two routes that work.

Naming the target

--to= takes either an alias, as in the setup above, or a connection string in the same grammar WP-CLI's own --ssh accepts:

[<scheme>:][<user>@]<host>[:<port>][<path>]
--to=@prod
--to=deploy@example.com:22/home/deploy/public_html
--to=deploy@example.com --remote-path=/home/deploy/public_html

An alias is the better habit: it keeps the key path and any ProxyJump in one place, and it is the only form that survives being typed at 2am. Group aliases (@all: [@one, @two]) are refused — fanning a destructive migration across several sites from one flag is not a feature.

The backup is taken before anything else, and brought to you

The remote database is dumped, pulled to your machine, and checked that it is a real SQL dump — all before the import starts. Only then does the migration run.

The ordering is the point. A backup that lives only on the machine you are about to overwrite is not an undo: if something goes wrong, that is precisely the machine you cannot reach. So it comes home first, and the remote run is told --skip-backup so the database is only dumped once.

You will see the remote run print "You ran with --skip-backup. There is no way back from here." Ignore it. That is this command telling the far end not to dump twice, and the summary says so.

The file lands in your working directory (or --backup-dir=<path>), named for the host and the time:

migrate-app-backup-example.com-20260827-231038.sql

To roll back:

wp --ssh=@prod db import /home/deploy/.migrate-app/migrate-app-backup-....sql
# or, if the remote copy is gone, push yours back up first

Remote flags

Everything from the Flags table still applies to the migration itself and is passed through. These are the ones remote mode adds:

flag effect
--to=<target> Required, except with --generate-config. Alias (@prod) or connection string.
--generate-config Write migration.yaml from the package and exit. No WordPress, no network.
--force With --generate-config, overwrite an existing migration.yaml.
--remote-path=<path> WordPress root on the remote, if not in the connection string.
--identity=<file> SSH key. Defaults to ssh-agent / ~/.ssh/config.
--proxyjump=<spec> Passed to ssh as -J.
--staging=<path> Where to upload. Defaults to $HOME/.migrate-app on the remote.
--wp-binary=<command> How to run WP-CLI on the remote when plain wp is not on its PATH.
--push-only Upload and stop. Nothing on the remote is modified.
--skip-push Already uploaded. Run the migration.
--cleanup-only Delete the staging directory and stop.
--force-unlock Take over a lock left by an interrupted run. Check nothing is still importing.
--backup-dir=<path> Where the pulled backup goes on your machine.

One environment variable, for hosts that need something ssh_config cannot express:

MIGRATE_APP_SSH_OPTS="-o PubkeyAcceptedKeyTypes=+ssh-rsa" wp migrate_app_remote ...

It is appended to every ssh and rsync invocation this command makes. It is not a way to turn StrictHostKeyChecking off, and it does not reach the migration itself — WP-CLI builds its own ssh flags for that leg and accepts no

This README is longer than the copy stored here. Read the rest on GitHub →