Plesk to HestiaCP migration

This is more reminder for me then step by step tutorial. Some basic knowledge is required. Or some advanced knowledge 🙂 depends. And focus is on mistakes I did, in order to not make them again.

Setup

Old server: CentOS managed by Plesk + Cloudflare DNS. 6 websites with some mail accounts. 5 wordpress sites and one phpbb forum. That’s all.

New server: Fresh installation of Debian 12, empty. No panel. Cloudflare DNS.

HestiaCP installation

Hestia needs full FQDN domain name. So something like host.mydomain.com and that domain name must match hostname. If it doesn’t match SSL lets Encrypt will not work. Hestia will generate self signed certificate instead of Lets Encrypt. Easy to fix, just change hostname to host.mydomain.com.

sudo hostnamectl set-hostname host.mydomain.com

### /etc/hosts
127.0.0.1   host.mydomain.com host

The other more important thing. Mail. Do not put any yahoo google mail during install. If it is required to put some mail, use your own domain mail. The thing is, Hestia will send notification on that mail, and without properly setup, so PTR (rDNS), DKIM, DMARC SPF1 your domain will be blacklisted for spam. Even when you setup everything right, it needs some time to regain reputation.

Installation is pretty easy. Go to https://hestiacp.com/install and just check features you need. In my case, no bind, multi php, sieve… etc

# download install script
wget https://raw.githubusercontent.com/hestiacp/hestiacp/release/install/hst-install.sh

# and run it
bash hst-install.sh --hostname 'host.mydomain.com' --username 'paneluser' --password 'myadminpass' --multiphp '7.4, 8.1, 8.3' --named no --sieve yes

# and follow, it will be a few prompts.. 

Now, in order to avoid downtime, the order of doing things is very important. When you have Hestia up and running, make users for all websites, make web space for all websites, make same email accounts, make databases for all websites. Transfer websites files and databases to right places. Plesk use /var/www/vhosts/mydomain.com/httpdocs. Hestia use /home/user/web/mydomain.com/public_html. Change database credentials in wp-config (it will be different in Hestia). Transfer all mails with imapsync. And then point your DNS to new server. Then add Let’s Encrypt from Hestia panel, for web and for mail. It is separate things. Cloudflare proxy must be turned off. After SSL is installed turn it on. After that, SPF1, DKIM DMARC, copy it from Hestia. You will find it in mail domain dns settings. PTR must be changed to mail.mydomain.com by your hosting provider. Or, login to your provider panel and search for PTR or RDNS. Change will take a while to take effect. At the end, your ip address must point to your domain. (reverse dns)

Transfer WEB sites content and databases

### on Plesk
### go to /var/www/vhosts/mydomain
zip -r mysite httpdocs
mysqldump --user=root -p my_db_name > my_db_name.sql

### transfer files via sftp or rsync

### on Hestia
### go to /home/user/web/mydomain.com/
### unzip to current folder
unzip mysite.zip -d .   
sudo mariadb -u root -p -e "USE my_db_name; SOURCE /path/to/my_data_base.sql;"

### move original html_public with some generic files in it, then move httpdocs to public_html
mv public_html public_html_orig
mv httpdocs public_html

### permissions and ownership
### permissions are saved by zip but ownershio has to be set
chown -R hestia_user:hestia_group public_html

### if you have to set permissions manualy general rules for wordpress is 755 for folders, 644 for files, 600 for .htaccess and wp-config.php. But it could vary from case to case

And the moment of truth. Point DNS to new IP address. All web sites worked like a charm. All but one. That one: SERVER ERROR 500. I spent all night on this. So, all sites was copied from hosting to hosting like I described above. But not this one. Plesk installed wordpress for this one. After searching the logs, it appears that there is hard coded path to wordfence plugin. Plesk path was /var/www/vhosts/domain…etc. And Hestia path is different. Deleting the plugin has no effect, disabling .htaccess also. Long story short, it is in root of website in .user.ini. Just delete it.

