Site Migrator
Site Migrator
by Newfold Labs · github.com/newfold-labs/wp-plugin-site-migrator · website
Install
The author publishes release zips, so WP-CLI can install straight from GitHub:
wp plugin install https://github.com/newfold-labs/wp-plugin-site-migrator/releases/download/0.1.1/nfd-site-migrator.zipReadme
Site Migrator
Move a WordPress site to another host without holding your breath.
Install it on both sites. The old one packs itself into a box. The new one opens the box — and shows you exactly what's inside before a single byte of your live site is touched.
Pre-release. Not on wp.org yet, but the zip is real: every release carries one that CI has installed into a clean WordPress and run before publishing. A full migration works today, from wp-admin and from WP-CLI. The plan lives in
docs/implementation-plan.md; the autopsy that started it lives indocs/code-analysis.md.
The one thing worth knowing
Most migration tools import over your site. If something breaks halfway, you own the rubble.
This one doesn't. The incoming database is built beside the live one, in tables prefixed
nfdimp_ that WordPress never looks at. Files land first. Every way the import can fail is
arranged to happen before the moment of truth. And the moment of truth is one statement:
RENAME TABLE wp_posts TO nfdold_wp_posts, nfdimp_wp_posts TO wp_posts, ... ;
MySQL runs that atomically. All of it, or none of it. Until it executes, your destination is
exactly as you left it and you can walk away. After it executes, your old site is sitting in
nfdold_ tables, one click from coming back.
There is no half-migrated state. That's the whole design.
What it does
Content only. Core is never packaged. The destination already has WordPress; shipping another copy is just postage.
It refuses to pack junk. .git, .svn, node_modules, core's upgrade/ folders — gone,
wherever they hide. One real site had a 129.8MB .git nine levels deep inside a vendored
dependency. Symlinks are never followed, because following one quietly packages somebody else's
files. Everything skipped is named in the manifest, so "where did it go" always has an answer.
It survives your host. Work happens in short, budgeted steps that checkpoint and return, so a 30-second execution limit is an inconvenience rather than a wall. Close the tab. Come back tomorrow. It picks up mid-file.
It moves site-to-site, if you let it. The destination fetches the package straight from the source over HTTP — no 2GB round trip through your laptop. Or download it yourself; both routes end in the same place.
Your accounts survive. The destination's users are kept and the source's merged in with their IDs intact, so migrated posts stay attributed to whoever wrote them. You stay signed in — even though the table holding your login was just replaced underneath you.
Two sites, one code, no accounts
Nobody signs up for anything. Each site mints a credential for the other:
| who makes it | what it stops | shape | |
|---|---|---|---|
| Pairing code | the destination | strangers targeting your site | 12 characters, read aloud, hashed slowly |
| Transfer key | the source | strangers reading your site | 48 hex characters, checked on every request |
Neither half of a migration can be started from outside. The transfer key expires on idleness, not on a clock — a fixed fifteen minutes would fail every 20GB move, which is the exact case it exists for — and it binds to the first address that uses it, so a key that leaks mid-transfer is already worthless.
Present a wrong key and you get a 404, not a 401. A 401 would cheerfully confirm that a WordPress site with this plugin lives at that address.
Installing
The plugin goes on both sites — the one you are leaving and the one you are moving to. It is the same plugin on each; which role a site plays is decided by what you click, not by what you install.
Grab nfd-site-migrator.zip from the releases, then on each site:
Plugins → Add New → Upload Plugin → Choose File → Install Now → Activate
or, if you have a shell:
wp plugin install nfd-site-migrator.zip --activate
Requirements: WordPress 5.8+, PHP 7.4+, and the zip extension. The plugin checks all three
and refuses to activate rather than failing partway through your first export. Multisite is not
supported and is blocked at preflight on both sides.
Building the zip yourself:
composer build:zip # -> dist/nfd-site-migrator.zip
composer verify:zip # installs it into a throwaway WordPress and checks it runs
Three ways to move a site
All three end in the same place: a package staged on the destination, previewed, then swapped in. Pick on one question — can the destination reach the source over the internet?
| when to use it | what it needs | |
|---|---|---|
| Direct transfer | almost always | the destination can reach the source's URL |
| Download and upload | source is behind a firewall, on a laptop, or on an intranet | a browser and patience |
| Put it there yourself | very large sites, or you already have SSH/FTP | shell or FTP access to the destination |
The middle one moves every byte twice — down to your machine and back up. The other two don't.
Step by step
Before you start
Install and activate the plugin on both sites. Site Migrator appears in each admin menu. You need an administrator account on both.
The destination's content is replaced. Its user accounts are kept.
Flow A — Direct transfer
The destination fetches the package straight from the source. Nothing goes through your computer.
On the destination — Site Migrator → Receive a site here
- Give the source this code. It shows a pairing code (
XXXX-XXXX-XXXX) and this site's address. Leave the tab open.
On the source — Site Migrator → Send this site somewhere else
- Paste the destination's address and pairing code. The source reads the destination's PHP, WordPress version, free space and database capabilities directly from it.
- Compatibility shows what it found. Anything that would break the move blocks here — a check that could not run counts as a failure, not a pass.
- Package. The site is archived in resumable steps. You can close the tab; reopening picks up where it stopped. Pause is safe.
- Deliver → Generate a transfer key. Copy the key and the address. The key is shown once.
On the destination — Bring in a package → Fetch it from the source
- Paste the address and the key. It starts pulling, checksumming each file as it lands. Close the tab, come back, open a second tab — the progress is measured from the files on disk, so all three agree.
- When it finishes it verifies every file, then See what it would do.
Both sites — the review
- Review shows everything before anything is written: the URL rewrite, every account and what
it will sign in as, which plugins and themes arrive, and any
wp-config.phpsettings the source had that this site does not. Nothing is written to yourwp-config.php— it prints them for you to copy. - Tick the box, Import it. Files land first, the database loads into staging tables the live site never reads, and everything that can fail happens before the swap.
- Finish. Check the front page, a few posts, your images, and signing in. Then Keep it, or Undo the import and the old site comes straight back.
Until step 10,
Keep ithas not run and your old site is intact innfdold_tables. After it, the old tables are dropped and the import cannot be undone.
Flow B — Download and upload
Identical up to step 4. Use this when the destination cannot reach the source.
- On Deliver, choose Download the package instead. Your browser asks for a folder and streams every file into it, subdirectories preserved — hand that folder straight to the destination's picker. If your browser doesn't support folder picking, files download individually instead.
- On the destination, Bring in a package → Choose the package folder, and select the folder you just downloaded. Uploads go in small pieces and resume where they stopped, so a dropped connection is not a lost upload.
- Continue from step 7 above.
Flow C — Put the package there yourself
For very large sites, this is the quickest route and the one least likely to time out.
- Package the source — in wp-admin, or
wp site-migrator export. - Copy the package directory to the destination by SSH,
rsyncor FTP. Anywhere works; insidewp-content/uploads/nfd-site-migrator/is tidiest. - On the destination, Bring in a package lists it under Already on this server. Click Use this, and continue from step 8 above.
Entirely from the command line
For sites where the browser is the wrong tool — a hundred thousand files, or tens of gigabytes.
# on the source
wp site-migrator export # package this site
wp site-migrator offer # prints an address and a transfer key
# on the destination
wp site-migrator pull <address> <key> # fetch it, checksumming as it lands
wp site-migrator import <dir> # stage, verify, swap
# then, once you have checked the site
wp site-migrator confirm # keep it, drop the old tables
wp site-migrator rollback # or put the old site back
wp site-migrator cancel abandons an import that has not yet swapped in — the live site is
untouched either way. Add --yes to skip a confirmation.
Mixing surfaces is fine. Package on the CLI, import in the browser; start a pull in the browser and finish it from a shell. Progress lives on disk, not in a session.
Looking before you leap
wp site-migrator preflight # can this site be migrated at all?
wp site-migrator preflight --against=<url> --code=<code> # ...and to that one?
wp site-migrator inspect <dir> # what is in this package?
wp site-migrator verify <dir> # ...and does it match its manifest?
inspect reads the manifest; verify re-reads every byte and checks it against that manifest,
which on a large package is minutes rather than milliseconds. Both are read-only.
Scripting it
Four commands report rather than act — preflight, inspect, verify and offer — and all four
take --format=table|json|csv|yaml. Data goes to stdout, everything else to stderr, so the
JSON is the only thing in the pipe:
wp site-migrator preflight --format=json | jq '.local.ok'
KEY=$(wp site-migrator offer --format=json | jq -r '.key')
Every JSON payload carries a schema integer. It is bumped when a field changes meaning or
disappears — not when one is added, since a consumer reading by name is unaffected by a field it
has never heard of.
Exit codes say what happened without anyone reading English:
| meaning | |
|---|---|
0 |
it worked |
1 |
it failed |
2 |
the sites are incompatible — the fix is on a server, not in the command |
3 |
stopped early with work outstanding; run the same command again |
4 |
the package is missing, unreadable, or does not match its manifest |
3 is the one worth building around. --budget=<seconds> bounds a single step; --max-time=<seconds>
bounds the whole command, leaving a checkpoint and exiting 3 if it needs longer. That makes a
migration something cron can drive:
until wp site-migrator export --max-time=50; do
[ $? -eq 3 ] || exit 1 # 3 means "not finished", anything else is real
done
Stopping is always safe. Every step checkpoints before it returns, and the swap is a single statement that has either run or not.
Nothing ever prompts when nobody can answer. With --format=json, or when stdin is not a
terminal, a missing --yes is an error rather than a question that would wait forever.
Before you trust it with a real site
Run wp site-migrator preflight on both. It refuses rather than half-works, and it checks things
that are easy to get wrong: whether the destination's WordPress and database schema are new enough,
whether its PHP satisfies what your plugins declare, whether zip is there, whether RENAME TABLE
works, whether there is room — and whether your package directory can be downloaded from the
web.
That last one matters more than it sounds. A package contains database.sql: every table, every
password hash, at a guessable path under uploads. The plugin writes .htaccess and web.config
to deny access, which covers Apache and IIS — nginx reads neither. So preflight does not take
its own word for it; it fetches the directory over HTTP and refuses to export if it comes back. If
you see that block, deny access to wp-content/uploads/nfd-site-migrator in your server config and
check again.
If something goes wrong
During packaging or transfer — nothing on either site has changed. Everything before the swap is re-runnable. Close the tab and come back.
During the import, before the swap — the destination is still serving its own content. Try again resumes; wp site-migrator cancel throws the staged copy away.
After the swap — Undo the import, or wp site-migrator rollback. The database goes back
exactly. Files go back only where it can be done honestly: plugins, themes, must-use plugins and
drop-ins the package added are removed, but anything it overwrote stays, because the
destination's own copy is already gone and deleting it would make an incomplete rollback into a
destructive one. uploads is never touched — a photo added after the import is indistinguishable
from one the package brought.
Once you press Keep it — the old tables are gone. That is the point of no return, and it is the only one.
Read the full README on GitHub →