Migrate a WordPress Site from Shared Hosting to a VPS
Your WordPress site has grown. The shared hosting plan that was fine at launch is now showing its limits: erratic response times at peak hours, no way to install Redis or pick your PHP version, a process limit that takes the site down the moment a campaign gets a little traction. Moving to a VPS solves all of that, provided the migration is done properly.
This guide covers the complete, zero-visible-downtime migration of a WordPress site from any shared host (cPanel, Plesk, o2switch, OVH, Ionos, Hostinger, and so on) to a Debian 12 VPS running Nginx, PHP-FPM and MariaDB. The key principle: build the new server entirely, test it with the real site, and only switch DNS at the very end.
Do You Really Need a VPS?
Let's be honest: a VPS requires administration. System updates, security, backups: you handle them. In exchange, you get guaranteed resources and full control.
| Shared hosting | VPS | |
|---|---|---|
| Resources | Shared with dozens of sites | Guaranteed (CPU, RAM, disk) |
| Stack | Imposed (Apache, PHP as provided) | Your choice (Nginx, PHP 8.3, Redis, server-side cache…) |
| Administration | Handled by the host | Your responsibility |
| Usually included | Needs a separate solution | |
| Best for | Brochure site, small blog | High-traffic sites, WooCommerce, multisite, agencies |
If what's driving you away from your current host is slow performance or poor support rather than a need for technical control, YorkHost cPanel Web Hosting may be the better answer: free SSL, Anti-DDoS included, professional email, and no server to administer. This guide is still the right one for anyone who wants full control of a VPS.
Prerequisites
- A YorkHost Linux VPS running Debian 12, with root access. For a standard WordPress site, a VPS-4 (2 vCores, 4 GB) is enough; for WooCommerce or a high-traffic site, go for a VPS-8 at minimum.
- Access to your current hosting: control panel (cPanel, Plesk, etc.), FTP/SFTP and phpMyAdmin
- Access to your domain's DNS zone (at your registrar or your host)
- SSH set up and secured on the VPS, with UFW active and ports 22, 80 and 443 open
Step 1: Audit the Existing Site
Before touching anything, write down what you are migrating. Five minutes here will save you hours of debugging later.
In the WordPress admin, under Tools → Site Health → Info, note down:
- The PHP version in use (you will install the same one, or a newer compatible one)
- The WordPress version and the size of the
wp-content/uploadsdirectory - The table prefix and the database name (visible in
wp-config.php) - The list of active plugins, especially caching plugins (WP Rocket, LiteSpeed Cache, W3 Total Cache) and security plugins (Wordfence, iThemes), which often carry host-specific settings
Also check that the site doesn't rely on host-specific features: a "real" cron configured in the panel, scheduled tasks, redirects in .htaccess (Nginx doesn't use .htaccess, so we will rewrite them), or a specific SSL certificate.
Most shared hosting plans include mailboxes. A VPS does not replace them: running your own mail server is a job in itself. Before cancelling your old hosting, decide where your email addresses will live (your registrar's email offer, the professional email included with YorkHost web hosting, Google Workspace, etc.). For emails sent by WordPress (notifications, WooCommerce orders), use an SMTP plugin (WP Mail SMTP, FluentSMTP) with a proper sending service rather than PHP's mail(), which ends up in spam.
Step 2: Export the Site
Two things to retrieve: the files and the database.
The Files
From your control panel's file manager or via FTP, download the entire site directory (usually public_html/ or www/). On cPanel, Backup → Download a Home Directory Backup produces a .tar.gz archive that is far faster to transfer than thousands of individual files over FTP.
If your host provides SSH access, it's even simpler: everything can be done with a single command from the VPS (see step 4).
The Database
Via phpMyAdmin: select the database, go to the Export tab, choose the Custom method and SQL format, tick Add DROP TABLE statement, and save the file. For a database of several hundred MB, enable gzip compression.
If you have SSH on the old hosting:
mysqldump -u DB_USER -p --single-transaction --default-character-set=utf8mb4 DB_NAME | gzip > wordpress.sql.gz
Duplicator, All-in-One WP Migration and UpdraftPlus work very well and automate part of this guide. Their limitation: the free versions often cap the size (512 MB for All-in-One), and they hide what is going on, which makes diagnosis harder when something breaks. The manual method described here works for a 50 MB site as well as a 50 GB one, and you will understand your server by the end.
Step 3: Prepare the VPS
Install the Stack
sudo apt update && sudo apt upgrade -y
sudo apt install -y nginx mariadb-server php-fpm php-mysql php-curl php-gd php-intl php-mbstring php-xml php-zip php-imagick php-bcmath unzip rsync
On Debian 12, this installs PHP 8.2, which is compatible with all recent WordPress versions. If you need PHP 8.3 or 8.4, add the Sury repository:
sudo apt install -y apt-transport-https lsb-release ca-certificates curl
curl -fsSLo /usr/share/keyrings/deb.sury.org-php.gpg https://packages.sury.org/php/apt.gpg
echo "deb [signed-by=/usr/share/keyrings/deb.sury.org-php.gpg] https://packages.sury.org/php/ $(lsb_release -sc) main" | sudo tee /etc/apt/sources.list.d/php.list
sudo apt update
sudo apt install -y php8.3-fpm php8.3-mysql php8.3-curl php8.3-gd php8.3-intl php8.3-mbstring php8.3-xml php8.3-zip php8.3-imagick php8.3-bcmath
In the rest of this guide, replace php8.2-fpm with the version you installed.
Secure MariaDB and Create the Database
sudo mysql_secure_installation
Answer yes to everything (root password, removal of anonymous users and the test database). Then create the database and user, reusing exactly the same database name and table prefix as on the old hosting so nothing needs to change in wp-config.php:
sudo mysql
CREATE DATABASE monsite_wp CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'monsite'@'localhost' IDENTIFIED BY 'un-mot-de-passe-fort';
GRANT ALL PRIVILEGES ON monsite_wp.* TO 'monsite'@'localhost';
FLUSH PRIVILEGES;
EXIT;
Tune PHP-FPM for WordPress
The defaults are too low for a site with media:
sudo nano /etc/php/8.2/fpm/php.ini
upload_max_filesize = 64M
post_max_size = 64M
memory_limit = 256M
max_execution_time = 120
max_input_vars = 3000
And size the pool according to the VPS RAM (each PHP process uses 30 to 60 MB):
sudo nano /etc/php/8.2/fpm/pool.d/www.conf
pm = dynamic
pm.max_children = 20 # VPS-4: ~12, VPS-8: ~20, VPS-16: ~40
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6
sudo systemctl restart php8.2-fpm
Install WP-CLI
WP-CLI is the WordPress command-line tool. It will make the rest of the process much faster:
curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
chmod +x wp-cli.phar
sudo mv wp-cli.phar /usr/local/bin/wp
wp --info
Step 4: Transfer the Files
Create the site directory and hand it over to the Nginx/PHP user:
sudo mkdir -p /var/www/monsite.fr
sudo chown -R www-data:www-data /var/www/monsite.fr
Option A: rsync from the Old Hosting (if you have SSH)
This is the most reliable method: it resumes where it left off if the transfer drops, and you can run it again to sync the latest changes right before the switch.
sudo rsync -avz --progress -e "ssh -p 22" ancien_user@ancien-hebergeur.fr:~/public_html/ /var/www/monsite.fr/
Option B: Upload the Archive
From your computer, send the archive you downloaded in step 2:
scp backup-monsite.tar.gz root@VPS_IP:/tmp/
Then on the VPS:
cd /tmp && tar -xzf backup-monsite.tar.gz
# Adjust the path to match the archive structure (often homedir/public_html)
sudo rsync -a /tmp/homedir/public_html/ /var/www/monsite.fr/
You can also use a graphical SFTP client, see SFTP with VSCode.
Permissions
sudo chown -R www-data:www-data /var/www/monsite.fr
sudo find /var/www/monsite.fr -type d -exec chmod 755 {} \;
sudo find /var/www/monsite.fr -type f -exec chmod 644 {} \;
sudo chmod 600 /var/www/monsite.fr/wp-config.php
Step 5: Import the Database
Send the dump to the VPS, then import it:
scp wordpress.sql.gz root@VPS_IP:/tmp/
gunzip < /tmp/wordpress.sql.gz | sudo mysql monsite_wp
Check that the tables are there:
sudo mysql -e "USE monsite_wp; SHOW TABLES;" | head
Update wp-config.php
Open /var/www/monsite.fr/wp-config.php and check these lines. If you reused the same names, only the password changes:
define( 'DB_NAME', 'monsite_wp' );
define( 'DB_USER', 'monsite' );
define( 'DB_PASSWORD', 'un-mot-de-passe-fort' );
define( 'DB_HOST', 'localhost' );
While you're there, add these lines if they are missing:
define( 'WP_MEMORY_LIMIT', '256M' );
define( 'DISALLOW_FILE_EDIT', true ); // disables the file editor in the admin
define( 'FS_METHOD', 'direct' );
Check that WordPress can talk to the database:
cd /var/www/monsite.fr
sudo -u www-data wp db check
sudo -u www-data wp option get siteurl
Step 6: Configure Nginx
Nginx does not read .htaccess. Here is a complete, battle-tested configuration for WordPress:
sudo nano /etc/nginx/sites-available/monsite.fr
server {
listen 80;
listen [::]:80;
server_name monsite.fr www.monsite.fr;
root /var/www/monsite.fr;
index index.php index.html;
client_max_body_size 64M;
# Logs
access_log /var/log/nginx/monsite.fr.access.log;
error_log /var/log/nginx/monsite.fr.error.log;
# WordPress permalinks
location / {
try_files $uri $uri/ /index.php?$args;
}
# PHP via PHP-FPM
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_read_timeout 120;
}
# Browser caching for static files
location ~* \.(jpg|jpeg|png|gif|webp|avif|svg|ico|css|js|woff2?|ttf)$ {
expires 30d;
add_header Cache-Control "public, immutable";
access_log off;
}
# Security: block PHP execution in uploads and access to sensitive files
location ~* /wp-content/uploads/.*\.php$ { deny all; }
location ~ /\.(?!well-known) { deny all; }
location = /xmlrpc.php { deny all; }
location ~* (readme\.html|license\.txt|wp-config\.php) { deny all; }
}
Enable the site:
sudo ln -s /etc/nginx/sites-available/monsite.fr /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx
If your old .htaccess contained custom redirects (Redirect 301 /old-page /new-page), convert them into Nginx blocks inside the server block: location = /old-page { return 301 /new-page; }. The standard WordPress rules are already covered by try_files.
Step 7: Test Before Switching DNS
This is the step that makes the migration risk-free. You are going to make your computer alone believe that monsite.fr already points to the VPS, while the rest of the world keeps seeing the old hosting.
Edit your machine's hosts file:
- Windows:
C:\Windows\System32\drivers\etc\hosts(open Notepad as administrator) - macOS / Linux:
sudo nano /etc/hosts
Add:
VPS_IP monsite.fr www.monsite.fr
Open http://monsite.fr in a private browsing window. You should see your site served by the VPS (check with the developer tools: the Server: nginx header instead of Apache or LiteSpeed). Browse the pages, log in to the admin, upload an image, test a contact form, place a test order if it's WooCommerce.
Any problems get fixed now, calmly, with zero impact on your visitors.
If the site is also changing address (for example monsite.ancien-hebergeur.fr → monsite.fr), replace the old URL everywhere in the database with WP-CLI, which handles serialized data correctly, unlike a raw SQL search-and-replace:
cd /var/www/monsite.fr
sudo -u www-data wp search-replace 'https://ancien-domaine.fr' 'https://monsite.fr' --all-tables --precise
Add --dry-run first to see what would be changed.
Step 8: Switch DNS
Once the site is validated on the VPS:
- Lower the TTL of your DNS records to 300 seconds, ideally 24 hours before the switch, so the change propagates quickly. If you haven't done it, it's not a blocker; propagation will simply take longer.
- If you have SSH on the old hosting, do a final sync of the files and database (posts published and orders placed in the meantime): run the
rsyncagain and re-import a fresh dump. - Update the records:
| Type | Name | Value | TTL |
|---|---|---|---|
| A | @ | VPS_IP | 300 |
| A | www | VPS_IP | 300 |
- Remove the line you added to your
hostsfile. - Check propagation with
dig monsite.fr +shortor dnschecker.org.
Keep it active for at least two weeks. That covers stubborn DNS caches and lets you retrieve any forgotten file. Put the old site in maintenance or read-only mode during that time so no content gets created there by mistake.
Step 9: Enable HTTPS
As soon as the domain points to the VPS, get a Let's Encrypt certificate:
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d monsite.fr -d www.monsite.fr
Choose the automatic HTTP → HTTPS redirect. Certbot edits the Nginx configuration and installs an automatic renewal timer. Verify it:
sudo certbot renew --dry-run
Then update the WordPress URLs if they were still on http://:
cd /var/www/monsite.fr
sudo -u www-data wp option update home 'https://monsite.fr'
sudo -u www-data wp option update siteurl 'https://monsite.fr'
sudo -u www-data wp search-replace 'http://monsite.fr' 'https://monsite.fr' --all-tables --precise
Step 10: Finishing Touches for a Fast, Secure Site
Reliable WordPress Cron
By default, WordPress runs its scheduled tasks on each visit, which is unreliable and costly. Replace that mechanism with a real cron:
// wp-config.php
define( 'DISABLE_WP_CRON', true );
sudo crontab -u www-data -e
*/5 * * * * cd /var/www/monsite.fr && /usr/local/bin/wp cron event run --due-now --quiet
See the Cron jobs guide for details.
Object Cache with Redis
An object cache avoids querying MariaDB again for the same data on every page. Typical gain: 30 to 50% less generation time in the admin and on WooCommerce.
sudo apt install -y redis-server php8.2-redis
sudo systemctl enable --now redis-server
sudo systemctl restart php8.2-fpm
Then install the Redis Object Cache plugin in WordPress and enable it.
Page Cache
For logged-out visitors, a page cache serves static HTML without running PHP. Two effective options: a plugin (WP Super Cache, Cache Enabler, or WP Rocket if you're willing to pay), or Nginx's FastCGI cache, which performs better but is a bit more technical. If you were already using a caching plugin on shared hosting, re-enable it and purge everything.
OPcache
Check that OPcache is enabled (it is by default on Debian) and increase its memory for WordPress:
sudo nano /etc/php/8.2/fpm/conf.d/10-opcache.ini
opcache.enable=1
opcache.memory_consumption=192
opcache.max_accelerated_files=20000
opcache.validate_timestamps=1
opcache.revalidate_freq=60
Protect the Login Page
Brute-force login attempts on wp-login.php are a constant on any public WordPress site. A dedicated Fail2ban jail blocks them at the firewall level. The full configuration is in Advanced Fail2ban. Also add two-factor authentication (Two Factor plugin or Wordfence).
Backups
Your YorkHost VPS includes 7-day rolling backups of the full disk, restorable in one click from the client area: a valuable safety net after an update goes wrong. Complement it with an off-site application backup (files + database dump): that is the subject of the Automatic offsite backup with Restic or Borg guide.
Migration Checklist
- Audit: PHP version, plugins, uploads size, cron, redirects
- Email solution decided before cancellation
- Files and database exported
- Stack installed, PHP-FPM sized, database created with the same name
- Files transferred, permissions correct
- Database imported,
wp db checkOK - Nginx configured,
.htaccessredirects converted - Site tested via the
hostsfile: browsing, admin, upload, forms - TTL lowered, final sync, DNS switched
- HTTPS active, URLs on
https:// - Real cron, Redis, page cache, Fail2ban, off-site backups
- Old hosting kept for two weeks, then cancelled
Troubleshooting
Blank Page or 500 Error
Check /var/log/nginx/monsite.fr.error.log and /var/log/php8.2-fpm.log. Temporarily enable define( 'WP_DEBUG', true ); and define( 'WP_DEBUG_LOG', true ); in wp-config.php: the error will show up in wp-content/debug.log. Common cause: a missing PHP extension (php-intl, php-imagick) used by a theme.
"Error establishing a database connection"
The credentials in wp-config.php don't match those created in MariaDB, or DB_HOST points to the old host's server instead of localhost.
Permalinks Return a 404
The location / { try_files ... } block is missing, or the active site in Nginx isn't the right one. sudo nginx -T | grep server_name lists the loaded vhosts. Then re-save the permalinks: Settings → Permalinks → Save Changes.
Infinite Redirect Loop
Usually a caching or SSL plugin (Really Simple SSL) configured for the old host, or an http/https mismatch between home, siteurl and the Nginx configuration. Deactivate plugins with wp plugin deactivate --all, fix the URLs, then re-enable them one by one.
Images Display but Uploads Fail
Permissions: sudo chown -R www-data:www-data /var/www/monsite.fr/wp-content/uploads. Or upload_max_filesize / client_max_body_size is too low.
The Site Is Slow After Migration
Before blaming the server: run htop to see whether PHP or MariaDB is saturated, then see Performance debugging. Nine times out of ten, it's a missing page cache or a plugin calling an external service on every page.
Our Linux VPS run on Enterprise SSDs with Xeon processors clocked above 3 GHz, which matters for PHP since its execution is essentially single-threaded. Multi-Tbps Anti-DDoS is included at no extra cost, as are 7-day backups, and our French support team responds in under 2 hours during the day if you get stuck on any step of this migration.