MAIL migration

This is easy, once you have imapsync installed. Debian 12? apt install imapsync? nope… so

sudo apt install cpanminus -y
sudo cpanm Mail::IMAPClient JSON::WebToken::Crypt::RSA
sudo cpanm imapsync

### missing modules?

sudo apt update
sudo apt install git make perl libmail-imapclient-perl libdigest-hmac-perl \
libterm-readkey-perl libio-socket-ssl-perl libio-tee-perl libfile-copy-recursive-perl \
libauthen-ntlm-perl libjson-webtoken-perl libcrypt-openssl-rsa-perl \
libsys-meminfo-perl libunicode-string-perl liburi-perl -y

cd /usr/local/src
sudo git clone https://github.com/imapsync/imapsync.git
cd imapsync
sudo make all
### if there are no errors
sudo make install

### if „rlog not found / error 127” ?
sudo apt install rcs -y

cd /usr/local/src/imapsync
sudo make all
sudo make install

### if rcsdiff: RCS/imapsync,v: No such file or directory make: *** [Makefile:107: VERSION] Error 2

cd /usr/local/src/imapsync
### copy script to the path
sudo install imapsync /usr/local/bin/

### if Can't locate Encode/IMAPUTF7.pm in @INC (you may need to install the Encode::IMAPUTF7 module)

apt install libencode-imaputf7-perl -y
apt install libfile-tail-perl -y

That was about installing imapsync on Debian 12. Now test connection. DNS still points to old server. For new server just use ip address.

imapsync \
--host1 mail.old-server.com --user1 [email protected] --password1 'old_password' \
--host2 123.123.123.123 --user2 [email protected] --password2 'new_password' \
--justconnect

If it’s ok, start sync. It can take a while depends on how big are mail boxes.

imapsync \
--host1 mail.old-server.com --user1 [email protected] --password1 'old_password' \
--host2 <IP_address_of_new_server> --user2 [email protected] --password2 'new_password' \
--automap --addheader --nofoldersizes --skipsize --useheader 'Message-ID'

PTR points to just one mail domain. For multiple mail domains use:

v=spf1 a mx include:mail.firstdomain.com -all

SpamAssassin + Bayes – learn from spam folder

nano /etc/spamassassin/local.cf

### copy this on the beggining of the local.cf

bayes_auto_learn 1
use_bayes 1
bayes_path /var/spamassassin/bayes
bayes_auto_learn_threshold_spam 6.0
bayes_auto_learn_threshold_nonspam 0.1

### restart service

systemctl restart spamd

### make folder if doesn't exist
mkdir -p /var/spamassassin
chown -R debian-spamd:debian-spamd /var/spamassassin
### OR chown -R hestia_user:mail /var/spamassassin (just for one user)

### from this moment, when you move mail as spam
### Hestia can start sa-learn script

### make bayes databes if does not exist
mkdir -p /var/lib/spamassassin/.spamassassin
chown -R debian-spamd:debian-spamd /var/lib/spamassassin/.spamassassin
chmod -R 700 /var/lib/spamassassin/.spamassassin

So, some files and folders to make, and permissions to set. I made a script that should check for missing files, make them and set permissions and then execute sa-learn for all mail boxes for spam and ham. It needs to collect at least 200 mails for spam and hame before it starts to work.

#!/bin/bash
# learn-spam-all.sh
# Version: PRO
# Runs sa-learn for multiple mailboxes: learns both spam and ham for each entry.
# Run as root (cron or manually). Log: /var/log/sa-learn-all.log
# If you don’t want the script to adjust permissions, set FIX_PERMS=false

set -euo pipefail

LOGFILE="/var/log/sa-learn-all.log"
BAYES_DIR="/var/spamassassin"
KEEP_DAYS=30
DATEFMT='+%Y-%m-%d %H:%M:%S'
FIX_PERMS=true   # set to false if you don’t want permission changes

