pexels itismowgli 16347

Migrating from Plesk to CloudPanel on a Single VPS With Zero Downtime

·

This is the hands-on companion to Free and Open-Source Plesk Alternatives, where I explain why I left Plesk (the licence cost) and why I picked CloudPanel. This post shows how to migrate Plesk to CloudPanel in place, exactly as I did it.

It’s written for advanced users. You should be comfortable with SSH, iptables, nginx, MariaDB dumps and reading a shell script before you run it. Every command below is one I actually ran, with site names, users, IPs and passwords replaced by placeholders (example.com, siteuser, <VPS_IP>). Read each step, understand it, adapt it. Don’t paste blindly on a production box.

The constraints

  • One VPS, Debian 12, 4 vCPU / 8 GB RAM / 80 GB disk, running Plesk Obsidian. No budget for a second server.
  • Production sites that must stay up: several WordPress sites (including this blog), a Laravel app with Horizon queue workers, and two static sites. Almost everything is behind Cloudflare; one site isn’t.
  • Mail and DNS live elsewhere (Google Workspace and Cloudflare), so no mail migration.

Why you can’t just install CloudPanel next to Plesk

I read the CloudPanel installer before running anything. It is written for a clean server:

  • it aborts if anything listens on 80, 443 or 3306, and Plesk owns all three;
  • it runs apt purge mysql*;
  • it adds its own apt repository pinned at priority 1000, so its nginx and PHP packages replace Plesk’s;
  • the CloudPanel admin UI wants port 8443, which is Plesk’s admin port.

Installing it on the host while Plesk is running would take production down. The solution is to not install it on the host yet.

The plan

  1. Stage: run CloudPanel inside an LXC container on the same VPS. It gets its own 80/443/3306/8443 on a private bridge, so nothing clashes. Migrate and test every site there.
  2. Cut over: point the host’s ports 80/443 at the container with an iptables DNAT rule. That’s instant and reversible, and Plesk is still installed as a fallback.
  3. Remove Plesk while the container serves live traffic.
  4. Install CloudPanel natively on the host, mirror the sites from the container, verify, then remove the DNAT so the host serves directly.
  5. Harden, clean up, delete the container.

LXC isn’t a VM: it shares the host kernel, so performance is near-native. That makes it a perfectly good place to serve production traffic for the few hours that step needs.

Step 0: Recon and backups

Inventory what Plesk is doing for each site before changing anything. The per-site custom config is the part people forget:

# Every domain and its databases, via Plesk's own DB wrapper (no credentials needed)
plesk db -Nse "select db.name, d.name from psa.data_bases db join psa.domains d on d.id=db.dom_id"

# Custom Apache and nginx directives per domain
for d in example.com; do
  for f in vhost.conf vhost_ssl.conf vhost_nginx.conf; do
    p=/var/www/vhosts/system/$d/conf/$f; [ -s $p ] && { echo "== $d/$f"; cat $p; }
  done
done
# ...plus every .htaccess, crontabs, supervisor programs and deploy hooks

Dump every database with plesk db dump, which uses Plesk’s admin credentials internally, and copy the dumps off the server:

mkdir -p /root/migration-backup/dbs && chmod 700 /root/migration-backup
for db in db_one db_two; do
  plesk db dump "$db" | gzip > /root/migration-backup/dbs/$db.sql.gz
done

I also exported Plesk’s fail2ban bans, so they could be re-imported later as permanent bans.

Step 1: The LXC host

Install only what’s needed. The default recommends pull in libpam-cgfs, which hooks every login session; you don’t want that on a production box:

apt-get install -y --no-install-recommends lxc lxcfs uidmap dnsmasq-base
# Unprivileged container: give root a subordinate ID range
echo 'root:1000000:65536' >> /etc/subuid
echo 'root:1000000:65536' >> /etc/subgid
cat > /etc/lxc/cloudpanel-create.conf <<'EOF'
lxc.include = /etc/lxc/default.conf
lxc.idmap = u 0 1000000 65536
lxc.idmap = g 0 1000000 65536
EOF
lxc-create -n cloudpanel -f /etc/lxc/cloudpanel-create.conf -t download -- -d debian -r bookworm -a amd64

Cap the container so staging can never starve the live sites, and make it autostart:

cat >> /var/lib/lxc/cloudpanel/config <<'EOF'
lxc.start.auto = 1
lxc.cgroup2.memory.max = 3G
lxc.cgroup2.cpu.weight = 50
EOF
lxc-start -n cloudpanel

Inside the container I set a static address, 10.0.3.10/24, in /etc/systemd/network/eth0.network.

Firewall note: Plesk’s firewall uses DROP policies on INPUT, FORWARD and OUTPUT. lxc-net inserts its ACCEPT and MASQUERADE rules at the top of the chains, so the container got network access without me touching Plesk’s rules. Check with iptables -S rather than assuming.

