# WordPress Site Import — Full Procedure
Bringing an existing WordPress site from any source host into kaburusvr. Covers cPanel shared hosting (Chemicloud, Namecheap, Guru) and any SSH-accessible server. Tested across ~15 site migrations May–June 2026.
—
## Source host reference
| Host | SSH | User | Key |
| —— | —– | —— | —– |
| Chemicloud | `57.129.144.46:1988` | `kaburuuk` | `~/.ssh/chemicloud` |
| Namecheap (taicbzyc) | `209.74.74.42:21098` | `kaburuuk` | `~/.ssh/id_ed25519` |
| Guru (rainbowvapes) | `85.92.72.178:22` | `rvwebcou` | `~/.ssh/rainbowvapes` |
| Other cPanel | check cPanel → SSH Access | varies | generate in cPanel |
Chemicloud exit: Sept 2026. All remaining Chemicloud sites still to migrate.
—
## Before you start — gather from source
SSH into source and collect:
```bash # WordPress DB credentials grep -E “DB_NAME|DB_USER|DB_PASSWORD|DB_HOST|table_prefix” ~/public_html/wp-config.php
# Site URL (what's in the DB) grep -E “siteurl|home” <(wp option get siteurl; wp option get home) 2>/dev/null # or from DB directly: mysql -u DB_USER -p“DB_PASS” DB_NAME -e “SELECT option_name,option_value FROM wp_options WHERE option_name IN ('siteurl','home');”
# Disk size of files + DB du -sh ~/public_html/ mysql -u DB_USER -p“DB_PASS” -e “SELECT table_schema, ROUND(SUM(data_length+index_length)/1024/1024,1) AS MB FROM information_schema.tables WHERE table_schema='DB_NAME' GROUP BY table_schema;”
# PHP version on source php -v | head -1
# Active plugins (useful to know before import) wp plugin list –status=active –path=~/public_html/ –allow-root 2>/dev/null | awk '{print $1}' ```
Note: table prefix — most sites use `wp_` but some use custom prefixes (e.g. `wp4w_`, `wpdx_`). Check `$table_prefix` in wp-config.php.
—
## Step 1 — DNS (Cloudflare)
Add the A record before creating the site in CyberPanel.
If migrating to the live domain (DNS cutover at end), add a staging subdomain now and point the live domain at the end. If importing to a staging subdomain permanently, add that record.
```bash CF_TOKEN=$(grep SAVED_CF_Token /root/.acme.sh/account.conf | cut -d“'” -f2) ZONE_ID=$(curl -s “https://api.cloudflare.com/client/v4/zones?name=<zone>” \
| python3 -c “import sys,json; d=json.load(sys.stdin); print(d['result'][0]['id'])”)
curl -s -X POST “https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records” \
```
Token gotcha: Must use user-scoped token (`cfut_` prefix). Account-scoped tokens (`cfat_`) return auth errors. Token is at `/root/.secrets/cloudflare` and also in `/root/.acme.sh/account.conf` as `SAVED_CF_Token`.
—
## Step 2 — Create site in CyberPanel
Via MCP tool or CyberPanel UI. Match the PHP version to source where possible (PHP 8.3 default).
``` Domain: <domain> Owner: admin Package: Default PHP: PHP 8.3 (or match source) SSL: true (tick it — vhost.conf gets cert paths written, even though cert issuance will fail) openBasedir: true ```
Note the Linux user CyberPanel assigns: ```bash stat -c '%U' /home/<domain> # e.g. testm1387, rfkab7804, kabur1825 ```
### Fix public_html permissions immediately
CyberPanel creates `public_html` as `0750`. This causes LiteSpeed to return 404/403 and silently not route requests to the vhost at all.
⚠️ tar extract resets permissions — if the source had 750, the archive preserves it and overwrites whatever you set before. Always chmod 755 AFTER the tar extract, not before.
```bash chmod 755 /home/<domain>/public_html ```
This has bitten us on every single import. Do it last, after extracting.
—
## Step 3 — Issue SSL
Do not use CyberPanel's Issue SSL button. HTTP-01 fails behind Cloudflare. Always use acme.sh DNS-01 via Cloudflare API.
```bash CF_Token=$(grep SAVED_CF_Token /root/.acme.sh/account.conf | cut -d“'” -f2)
CF_DNS_API_TOKEN=$CF_Token /root/.acme.sh/acme.sh \
mkdir -p /etc/letsencrypt/live/<domain> /root/.acme.sh/acme.sh –install-cert -d <domain> –ecc \
```
Verify: ```bash openssl x509 -issuer -enddate -noout -in /etc/letsencrypt/live/<domain>/fullchain.pem # issuer must NOT say (STAGING) ```
See ssl-issuance for full troubleshooting.
—
## Step 4 — Create database
Use CyberPanel MCP tool or directly:
```bash # Pick a sensible short name — CyberPanel will prefix with Linux username # e.g. for Linux user rfkab7804, DB becomes rfkab7804_sitename LINUX_USER=$(stat -c '%U' /home/<domain>) DBNAME=“${LINUX_USER}_wp” DBUSER=“${LINUX_USER}_usr” DBPASS=$(openssl rand -base64 16 | tr -dc 'a-zA-Z0-9' | head -c 20)
mysql -u root -e “CREATE DATABASE IF NOT EXISTS \`$DBNAME\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;” mysql -u root -e “CREATE USER IF NOT EXISTS '$DBUSER'@'localhost' IDENTIFIED BY '$DBPASS';” mysql -u root -e “GRANT ALL PRIVILEGES ON \`$DBNAME\`.* TO '$DBUSER'@'localhost';” mysql -u root -e “FLUSH PRIVILEGES;” ```
CyberPanel gotcha: If creating DB via CyberPanel UI/MCP, it may not actually grant privileges to the user — it creates user and DB separately but the GRANT sometimes doesn't apply. Always verify:
```bash mysql -u root -e “SHOW GRANTS FOR '$DBUSER'@'localhost';” # If missing GRANT: run the GRANT manually as above ```
—
## Step 5 — Transfer files from source
### Option A — Direct pull from source to Hetzner via SSH key
Fastest. Add Hetzner's public key to source first:
```bash # On kaburusvr — get our pubkey cat ~/.ssh/id_ed25519.pub
# Add to source (run from kaburusvr) ssh -p <port> -i ~/.ssh/<key> <user>@<sourceip> \
'echo "<PUBKEY>" >> ~/.ssh/authorized_keys'
```
Then archive on source and pull:
```bash # On source — archive (skip cache/log dirs to keep size down) nohup tar -czf /tmp/site_files.tar.gz \
> /tmp/tar.log 2>&1 &
# Monitor tail -f /tmp/tar.log
# Pull to Hetzner (run from kaburusvr) rsync -az -e “ssh -p <port> -o StrictHostKeyChecking=no” \
<user>@<sourceip>:/tmp/site_files.tar.gz \ /tmp/<domain>_files.tar.gz
```
### Option B — Large uploads (e.g. WooCommerce with thousands of product images)
For rf.kaburu.co (39GB raw, 1.5GB tarball), we cherry-picked uploads directories:
```bash # Archive only the uploads years we actually want — skip bad imports and huge dirs tar -czf /tmp/rv_files.tar.gz \
```
Always check what's eating space on source before blindly archiving: ```bash du -sh ~/public_html/wp-content/uploads/*/ du -sh ~/public_html/wp-content/plugins/*/ | sort -rh | head -10 ```
—
## Step 6 — Transfer and import database
```bash # Dump on source mysqldump -u DB_USER -p“DB_PASS” DB_NAME 2>/dev/null > /tmp/site_db.sql # For large DBs (>100MB) run in background: nohup mysqldump -u DB_USER -p“DB_PASS” DB_NAME > /tmp/site_db.sql 2>/tmp/dump.log &
# Pull to Hetzner rsync -az -e “ssh -p <port> -o StrictHostKeyChecking=no” \
<user>@<sourceip>:/tmp/site_db.sql \ /tmp/<domain>_db.sql
```
### DB collation fix — critical for MySQL 8 → MariaDB
If source is MySQL 8 (common on newer cPanel hosts), the dump will contain `utf8mb4_0900_ai_ci` collation and `ENGINE=MEMORY` on some tables. MariaDB doesn't support either. Fix before importing:
```bash sed -i \
/tmp/<domain>_db.sql ```
Then import: ```bash mysql -u root $DBNAME < /tmp/<domain>_db.sql ```
Verify row count roughly matches source: ```bash mysql -u root -e “SELECT COUNT(*) FROM $DBNAME.$(grep 'table_prefix' /home/<domain>/public_html/wp-config.php | cut -d”'“ -f4)posts;” ```
—
## Step 7 — Extract files
```bash SITEPATH=“/home/<domain>/public_html” LINUX_USER=$(stat -c '%U' /home/<domain>)
# Remove CyberPanel placeholder rm -f $SITEPATH/index.html
# Extract tar -xzf /tmp/<domain>_files.tar.gz -C $SITEPATH/
# Set ownership chown -R ${LINUX_USER}:${LINUX_USER} $SITEPATH/ find $SITEPATH -type d -exec chmod 755 {} \; find $SITEPATH -type f -exec chmod 644 {} \; chmod 600 $SITEPATH/wp-config.php ```
—
## Step 8 — Update wp-config.php
```bash SITEPATH=“/home/<domain>/public_html”
# Update DB credentials in wp-config.php sed -i “s/define( 'DB_NAME', '.*' );/define( 'DB_NAME', '$DBNAME' );/” $SITEPATH/wp-config.php sed -i “s/define( 'DB_USER', '.*' );/define( 'DB_USER', '$DBUSER' );/” $SITEPATH/wp-config.php sed -i “s/define( 'DB_PASSWORD', '.*' );/define( 'DB_PASSWORD', '$DBPASS' );/” $SITEPATH/wp-config.php # Fix DB host (source may have used socket path or port suffix) sed -i “s/define( 'DB_HOST', '.*' );/define( 'DB_HOST', 'localhost' );/” $SITEPATH/wp-config.php ```
DB_HOST gotcha: Source hosts often use `localhost:/var/run/mysqld/mysqld.sock` or `localhost:3306`. Hetzner MariaDB needs plain `localhost` (TCP on 127.0.0.1). Using a socket path in the host field causes “Could not connect to database” — this hit Matomo and would hit WordPress too.
—
## Step 9 — Update siteurl and home in database
If you're importing to a staging subdomain first (e.g. `rf.kaburu.co` before going live as `rainbowvapes.co.uk`), update the URLs in the DB:
```bash PHP_BIN=“/usr/local/lsws/lsphp83/bin/php” WP=“$PHP_BIN /usr/local/bin/wp –allow-root”
$WP option update siteurl “https:<new-domain>” –path=$SITEPATH $WP option update home “https:<new-domain>” –path=$SITEPATH ```
If doing a full search-replace of the old domain across all content and meta: ```bash $WP search-replace “https:<old-domain>” “https:<new-domain>” \
# Always run –dry-run first: $WP search-replace “https:<old-domain>” “https:<new-domain>” \
```
—
## Step 10 — PHP settings
### vhost.conf (LiteSpeed-level — takes precedence)
CyberPanel creates a minimal phpIniOverride. Expand it:
``` # /usr/local/lsws/conf/vhosts/<domain>/vhost.conf phpIniOverride { php_admin_value open_basedir “/tmp:$VH_ROOT” php_value memory_limit 256M php_value upload_max_filesize 256M php_value post_max_size 256M php_value max_execution_time 300 php_value max_input_time 300 php_value max_input_vars 10000 } ```
For Divi sites, bump memory to 512M — Divi builder will OOM at 256M on large pages.
Reload after: ```bash systemctl reload lsws ```
### .user.ini (WordPress-level)
```bash cat > $SITEPATH/.user.ini « 'PHPINI' ; WordPress Memory Limits wp_memory_limit = 256M wp_max_memory_limit = 512M
; PHP Core Settings memory_limit = 256M upload_max_filesize = 256M post_max_size = 256M max_execution_time = 300 max_input_time = 300 max_input_vars = 10000 default_socket_timeout = 300 PHPINI
chown ${LINUX_USER}:${LINUX_USER} $SITEPATH/.user.ini chmod 640 $SITEPATH/.user.ini # 640 not 644 — prevents public exposure via browser ```
Security note: `.user.ini` must be `640`. LiteSpeed can still read it (runs as the site Linux user). `644` makes it browser-accessible — Wordfence flags this as Critical. This has been missed on multiple imports. Do not skip it.
—
## Step 11 — Handle problem plugins
### Wordfence WAF
Wordfence writes `wordfence-waf.php` and an `auto_prepend_file` entry to `.user.ini`. On a fresh import, disable the WAF until the site is confirmed working:
```bash # Rename WAF file to disable it mv $SITEPATH/wordfence-waf.php $SITEPATH/wordfence-waf.php.disabled 2>/dev/null
# Remove auto_prepend_file from .user.ini if present sed -i '/auto_prepend_file/d' $SITEPATH/.user.ini # Also remove from .htaccess if present sed -i '/wordfence-waf/d' $SITEPATH/.htaccess 2>/dev/null ```
Re-enable after the site is live and Wordfence has run its initial scan.
### ⚠️ Wordfence Extended Protection on OLS — mandatory post-migration step
OLS does not honour `auto_prepend_file` from `.htaccess` or `.user.ini` — the methods WF's optimizer writes on Apache/cPanel. Without this, the WAF bootstrap never loads early, WAF rule update cron never fires, and the WF admin UI shows “undefined NaN” for next update time.
After the site is live and WF is confirmed working, add `php_admin_value auto_prepend_file` to the OLS vhost conf:
```bash DOMAIN=“example.co.uk” CONF=“/usr/local/lsws/conf/vhosts/${DOMAIN}/vhost.conf” WAF_PATH=“/home/${DOMAIN}/public_html/wordfence-waf.php”
python3 - « PYEOF path = '${CONF}' waf = '${WAF_PATH}' with open(path, 'r') as f:
content = f.read()
if 'auto_prepend_file' in content:
print("ALREADY SET")
else:
old = 'php_value max_input_time 300\n}'
new = 'php_value max_input_time 300\nphp_admin_value auto_prepend_file ' + waf + '\n}'
content = content.replace(old, new, 1)
with open(path, 'w') as f:
f.write(content)
print("DONE")
PYEOF
killall lsphp 2>/dev/null /usr/local/lsws/bin/lswsctrl restart ```
Verify with curl (not WP-CLI — CLI context will always show No): ```bash cat > /home/${DOMAIN}/public_html/wf-check.php « 'PHPEOF' <?php echo (defined('WFWAF_AUTO_PREPEND') && WFWAF_AUTO_PREPEND) ? 'OK' : 'FAIL'; unlink(FILE); PHPEOF curl -s https://${DOMAIN}/wf-check.php # Expected: OK ```
Then hit the site a few times to let the bootstrap fire stale cron events and reschedule rule updates: ```bash for i in 1 2 3; do curl -s https://${DOMAIN}/ > /dev/null; done ```
See cyberpanel for full background and troubleshooting.
### LiteSpeed Cache
Import from a non-LiteSpeed host will have LSCache inactive or misconfigured. After import, deactivate and reactivate it to force a fresh config:
```bash $WP plugin deactivate litespeed-cache –path=$SITEPATH $WP plugin activate litespeed-cache –path=$SITEPATH ```
### Updraft / backup plugins
Never import the `updraft` directory — it can be 10–50GB of old backup zips. Exclude it during archive step (already in the tar exclude list above).
### WP Rocket / caching plugins that aren't LiteSpeed
Deactivate on import — they'll conflict with LiteSpeed Cache: ```bash $WP plugin deactivate wp-rocket wp-fastest-cache w3-total-cache \
```
—
## Step 12 — Fluent SMTP / email
All Kaburu sites send via Brevo SMTP. Configure after import:
```bash # Set via DB (Fluent SMTP stores config in wp_options) $WP option update fluentmail-settings \
'{"mappings":[],"connections":{"conn_1":{"provider":"smtp","title":"Brevo","host":"smtp-relay.brevo.com","port":"587","encryption":"tls","auth":"yes","username":"[email protected]","password":"<brevo_smtp_password>","sender_name":"<Site Name>","sender_email":"no-reply@<domain>"}}}' \
--format=json --path=$SITEPATH
```
Or install Fluent SMTP fresh if it wasn't on the source: ```bash $WP plugin install fluent-smtp –activate –path=$SITEPATH ```
Then configure via WP admin → Fluent SMTP → Settings.
Brevo credentials: `smtp-relay.brevo.com:587`, login `[email protected]` Password at `/root/.secrets/brevo` or in existing site wp-config.
Brevo sender domain: Must be verified in Brevo dashboard for the domain you're sending from. Subdomains of already-verified domains (e.g. `*.kaburu.co.uk`) are auto-authenticated — no extra setup needed.
—
## Step 13 — Verify the site
```bash # HTTP check curl -skI https://<domain> | grep -E “HTTP|Location” | head -3
# WP admin accessible curl -sk https://<domain>/wp-login.php | grep -c “loginform”
# Check for PHP fatal errors tail -50 /home/<domain>/logs/<domain>.error_log | grep -i “fatal\|error” | grep -v “Notice\|Warning”
# DB connection working $WP option get siteurl –path=$SITEPATH
# Ownership correct ls -la $SITEPATH | head -5 stat –format=“%a %U:%G” $SITEPATH/wp-config.php ```
—
## Step 14 — DNS cutover (going live)
Only do this when the site is fully verified on staging.
```bash CF_TOKEN=$(grep SAVED_CF_Token /root/.acme.sh/account.conf | cut -d“'” -f2) ZONE_ID=$(curl -s “https://api.cloudflare.com/client/v4/zones?name=<zone>” \
| python3 -c “import sys,json; d=json.load(sys.stdin); print(d['result'][0]['id'])”)
# Get current A record ID RECORD_ID=$(curl -s “https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?type=A&name=<domain>” \
| python3 -c “import sys,json; d=json.load(sys.stdin); print(d['result'][0]['id'])”)
# Update to Hetzner IP curl -s -X PATCH “https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$RECORD_ID” \
```
Issue the live domain SSL before DNS cutover (DNS-01 doesn't need the domain to resolve to this server — it uses the CF API):
```bash CF_DNS_API_TOKEN=$CF_TOKEN /root/.acme.sh/acme.sh \
```
Then do the DNS update, then install the cert.
—
## Step 15 — Post-import checklist
``` [ ] Site loads at https:<domain> — HTTP 200, no SSL warnings [ ] wp-admin accessible [ ] All pages load (check homepage, a post, a product if WooCommerce) [ ] No PHP fatal errors in error_log [ ] Ownership: all files <linuxuser>:<linuxuser> [ ] wp-config.php: 600 permissions [ ] .user.ini: 640 permissions, NOT publicly accessible (curl https:<domain>/.user.ini → 403) [ ] LiteSpeed Cache active [ ] MainWP Child active — connect to MainWP dashboard on kaburusvr.uk [ ] Wordfence active — run initial scan [ ] Fluent SMTP configured — send test email [ ] Brevo: sender domain verified (check Brevo dashboard) [ ] Search-replace done if staging URL → live URL [ ] Cloudflare SSL mode: Full (Strict) for the zone [ ] Site added to Matomo (stats.kaburu.co) [ ] Old host site suspended/deleted once confirmed working ```
—
## Known gotchas — complete list
| Problem | Cause | Fix |
| ——— | ——- | —– |
| 403 on all pages | `public_html` is 0750 | `chmod 755 /home/<domain>/public_html` |
| 403 on all pages (after chmod) | PHP handler wrong | Check vhost.conf — `lsphpPHP83` → `lsphp83` |
| DB connection fails | `DB_HOST` has socket path or port | Set to plain `localhost` |
| DB import fails on MariaDB | MySQL 8 collation/engine | `sed` fix for `utf8mb4_0900_ai_ci` and `ENGINE=MEMORY` |
| CyberPanel DB user has no grants | CyberPanel UI bug | Run `GRANT` manually |
| White screen / 500 after import | Wordfence WAF file missing | Disable `auto_prepend_file`, rename `wordfence-waf.php` |
| `.user.ini` publicly accessible | chmod 644 (default) | `chmod 640 .user.ini` |
| Site shows old domain after import | siteurl/home not updated | `wp option update` or `wp search-replace` |
| WP-CLI `mysqli_init` fatal | System PHP has no mysqli | Use `/usr/local/lsws/lsphp83/bin/php` |
| Cloudflare API 401 | Wrong token type | Use `cfut_` (user-scoped), not `cfat_` (account-scoped) |
| SSL cert shows STAGING | acme.sh defaulted to staging CA | `–server letsencrypt` flag required |
| Divi OOM errors | memory_limit too low | Set 512M in vhost.conf phpIniOverride for Divi sites |
| Plugin deleted wrong file | Filename collision during cleanup | Always use full path, not just filename |
| Wordfence IP source wrong | Default not set for Cloudflare | Set `wfIPSrcOption` → `HTTP_CF_CONNECTING_IP` |
| Upload too slow / times out | Large uploads dir on source | Cherry-pick uploads years, exclude bad imports |
| Staging acme dir has key+CSR but no cert | Context limit hit mid-issuance | `rm -rf /root/.acme.sh/<domain>_ecc/` and reissue |