# ------------------------------------------------------------------
# CONFIGURATION — each line: user domain mailbox
# Example:
#   "user1 mydomain1.com mailbox1"
#   "user2 mydomain2.com mailbox2"
#   "user3 mydomain3.com mailbox3"
# ------------------------------------------------------------------
MAILBOXES=(
   "user1 mydomain1.com mailbox1"
   "user2 mydomain2.com mailbox2"
   "user3 mydomain3.com mailbox3"
)
# ------------------------------------------------------------------

echo "[$(date "$DATEFMT")] INFO: Starting learn-spam-all run" | tee -a "$LOGFILE"

# Check if sa-learn is available
if ! command -v sa-learn >/dev/null 2>&1; then
  echo "[$(date "$DATEFMT")] ERROR: sa-learn not found on PATH" | tee -a "$LOGFILE"
  exit 1
fi

# Ensure /var/spamassassin exists
mkdir -p "$BAYES_DIR"

# Optional permissions fix (safe — changes owner/group/perms, never deletes)
if [ "$FIX_PERMS" = true ]; then
  echo "[$(date "$DATEFMT")] INFO: Applying Bayes permissions fix to $BAYES_DIR" | tee -a "$LOGFILE"
  # Change owner to mkdnews:mail (adjust if you want another owner)
  chown -R mkdnews:mail "$BAYES_DIR" 2>/dev/null || true
  # SGID on directory
  chmod 2770 "$BAYES_DIR" 2>/dev/null || true
  # Files 660, directories 770
  find "$BAYES_DIR" -type f -exec chmod 660 {} \; 2>/dev/null || true
  find "$BAYES_DIR" -type d -exec chmod 770 {} \; 2>/dev/null || true
  # Apply ACLs if available (default group rwX)
  if command -v setfacl >/dev/null 2>&1; then
    setfacl -R -m g:mail:rwX "$BAYES_DIR" 2>/dev/null || true
    setfacl -R -d -m g:mail:rwX "$BAYES_DIR" 2>/dev/null || true
  fi
else
  echo "[$(date "$DATEFMT")] INFO: Skipping permission fixes (FIX_PERMS=false)" | tee -a "$LOGFILE"
fi

# Helper: tries to locate spam paths and ham paths
find_spam_path() {
  local user="$1" domain="$2" box="$3"
  candidates=(
    "/home/${user}/mail/${domain}/${box}/.Spam/cur"
    "/home/${user}/mail/${domain}/${box}/.Junk/cur"
    "/home/${user}/mail/${domain}/${box}/Spam/cur"
    "/home/${user}/mail/${domain}/${box}/.spam/cur"
  )
  for p in "${candidates[@]}"; do
    [ -d "$p" ] && { printf '%s' "$p"; return 0; }
  done
  return 1
}

find_ham_path() {
  local user="$1" domain="$2" box="$3"
  candidates=(
    "/home/${user}/mail/${domain}/${box}/cur"
    "/home/${user}/mail/${domain}/${box}/Inbox/cur"
    "/home/${user}/mail/${domain}/${box}/INBOX/cur"
    "/var/mail/${user}"
    "/home/${user}/Maildir/cur"
  )
  for p in "${candidates[@]}"; do
    [ -d "$p" ] && { printf '%s' "$p"; return 0; }
  done
  return 1
}