Step 2: CloudPanel inside the container

Two installer gotchas cost me a rebuild:

  1. The package’s post-install script needs /etc/pam.d/sshd, so install openssh-server first on minimal images.
  2. MariaDB’s repository is signed with its 2019 key, which the installer doesn’t import. When that fails, the installer silently falls back to Debian’s MariaDB 10.11 even though you asked for 11.8.
apt-get install -y curl lsof sudo ca-certificates openssh-server gnupg
curl -fsSL https://supplychain.mariadb.com/mariadb-keyring-2019.gpg \
  -o /etc/apt/trusted.gpg.d/mariadb-keyring-2019.gpg
curl -sSL https://installer.cloudpanel.io/ce/v2/install.sh -o install.sh
sha256sum install.sh   # compare with the checksum on CloudPanel's install page
DB_ENGINE=MARIADB_11.8 SWAP=false bash install.sh

The default engine is MySQL 8.4, so set DB_ENGINE explicitly. MariaDB 11.8 is LTS and imports 10.11 dumps cleanly.

Create the admin user straight away, over an SSH tunnel. CloudPanel only registers its database server when the first admin account is created, so clpctl db:add silently does nothing until you’ve done it:

ssh -N -L 8443:10.0.3.10:8443 user@<VPS_IP>   # then open https://localhost:8443

Step 3: Migrating a WordPress site

CloudPanel’s CLI does most of the work, but database and database-user names must match ^[a-z][-a-z0-9]+$. No underscores, so most Plesk names need renaming. Generate passwords on the server and keep them in a root-only file; never echo them:

clpctl site:add:php --domainName=www.example.com --phpVersion=8.4 \
  --vhostTemplate='WordPress' --siteUser=siteuser --siteUserPassword="$SITE_PW"
clpctl db:add --domainName=www.example.com --databaseName=exampledb \
  --databaseUserName=exampledbuser --databaseUserPassword="$DB_PW"

plesk db dump output contains CREATE DATABASE and USE statements. Import it as-is and your data lands in a stray database under the old name. Strip them first:

plesk db dump old_db_name \
  | sed -E '/^CREATE DATABASE .*`old_db_name`/d; /^USE `old_db_name`;/d' \
  | gzip | lxc-attach -n cloudpanel -- sh -c 'cat > /root/import/exampledb.sql.gz'
lxc-attach -n cloudpanel -- clpctl db:import --databaseName=exampledb --file=/root/import/exampledb.sql.gz

Copy the files with a tar pipe through lxc-attach, at idle I/O priority so live sites don’t feel it:

SRC=/var/www/vhosts/example.com/httpdocs
DST=/home/siteuser/htdocs/www.example.com
ionice -c3 nice -n19 tar -C $SRC --exclude=./wp-content/cache -cf - . \
  | lxc-attach -n cloudpanel -- tar -C $DST --no-same-owner -xf -
lxc-attach -n cloudpanel -- chown -R siteuser:siteuser $DST

Then fix the config. Two things to watch: some wp-config.php files lack the standard “stop editing” anchor, so give wp config set one explicitly; and Wordfence’s .user.ini hard-codes the old Plesk path:

wp config set DB_NAME exampledb;  wp config set DB_USER exampledbuser
wp config set DB_PASSWORD "$DB_PW"; wp config set DB_HOST 127.0.0.1
sed -i "s|/var/www/vhosts/example.com/httpdocs|$DST|" .user.ini

Reuse Plesk’s certificates for testing, so HSTS doesn’t get in the way. Plesk stores the key and certificate in one file; split them without ever printing the key:

P=/usr/local/psa/var/certificates
CERT=$(plesk db -Nse "select cert_file from psa.certificates where name='Lets Encrypt example.com'")
openssl pkey -in $P/$CERT | lxc-attach -n cloudpanel -- sh -c 'umask 077; cat > /tmp/key.pem'
openssl x509 -in $P/$CERT | lxc-attach -n cloudpanel -- sh -c 'cat > /tmp/cert.pem'

Step 4: Translating Apache and .htaccess to nginx

This is where most of the effort went.

Redirects. This blog had 79 legacy Redirect 301 lines. Apache’s Redirect /old /new is a prefix match that appends the rest of the path, and the first match wins, so a naive location = /old loses the sub-paths. I generated nginx rewrites that keep those semantics:

# Apache: Redirect 301 /tech/mac /mac   (so /tech/mac/foo also goes to /mac/foo)
rewrite ^/tech/mac(/.*)?$ https://jonathansblog.co.uk/mac$1 permanent;

Before writing a single rule, I captured how production answered every old path, then diffed staging against it:

while read -r p; do
  printf '%s\t%s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' "https://jonathansblog.co.uk$p")"
