A production-grade Bash tool to migrate an addon domain — including its subdomain children, MySQL database, DNS records, SSL certificates, and mail routing — between two cPanel accounts on the SAME server.
cPanel & WHM does not provide a native API or WHM interface for moving an addon domain between two accounts on the same server. The official options cover only:
- Transfer Tool — account-to-account, but only between DIFFERENT servers.
- Convert Addon Domain to Account — extracts an addon into a NEW standalone cPanel account (cannot merge into an existing one).
- JetBackup Restore — restores to the SAME origin account; no cross-account support (open feature request since 2022).
For hosting providers consolidating clients, merging resellers into a single account, or simply reorganizing domains between accounts they already run, there is no supported path. Every guide online ends with "do it manually, step by step, domain by domain" — which is where bugs and data loss creep in.
This script composes the cPanel primitives that DO exist (whmapi1 create_subdomain, whmapi1 create_parked_domain_for_user, whmapi1 delete_domain, uapi Mysql *, rsync, mysqldump) into a single idempotent flow, with defensive checks for every failure mode we encountered in production.
For a single addon domain (or a batch via --batch-file), the script:
- Preflight — validates source/destination accounts, ownership, disk space, PHP version per vhost, presence of subdomain children (which otherwise block the delete), MySQL DB reachability, email account detection, domain registration status (RDAP / DoH).
- DNS snapshot — dumps the full DNS zone as JSON (not just the raw
.dbfile). This is what drives record replay after cPanel regenerates the zone during vhost creation. - File sync —
rsync -aHAX --delete-after --backup --backup-dir=...with the destination user's ownership, preserving.user.ini,.htaccess, WordPress drop-ins, ACLs and xattrs. - Database migration —
mysqldumpof the source DB, creates the destination DB / user (with a prefix discovered viauapi Mysql get_restrictions— does not assume${user:0:8}_), imports, patcheswp-config.phpand verifies the login works, auto-resetting the password viauapi Mysql set_passwordif cPanel stored a different hash from what we generated. - Subdomain children — if the parent addon has children (e.g.
lp.example.comsitting underexample.com), each child is migrated first, because cPanel rejectsdelete_domainon the parent while any child still exists. - Vhost creation on destination —
create_subdomain+php_set_vhost_versions(preserves the source PHP version). - Vhost removal on source —
delete_domainfor the addon and its control subdomain. This must run BEFOREcreate_parked_domain_for_useron the destination, because cPanel bans the same domain being in two accounts simultaneously. - Addon registration on destination —
create_parked_domain_for_user, parsingmetadata.resultfrom the JSON response rather than trusting the CLI exit code (which is always0). - DNS record replay — every MX / CNAME / TXT / SRV / A / AAAA record from the pre-migration snapshot is re-applied via
whmapi1 addzonerecord. cPanel wipes customizations during vhost creation; without this step you lose Microsoft 365 / Google Workspace MX, DKIM, SPF, SRV Teams/Lync, custom subdomain CNAMEs, etc.+characters in TXT values are URL-encoded as%2Bsowhmapi1does not decode them to spaces (silent SPF corruption). - DNS completeness check + auto-retry — after replay, the script diffs the backup JSON against the live zone and retries any missing record up to 3 times before failing the migration.
- Exim mail routing — if the restored MX points outside the apex (M365, Google, Mailgun, CarrierZone, etc.), the domain is moved from
/etc/localdomainsto/etc/remotedomainsand Exim is rebuilt/restarted so the local MTA does not intercept mail that should go to the external provider. - SMTP banner probe — opens TCP/25 to the external MX to confirm it actually responds.
- httpd/LSWS rebuild + cache flush + LSWS reload.
- AutoSSL reissue with active wait loop — triggers
start_autossl_check_for_one_userand polls every 10s (up to 120s by default) for the cert to be reissued for the real apex, reloading LiteSpeed the moment a new cert is detected. - Smoke test via GET + body analysis — uses a browser User-Agent (sites with defensive
.htaccessrules return 403 on HEAD requests, which is a false alarm) and scans the body for signatures like "Database Error", "erro crítico", "Fatal error" or empty bodies under 80 bytes. - Final DNS reconciliation — one last replay + verify pass after AutoSSL / httpd rebuild, in case those actions wiped records again.
Each step is idempotent — a re-run after a partial failure resumes cleanly rather than duplicating work or corrupting state.
- cPanel & WHM (tested on 132.x; should work on 106+ where the WHM API 1 calls used here exist).
- Root access on the server.
- Tools:
whmapi1,uapi,rsync,mysqldump,mariadb(ormysql),jq,curl,awk,grep,find,openssl,sed,dig. Optional:wp-cli. - A DNS backend reachable locally (PowerDNS with
launch=bind, or standalone BIND). - Web server LiteSpeed or Apache (the script uses
/scripts/rebuildhttpdconf+/usr/local/lsws/bin/lswsctrl reload; replace that function if you run stock Apache). - Exim as the MTA (cPanel default).
# 1. Clone into a directory of your choice
sudo git clone https://github.com/DevSkillsIT/cpanel-addon-domain-migration-between-accounts.git /opt/cpanel-migrate-addon
sudo chmod 750 /opt/cpanel-migrate-addon/cpanel-migrate-addon.sh
# 2. (Optional) create a stable install prefix where logs and state will live
sudo mkdir -p /usr/local/cpanel-migrate-addon/{bin,log,state,reports}
sudo ln -s /opt/cpanel-migrate-addon/cpanel-migrate-addon.sh \
/usr/local/cpanel-migrate-addon/bin/cpanel-migrate-addon.sh
# 3. (Optional) expose via PATH
sudo ln -s /usr/local/cpanel-migrate-addon/bin/cpanel-migrate-addon.sh /usr/local/bin/cpanel-migrate-addon| Variable | Default | Purpose |
|---|---|---|
INSTALL_PREFIX |
/usr/local/cpanel-migrate-addon |
Where logs, state and DNS backups are written |
AUTOSSL_TIMEOUT_SEC |
120 |
Maximum time the script waits for AutoSSL to reissue |
AUTOSSL_POLL_SEC |
10 |
Polling interval while waiting for the new cert |
NO_COLOR |
(unset) | Set to disable ANSI color output |
Set them before the invocation if you need to override:
INSTALL_PREFIX=/srv/cpmig AUTOSSL_TIMEOUT_SEC=240 \
/usr/local/bin/cpanel-migrate-addon --domain=... --to=... --apply --yes# Source account is auto-detected from /etc/userdomains
cpanel-migrate-addon --domain=example.com --to=destacct --dry-run
cpanel-migrate-addon --domain=example.com --to=destacct --apply --yes
cpanel-migrate-addon --domain=example.com --to=destacct --verifycat > domains.txt <<EOF
# one domain per line, # for comments, blank lines skipped
site1.com
site2.net
# optional per-line source override:
site3.org,olduser,newuser
EOF
cpanel-migrate-addon --batch-file=domains.txt --to=destacct --apply --yesA consolidated Markdown report is written to $INSTALL_PREFIX/reports/batch-<timestamp>.md.
| Mode | Effect |
|---|---|
--dry-run |
Plans the migration, prints every command, shows real rsync --dry-run stats, touches nothing. |
--apply |
Executes. Writes logs to $INSTALL_PREFIX/log/<domain>-apply-<ts>.log. |
--verify |
Post-migration deep health check — DB login via defaults-file (bypasses the MariaDB MYSQL_PWD CLI bug), WP siteurl via the auto-detected $table_prefix, body smoke (GET + WordPress error detection), certificate SAN coverage, DNS record diff against the pre-migration JSON snapshot, Exim routing sanity. Read-only. |
| Flag | Behavior |
|---|---|
--from=<user> |
Override auto-detected source account |
--no-subs |
Fail at preflight if the parent has subdomain children (default is to auto-migrate them first) |
--strict-dns |
Abort if the domain is expired / NXDOMAIN (default is WARN) |
--skip-db |
Do not migrate MySQL even if wp-config.php exists |
--skip-ssl |
Do not trigger AutoSSL (cron will catch up within 4h) |
--force-wp / --force-static |
Override site-type autodetection |
--yes |
Non-interactive (no confirmation prompt) |
--quiet |
Reduce log chatter |
Run --help for the full synopsis.
▶ Preflight checks
✓ domain example.com confirmed as addon of olduser
✓ source docroot: /home/olduser/public_html/_domains/example.com
✓ site type: wordpress (wp-config.php found)
✓ source DB: olduser_wp @ localhost
✓ dest DB planned: newuser_wp
✓ DB host reachable: localhost
✓ source PHP version: ea-php83
✓ detected 1 subdomain children under example.com
✓ will migrate them automatically before the parent
✓ domain status: active (registered, expires 2028-03-17T21:15:41Z)
▶ Backup DNS zone for example.com (full JSON snapshot)
✓ zone JSON: /usr/local/cpanel-migrate-addon/state/dns-backup/example.com.db.20260424-094512.json (34 records)
▶ Destination directory
▶ rsync data (source -> destination)
▶ Database migration
✓ DB login confirmed with generated password
▶ Migrate subdomain child: blog.example.com
▶ Create control subdomain on destination
▶ Set PHP version on destination vhost (ea-php83)
▶ Remove from source (olduser)
▶ Park domain on destination vhost
▶ Restore DNS custom records (post-migration)
✓ records replayed — added=24 skipped=8 failed=0
▶ Verify DNS restore completeness
✓ zone complete — all 1-pass records match backup
▶ Exim mail routing
✓ MX points to 'example-com.mail.protection.outlook.com' (external) — domain must be REMOTE
✓ Exim reconfigured: example.com marked as remote
▶ SMTP banner probe on external MX
✓ MX example-com.mail.protection.outlook.com responded: 220 VI1PE...EURP250CA0077.outlook.office365.com
▶ AutoSSL reissue + wait for cert
✓ (40s) cert emitted for example.com (CN=example.com)
✓ LSWS reloaded to serve new cert
▶ Smoke test (GET + body analysis)
✓ http://example.com/ -> HTTP 200 (15623B, ok)
✓ https://example.com/ -> HTTP 200 (15623B, ok)
✅ apply completed for example.com
On failure during the destructive section (steps 7 onward), the script prints the exact whmapi1 commands required to undo each change, plus the paths of preserved artifacts:
❌ ABORT (code=17): remove_source_addon failed
Preserved artifacts:
snapshot: /usr/local/cpanel-migrate-addon/state/example.com.snapshot.json
log: /usr/local/cpanel-migrate-addon/log/example.com-apply-...log
DB dump: /usr/local/cpanel-migrate-addon/state/example.com.dump.sql
# To roll back:
whmapi1 delete_domain domain=example.com
whmapi1 delete_domain domain=example.com.destacct.example.com
whmapi1 create_subdomain domain=example.com.olduser.com document_root=...
whmapi1 create_parked_domain_for_user username=olduser domain=example.com ...
uapi --user=destacct Mysql delete_database name=destuser_wp
uapi --user=destacct Mysql delete_user name=destuser_wp
The source account's DB is never dropped by the script. The rsync uses --backup-dir so overwritten destination files are preserved.
Please read this list carefully before running the script. Anything outside this list must be handled separately — typically through JetBackup / cPanel full-backup, manual copy, or a dedicated tool.
- ❌ Mailboxes under
/home/<user>/mail/<domain>/(IMAP accounts, Dovecot index/cache, sent/trash/archive folders) - ❌ Mail forwarders, autoresponders, filters (Exim routing rules tied to the account)
- ❌ BoxTrapper / SpamAssassin user rules
- ❌ cPanel-generated DKIM private keys — the DNS TXT record is restored from the backup, but the signing key lives in
/etc/exim.conf.local//var/cpanel/domain_keys/. If mail is routed externally (M365/Google/etc.) this is irrelevant; if mail was previously local, mail signed by the old key will fail DKIM verification at recipients that have cached the key.
The preflight step detects local mailboxes and prints a loud warning before continuing. If the domain has mailboxes and you want to keep them, migrate them BEFORE running this script with tools like imapsync, doveadm backup, JetBackup's mailbox restore, or a full cPanel backup + restore.
- ❌ FTP accounts (additional FTP users beyond the main cPanel account)
- ❌ SSH keys
- ❌ Cron jobs (
/var/spool/cron/<user>— neither rsync nor the script touches this path) - ❌ Password-protected directories configured through cPanel GUI (
.htpasswdfiles inside the docroot ARE copied, but the cPanel-side association is not) - ❌ IP Blocker rules, Hotlink Protection, Leech Protection
- ❌ Custom Apache handlers / MIME types (GUI-managed ones;
.htaccess-based rules migrate with rsync) - ❌ Softaculous installations metadata — files are copied, but the Softaculous registry entries (backup schedules, auto-updates) remain on the source account
- ❌ cPanel account packages, feature lists, CloudLinux LVE limits, reseller ACLs
- ❌ WebDisk accounts
- ❌ Backup job configurations (JetBackup, cPanel backup, custom)
- ❌ Manually-installed SSL certificates — AutoSSL is triggered after migration, so Let's Encrypt / Sectigo free certs are reissued. Paid / manually-installed certificates need to be re-installed on the destination.
- ❌ Wildcard subdomains (
*.domain.com) — partially handled but not fully validated in production - ❌ Multi-level subdomain children (
sub.sub.domain.com) — only direct children of the parent addon are detected and migrated - ❌ cPanel Redirects configured through the Redirects GUI — these are stored in the user's
userdatafile, not the docroot - ❌ DNSSEC keys (
/var/cpanel/dnssec-keys/) — regenerated on the destination by cPanel; external DS records at the registrar will need updating - ❌ Custom name server delegation changes at the registrar
- Same-server only. For cross-server migration, use the cPanel Transfer Tool.
- LiteSpeed / Apache assumption. The reload call assumes cPanel's LSWS binary path. For OpenLiteSpeed or stock Apache you'll need to adapt
cache_reload()/rebuild_httpd_conf(). - Brazilian TLD (.br) RDAP is queried via
rdap.registro.br; other TLDs use Cloudflare DoH. Less common TLDs may need specific adjustments. - Browser caching / HSTS during the 1–3 second cutover window may cause some users to temporarily see the cPanel SORRY page. Recommend them to use an incognito window if they hit that.
Skills IT — Technology Solutions specializes in hosting infrastructure and has deep expertise in cPanel/WHM administration, large-scale WordPress migrations, mail deliverability (SPF/DKIM/DMARC), DNS architecture, and hosting automation at scale. Our team also builds Artificial Intelligence and Model Context Protocol (MCP) integrations for hosting providers, system integrators, and agencies running multi-tenant environments.
Our services:
- ✅ cPanel/WHM consulting, hardening and migration
- ✅ Mail deliverability audits (SPF, DKIM, DMARC, BIMI)
- ✅ Custom automation scripts for hosting operations
- ✅ DNS architecture, DNSSEC rollout, secondary/GSLB design
- ✅ WordPress / WooCommerce performance tuning on LiteSpeed
- ✅ Security hardening (CSF, Imunify360, ModSecurity tuning)
- ✅ Custom MCP development for hosting infrastructure
- ✅ AI integration with provisioning, billing and support systems
- ✅ Incident response and post-mortem consulting
- ✅ Specialized training for hosting operations teams
📞 WhatsApp/Phone: +55 63 3224-4925 — Brazil 🇧🇷 🌐 Website: skillsit.com.br 📧 Email: contato@skillsit.com.br
"Transforming infrastructure into intelligence"
This script is provided "as is" and "as available" without warranty of any kind, express or implied, including but not limited to warranties of merchantability, fitness for a particular purpose, non-infringement, or data preservation. Use at your own risk.
Skills IT — Technology Solutions and the script's contributors are not liable for any direct, indirect, incidental, consequential, special, exemplary or punitive damages arising out of or in connection with the use of this software, including but not limited to: data loss, website downtime, email delivery failures, DNS outages, SSL certificate issues, MySQL corruption, broken WordPress installations, or any service interruption affecting your customers or your business.
Before running --apply in production:
- Take a full snapshot at your hypervisor / cloud provider level (Vultr snapshot, AWS AMI, DO snapshot, Proxmox backup, etc.).
- Run a cPanel
/scripts/pkgacct <user>backup of both source and destination accounts if possible. - Run
--dry-runfirst and review the entire output. - Test the full cycle on a non-production domain before production use.
- Perform the migration during a maintenance window and notify affected users.
By using this script you acknowledge that you have read and understood the limitations in the "What this script does NOT do" section above, and that you are solely responsible for validating the migration outcome, rolling back if necessary, and communicating with affected users.
Pull requests welcome, especially:
- Support for servers running OpenLiteSpeed or stock Apache without LSWS.
- RDAP handling for more ccTLDs (
.ar,.uy,.co,.pt, etc.). - Integration with JetBackup / cPanel backup system for a pre-migration snapshot when neither
mysqldumpnorrsyncare desirable (e.g., very large sites where block-level snapshots are preferable). - Hooks system so operators can plug in site-specific pre/post actions (search-replace for URL changes, flushing custom caches, etc.).
Please open an issue describing the environment and reproduction steps before submitting a large change.
See TROUBLESHOOTING.md for the historical list of real-world failure modes encountered during development and the fix each one triggered in the script.
MIT — see LICENSE.