# Main loop: for each entry, attempt spam then ham learning
for entry in "${MAILBOXES[@]}"; do
  read -r USER DOMAIN BOX <<< "$entry"

  if ! id "$USER" &>/dev/null; then
    echo "[$(date "$DATEFMT")] WARN: user $USER does not exist, skipping" | tee -a "$LOGFILE"
    continue
  fi

  echo "[$(date "$DATEFMT")] INFO: Processing $USER@$DOMAIN/$BOX" | tee -a "$LOGFILE"

  # --- SPAM ---
  if spam_path=$(find_spam_path "$USER" "$DOMAIN" "$BOX"); then
    echo "[$(date "$DATEFMT")] INFO: Found spam path: $spam_path" | tee -a "$LOGFILE"
    echo "[$(date "$DATEFMT")] INFO: Running sa-learn --spam as $USER ..." | tee -a "$LOGFILE"
    OUTPUT=$(sudo -u "$USER" -H sa-learn --spam "$spam_path" --dbpath "$BAYES_DIR" 2>&1 || true)
    echo "$OUTPUT" | tee -a "$LOGFILE"
  else
    echo "[$(date "$DATEFMT")] NOTICE: No spam folder found for $USER@$DOMAIN/$BOX" | tee -a "$LOGFILE"
  fi

  # --- HAM ---
  if ham_path=$(find_ham_path "$USER" "$DOMAIN" "$BOX"); then
    echo "[$(date "$DATEFMT")] INFO: Found ham path: $ham_path" | tee -a "$LOGFILE"
    echo "[$(date "$DATEFMT")] INFO: Running sa-learn --ham as $USER ..." | tee -a "$LOGFILE"
    OUTPUT=$(sudo -u "$USER" -H sa-learn --ham "$ham_path" --dbpath "$BAYES_DIR" 2>&1 || true)
    echo "$OUTPUT" | tee -a "$LOGFILE"
  else
    echo "[$(date "$DATEFMT")] NOTICE: No ham folder found for $USER@$DOMAIN/$BOX" | tee -a "$LOGFILE"
  fi

  # Summary: dump magic
  SUMMARY=$(sudo -u "$USER" -H sa-learn --dbpath "$BAYES_DIR" --dump magic 2>/dev/null || true)
  echo "$SUMMARY" | grep -E "nspam|nham|ntokens" | tee -a "$LOGFILE" || true

  echo "[$(date "$DATEFMT")] INFO: Completed $USER@$DOMAIN/$BOX" | tee -a "$LOGFILE"
  echo "------------------------------------------------------------" | tee -a "$LOGFILE"
done

# Rotate old logs (KEEP_DAYS)
find /var/log -name "sa-learn-all.log*" -mtime +"${KEEP_DAYS}" -exec rm -f {} \; 2>/dev/null || true

echo "[$(date "$DATEFMT")] INFO: Full run finished." | tee -a "$LOGFILE"
exit 0

If works, put to cron

crontab -e
### put line ###
0 3 * * * /usr/local/bin/learn-spam.sh

Mail debug

I had an situation. Mail loop. Deleting the spam folder, but all mails reappears after few seconds. Problem from Plesk setup, different name for spam folder. Here is junk folder