done < paths.txt > live.tsv
# ...the same against staging with --resolve jonathansblog.co.uk:443:10.0.3.10, then: diff live.tsv staging.tsv

That diff caught a trailing-slash bug in my generator on the first run. I got 86 of 86 matching, apart from two deliberate fixes.

Headers. Apache’s Header set replaces a header. nginx’s add_header appends, and any add_header inside a location stops that location inheriting the server-level ones. CloudPanel also ships /etc/nginx/global_settings, which adds X-Frame-Options SAMEORIGIN and Referrer-Policy same-origin at the same level. That changes live behaviour, and nginx can’t unset a header added at the same level. For sites where that mattered, I dropped the global_settings include and set the headers explicitly.

Keep custom config out of the generated vhost. I put each site’s additions in root-owned files under /etc/nginx/custom.d/<site>/ and added an include line. CloudPanel regenerates the rendered vhost from a template stored in its SQLite database (site.vhost_template), so patch both the template and the rendered file, or your change vanishes the next time the vhost is regenerated. Mine was a small Python helper that read the file before opening it for write. I learned that the hard way: the first version truncated a vhost to zero bytes.

Step 5: The Laravel app

  • Queue workers: Plesk ran Horizon under supervisor. Recreate it under the new site user, with autostart=false in staging.
  • public/storage is an absolute symlink to the old path. Recreate it relatively with ln -sfn ../storage/app/public storage. artisan storage:link --relative needs symfony/filesystem, which the app didn’t have.
  • The scheduler. When I recreated the cron entries, I found that no cron had ever run schedule:run under Plesk. Horizon runs queued jobs, but only the scheduler queues scheduled ones, so the monthly and weekly jobs had never run. Migrations are good at surfacing this kind of thing.
  • The one that bit me after go-live: Livewire serves livewire.min.js through a Laravel route. CloudPanel’s Laravel template has a regex location for .js/.css that serves from disk, so the request 404’d. Apache used to fall back to index.php. The fix is a prefix location, which beats the regex, in both the public and :8080 backend server blocks:
# public server block
location ^~ /livewire- { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; }
# backend (:8080) server block
location ^~ /livewire- { try_files $uri /index.php?$args; }

Step 6: Testing staging with real hostnames

No public ports are needed. Tunnel 80/443 to the container, then point the hostnames at localhost in /etc/hosts. On macOS, binding ports below 1024 on 127.0.0.1 needs root, so run ssh under sudo but with your key and known_hosts:

sudo ssh -N -i ~/.ssh/id_rsa -o UserKnownHostsFile=~/.ssh/known_hosts \
  -L 443:10.0.3.10:443 -L 80:10.0.3.10:80 user@<VPS_IP>
echo "127.0.0.1 example.com www.example.com" | sudo tee -a /etc/hosts

Make staging harmless. A staging copy of a live site can still act on the real world:

  • WordPress cron can publish scheduled posts, which can trigger social auto-posting;
  • contact forms and apps can send email;
  • queue workers can run real jobs.

So I set DISABLE_WP_CRON on every WordPress copy, set the Laravel app’s MAIL_MAILER=log, and left Horizon stopped. All of these were undone at cutover.

Step 7: Cutover to the container

A final file delta (rsync with --no-owner, then a host-side chown to the container’s shifted IDs: 1000000 + the container uid), a final DB dump and import, then one iptables rule per port:

iptables -t nat -I PREROUTING -i eth0 -p tcp --dport 80  -j DNAT --to-destination 10.0.3.10:80
iptables -t nat -I PREROUTING -i eth0 -p tcp --dport 443 -j DNAT --to-destination 10.0.3.10:443

DNAT in PREROUTING applies before local delivery, so traffic moves the instant the rule lands, even though Plesk’s nginx is still listening. Rollback is deleting the two rules. Inside the container, map every site hostname to 127.0.0.1 in /etc/hosts, so loopback requests like wp-cron stay local.

Step 8: Removing Plesk without locking yourself out

Plesk has no “uninstall everything”; officially the answer is to reinstall the OS. Doing it by hand meant three hazards.

1. The firewall. Plesk’s rules use DROP policies. If the uninstall removes the allow rules and leaves the policies, SSH is gone. Replace them first with a tight ruleset of your own, applied with an auto-revert that only a fresh SSH login can cancel:

iptables-save > /root/migration-backup/iptables-plesk.rules
nohup bash -c 'sleep 90; [ -f /root/fw-confirmed ] || iptables-restore < /root/migration-backup/iptables-plesk.rules' &
iptables-restore < /etc/tight-fw/rules.v4   # INPUT/FORWARD DROP; allow 22, 80, 443, ICMP, lxcbr0 DNS/DHCP, DNAT'd traffic
# then, from a NEW ssh session:  touch /root/fw-confirmed