cd /home/USERNAME/mail/mydomain.com/mail/
systemctl stop dovecot
rm -f dovecot.index* maildirsize
systemctl start dovecot
rm -rf /var/lib/roundcube/temp/*
rm -rf /var/lib/roundcube/cache/*

ls -lh new cur
find . -type f -size 0
find . -type f -size 0 -delete
rm -f dovecot.index* dovecot-uidlist dovecot-keywords


systemctl restart dovecot

### mark as spam
nano /etc/exim4/spam-block.conf

### add to file
# Enable SpamAssassin headers
warn  spam = Debian-exim:true
      add_header = X-Spam-Score: $spam_score_int
      add_header = X-Spam-Flag: $spam_score_int :Gte: 50 :?YES:?NO
      add_header = X-Spam-Report: $spam_report

# Tag message subject if spam
warn  spam = Debian-exim:true
      condition = ${if >{$spam_score_int}{50}{true}{false}}
      add_header = Subject: *****SPAM***** $h_Subject:

systemctl restart exim4

### put spam to junk
mkdir -p /etc/dovecot/sieve
nano /etc/dovecot/sieve/global-spamfilter.sieve
### put the same

require ["fileinto", "mailbox"];

# Ako header sadrži SPAM oznaku, prebaci u Junk
if header :contains "X-Spam-Flag" "YES" {
    fileinto "Junk";
    stop;
}

# Ako subject ima *****SPAM*****, takođe u Junk
if header :contains "Subject" "*****SPAM*****" {
    fileinto "Junk";
    stop;
}

### save 
### compile

sievec /etc/dovecot/sieve/global-spamfilter.sieve

### install if missing
apt install dovecot-sieve dovecot-managesieved -y


/etc/dovecot/conf.d/90-sieve.conf
### put this line
sieve_before = /etc/dovecot/sieve/

### restart dovecot
systemctl restart dovecot

### permissions
chown -R vmail:vmail /home/user/mail/mydomain.com/info/sieve
chmod 750 /home/user/mail/mydomain.com/info/sieve
systemctl restart dovecot

### then I have problem with this conf
/etc/exim4/dnsbl.conf
### where zen.spamhouse was blocking everything (delete all content, leave empty file)

SSL Let’s Encrypt Renew

This Script will connect Cloudflare via API. Turn-off proxy. Wait and check when proxy is turned off. Issue new certificate for web and mail. Turn on proxy.

#!/bin/bash

DOMAIN="mydomain.com"
USER="myuser"

echo "=== Cloudflare proxy kontrola za $DOMAIN ==="

# 1️⃣ Gasi proxy (ovde dodaj svoju komandu)
echo "[1/4] Gasim Cloudflare proxy..."

# Cloudflare proxy OFF
CF_API_TOKEN="Cloudflare_API_token_here"
ZONE_ID="Cloudflare_ID_ZONE_here"
DOMAIN="mydomain.com"
RECORDS=("" "www" "webmail")

for record in "${RECORDS[@]}"; do
  [ -z "$record" ] && name="$DOMAIN" || name="$record.$DOMAIN"
  echo "➡️ Gasi proxy za $name..."
  id=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?name=$name" \
       -H "Authorization: Bearer $CF_API_TOKEN" \
       -H "Content-Type: application/json" | jq -r '.result[0].id // empty')

  [ -z "$id" ] && echo "⚠️ Nije pronađen DNS zapis za $name" && continue

  curl -s -X PATCH "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$id" \
       -H "Authorization: Bearer $CF_API_TOKEN" \
       -H "Content-Type: application/json" \
       --data '{"proxied": false}' >/dev/null

  echo "✅ Proxy isključen za $name"
done

# 2️⃣ Cekaj dok Cloudflare stvarno ne nestane iz HTTP odgovora
echo "[2/4] Proveravam da li je Cloudflare iskljucen..."
MAX_WAIT=120   # maksimalno cekaj 2 minuta
INTERVAL=5     # proverava svakih 5 sekundi
elapsed=0

while ((elapsed < MAX_WAIT)); do
    SERVER_HEADER=$(curl -sI "https://$DOMAIN" | grep -i "server:" | tr -d '\r')
    if [[ "$SERVER_HEADER" =~ "cloudflare" ]]; then
        echo " ⏳ Cloudflare jos aktivan ($SERVER_HEADER)... cekam..."
        sleep $INTERVAL
        ((elapsed+=INTERVAL))
    else
        echo " ✅ Cloudflare proxy iskljucen ($SERVER_HEADER)"
        break
    fi
done

if ((elapsed >= MAX_WAIT)); then
    echo " ⚠️ Upozorenje: Cloudflare se nije iskljucio ni posle $MAX_WAIT sekundi."
    echo "     Mozda DNS jos uvek pokazuje na proxy IP."
    echo "     Nastavljam ipak sa Let's Encrypt pokusajem..."
fi

# 3️⃣ Pokreni Let's Encrypt izdavanje sertifikata.... ovo "" yes je za mail domain samo
echo "[3/4] Pokrecem izdavanje Let's Encrypt sertifikata..."
sudo /usr/local/hestia/bin/v-add-letsencrypt-domain "$USER" "$DOMAIN" "" yes
sudo /usr/local/hestia/bin/v-add-letsencrypt-domain "$USER" "$DOMAIN"
LE_STATUS=$?

if [[ $LE_STATUS -eq 0 ]]; then
    echo " ✅ Sertifikati uspesno izdati za $DOMAIN!"
else
    echo " ❌ Greska prilikom izdavanja sertifikata (exit code $LE_STATUS)."
    echo "     Proveri log: /usr/local/hestia/log/letsencrypt.log"
fi

# Cloudflare proxy ON mydomain.com
CF_API_TOKEN="Cloudflare_API_TOKEN_here"
ZONE_ID="Cloudflare_ZONE_ID_here"
DOMAIN="mydomain.com"
RECORDS=("" "www" "webmail")

for record in "${RECORDS[@]}"; do
  [ -z "$record" ] && name="$DOMAIN" || name="$record.$DOMAIN"
  echo "➡️ Pali proxy za $name..."
  id=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?name=$name" \
       -H "Authorization: Bearer $CF_API_TOKEN" \
       -H "Content-Type: application/json" | jq -r '.result[0].id // empty')

  [ -z "$id" ] && echo "⚠️ Nije pronađen DNS zapis za $name" && continue

  curl -s -X PATCH "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$id" \
       -H "Authorization: Bearer $CF_API_TOKEN" \
       -H "Content-Type: application/json" \
       --data '{"proxied": true}' >/dev/null

  echo "✅ Proxy uključen za $name"
done

echo "[4/4] Gotovo!"

protect PHPMYADMIN with Cloudflare ZeroTrust

ZeroTrust > access > applications > add an application

ZeroTrust > access > policies > add a policy

ZeroTrust > settings > authentication > Login methods > one-time PIN

ZeroTtust > access > applications > policies > select existing policies

ZeroTrust > settings > authentication > app launcher > manage > select existing plicies

Limit PHPMYADMIN to just one URL

sudo nano /etc/apache2/conf.d/phpmyadmin.inc

### paste under:
### <Directory /usr/share/phpmyadmin>
### Options SymLinksIfOwnerMatch
### DirectoryIndex index.php

<If "%{HTTP_HOST} != 'myhost.mydomain.com'">
        Require all denied
</If>


#### and then
sudo apachectl configtest
sudo systemctl reload apache2

rclone > gdrive (backups)

apt install rclone ## debian will install old version v1.60.1-DEV
curl https://rclone.org/install.sh | bash    ### this will install latest

rclone config

n) New remote
name> gdrive
type> drive
Scope> 1   # (full access)

Use auto config? (y/n) # NO....or Advanced config → n....“Edit the config manually?” yeap

Go to the following link: https://accounts.google.com/o/oauth2/auth?client_id=...
### copy link to browser
### Google will reply with auth code. Paste it back to terminal

rclone lsd gdrive:

rclone copy /backup/ gdrive:/test-backup/ --progress


Secure /wp-admin with Cloudflare managed challenge

In domain menu: security > security rules > create rule > custom rule

### Rule name (required)
wp-login protect

### When incoming requests match…
(http.request.uri.path contains "/wp-login.php" or http.request.uri.path contains "/wp-admin")

Then take action…
Managed Challenge

### Rule name
xmlrpc block

### When incoming requests match…
http.request.uri.path contains "/xmlrpc.php"

Then take action…
Block

SSHD – sftp config bug

nano /etc/ssh/sshd_config

# override default of no subsystems
Subsystem sftp internal-sftp   ### instead of sftp-server

DB root pass reset

Do not put #$@#$ in password in mysql cli. Yeap, I locked root password

sudo systemctl stop mariadb
sudo mariadbd --skip-grant-tables --skip-networking &

sudo mariadb

FLUSH PRIVILEGES;
ALTER USER 'root'@'localhost' IDENTIFIED BY 'New_password';
FLUSH PRIVILEGES;
EXIT;

sudo pkill -f mariadbd
sudo systemctl start mariadb

Leave a Reply

Your email address will not be published. Required fields are marked *

Posted by: Aca Faca on

Tags: , , , , ,

seo reseller