2. PAM. Plesk adds pam_plesk.so to common-auth and common-password. Remove those lines and fix the jump count on the pam_unix line, which was success=2 to skip over the Plesk module and must become success=1. Then test a fresh login.

3. Broken uninstall scripts. Purging failed partway because plesk-core‘s pre-removal script depends on Plesk tooling that was already half gone. For the remaining Plesk packages only, back up and neutralise their maintainer scripts, then purge:

for p in $(dpkg -l | awk '{print $2}' | grep -E '^(plesk|psa|sw-|pp[0-9]|imunify)'); do
  for s in prerm postrm preinst postinst; do
    f=/var/lib/dpkg/info/$p.$s
    [ -f "$f" ] && cp -p "$f" /root/migration-backup/dpkg-scripts/ && printf '#!/bin/sh\nexit 0\n' > "$f"
  done
done
dpkg --purge --force-depends $(dpkg -l | awk '{print $2}' | grep -E '^(plesk|psa|sw-|pp[0-9]|imunify)')

Then delete the Plesk system users. CloudPanel will want to create site users with the same names.

Step 9: Installing CloudPanel natively

The container is serving production through the DNAT, so the native install must not disturb it. CloudPanel’s post-install runs ufw --force reset and enables ufw with routed traffic denied, which would cut the DNAT to the container and take every site down. Three pre-emptive fixes:

apt-get install -y ufw
sed -i 's/^DEFAULT_FORWARD_POLICY=.*/DEFAULT_FORWARD_POLICY="ACCEPT"/' /etc/default/ufw   # survives 'ufw reset'
# Keep the not-yet-claimed admin UI private: the raw table is untouched by ufw
iptables -t raw -I PREROUTING -i eth0 -p tcp --dport 8443 -j DROP
# Container DNS mustn't depend on the host's dnsmasq once ufw is on: use 1.1.1.1 / 8.8.8.8 inside it

Two more surprises:

  • The post-install’s pgrep clp-agent sees the container’s agent process, because containers share the host’s process table, and then fails to stop a host unit that doesn’t exist. Stop the container’s agent for the duration of the install.
  • If you deleted /etc/fail2ban with Plesk, dpkg stops at a conffile prompt. Recover with dpkg --force-confnew --configure -a, and then apt-get install --reinstall -o Dpkg::Options::=--force-confmiss fail2ban.

Step 10: Mirror, verify, flip

Recreate each site natively with the same site users, database names and passwords as the container, so app configs need no edits. rsync from the container’s rootfs, then dump and import each DB. I compared the native copy with the live container on 132 checks (status, redirect target, page title and security headers) and got 132 identical.

The flip script: stop the container’s queue workers, run a final DB and file sync, flush Redis (it still held stale cache from the Plesk era), remove the two DNAT rules, then enable wp-cron and Horizon on the host. The switch itself takes about a minute.

Step 11: Hardening and certificates

  • ufw back to routed DROP, with only 22, 80, 443 and the admin port open.
  • fail2ban: jails for sshd and recidive, plus a perma jail seeded with Plesk’s old bans. ignoreip must include all Cloudflare ranges: behind Cloudflare, every visitor arrives from a Cloudflare IP, so banning one blocks real users. fail2ban 1.0.x doesn’t restore manually added bans after a restart, so a systemd drop-in reloads the list with ExecStartPost.
  • Certificates: the certs copied from Plesk are “imported” as far as CloudPanel is concerned, and CloudPanel never renews imported certificates. Issue fresh Let’s Encrypt certs from CloudPanel for every site. They renew automatically, and the challenge works fine through the Cloudflare proxy.

Things I’d do differently

  • Diff assets, not just pages. My checks compared page status codes and titles. The Livewire 404 was hiding inside a page that returned 200.
  • Check your grep patterns. I declared a Content Security Policy “never live” because my pattern content-security: could never match content-security-policy:. A Cloudflare-cached copy of a page served by Plesk proved me wrong. Beware of Cloudflare caching in general: stale 301s and 404s outlive the fix.
  • Deployments. If Plesk’s Git extension deploys any of your apps, replace it before you remove Plesk. I moved the Laravel app to a GitHub Actions workflow that builds in CI (composer --no-dev, npm and Vite), rsyncs the release over SSH with a key restricted to rrsync plus a single deploy command, and runs migrations and horizon:terminate on the server. This replaces my older Deploy to Plesk with GitHub Actions approach. One tip from its first run: delete bootstrap/cache/packages.php before package:discover, or a switch to --no-dev leaves Laravel unable to boot.

The result: no Plesk licence, a faster and lighter stack, every site on auto-renewing certificates, and a tighter firewall. No site went offline during the move. The one visitor-facing regression was the Livewire asset 404 above, which broke the Laravel app’s interactive parts for a couple of hours until I checked assets rather than just pages.


Leave a Reply