The 3-Hour Migration That Saved $200/Year — A Real Shared-to-VPS Walkthrough
Last October, a client emailed me a screenshot of their cPanel dashboard. CPU usage exceeded. Memory limit reached. TTFB hovering around 940ms on a Tuesday afternoon. They were paying $14.99/mo for a "premium" shared hosting plan that throttled them every 6 hours. Their site — a WordPress blog with about 800 posts and 25,000 monthly visitors — had officially outgrown the shared environment. The fix was not another caching plugin. The fix was leaving.
I moved them to a Vultr 2GB VPS at $12/mo. The TTFB dropped from 940ms to 118ms. Not after weeks of tweaking. After the migration itself. Same WordPress install, same theme, same plugins, same database. The only thing that changed was the server underneath it, and that change saved them $36/year in hosting costs while delivering 8x faster response times. That afternoon I wrote up every step I took, because this is a migration I have done dozens of times now, and the process has become surgical.
Here is the complete playbook. Three hours of work. Zero seconds of downtime. And a performance upgrade that makes every other optimization you have tried feel like rearranging deck chairs.
Table of Contents
- 6 Signs You Have Outgrown Shared Hosting
- The Real Cost Comparison (Shared vs VPS)
- Pre-Migration Checklist (15 Minutes That Save Hours)
- Choose and Provision Your VPS
- Install the LEMP Stack
- Lower DNS TTL (24 Hours Before)
- Transfer Files with rsync
- Migrate the Database with mysqldump
- Configure Nginx Virtual Hosts
- Convert .htaccess Rules to Nginx
- Install SSL with Certbot
- Test Privately Before DNS Switch
- The DNS Cutover (5 Minutes)
- Post-Migration Verification Checklist
- The Rollback Plan
- Before and After: Real Numbers
- FAQ
1. 6 Signs You Have Outgrown Shared Hosting
Shared hosting is where every website starts. It works. It is cheap. And for a personal blog getting 50 visits a day, it is perfectly adequate. But the architecture has a ceiling, and when you hit it, no amount of optimization on your end fixes the problem — because the problem is 200 other sites on the same physical machine competing for the same CPU cores, the same RAM, and the same I/O bandwidth.
I have identified these signals across hundreds of migrations. If three or more apply to you, stop troubleshooting your application and start planning your move:
- TTFB above 500ms on a cached page. If your time-to-first-byte exceeds half a second when serving a cached HTML page, the bottleneck is not your code. It is the server. On a properly configured VPS, cached pages serve in under 50ms.
- CPU limit warnings in cPanel. Those "Entry Processes" or "CPU Usage" warnings mean the shared host is throttling you. Every request that hits the throttle either slows down or returns an error. You cannot buy more CPU on shared hosting.
- You need software that requires root access. Redis, custom PHP extensions, Node.js, Python virtual environments, Docker — anything beyond the standard cPanel stack requires a server you control. Shared hosting says no. VPS says yes.
- Traffic exceeds 15,000 visits per month. Most shared hosts start aggressive throttling around this threshold. The exact number varies by host and plan, but the pattern is consistent: sustained traffic triggers resource limits. A $4/mo VPS handles this traffic without breaking a sweat.
- Your neighbor's bad code is affecting your site. Shared hosting means shared PHP-FPM pools. One poorly coded plugin on a neighboring site can spike CPU and slow everyone. On a VPS, there are no neighbors. The resources are yours.
- SSL or security configuration is restricted. Need HSTS headers? Custom firewall rules? HTTP/2 push? TLS 1.3-only? Shared hosting gives you checkboxes. VPS gives you the full Nginx config.
2. The Real Cost Comparison
The common objection: "VPS is more expensive." Let me break that assumption with real numbers from providers I have actually used and benchmarked.
A "good" shared hosting plan costs $7-15/mo once the introductory rate expires. SiteGround's GrowBig plan is $14.99/mo at renewal. Bluehost Plus is $13.99/mo. These plans share CPU, RAM, and I/O with hundreds of other accounts. You get no guaranteed resources.
Now compare with VPS options that give you dedicated resources:
| Provider | Plan | Price/mo | vCPU | RAM | Storage |
|---|---|---|---|---|---|
| Kamatera | 1 vCPU / 1GB | $4.00 | 1 | 1 GB | 20 GB SSD |
| Hetzner | CX22 | $4.59 | 2 | 4 GB | 40 GB NVMe |
| RackNerd | KVM 2GB | $5.49 | 2 | 2 GB | 40 GB SSD |
| Vultr | Cloud Compute | $6.00 | 1 | 1 GB | 25 GB NVMe |
| DigitalOcean | Basic Droplet | $6.00 | 1 | 1 GB | 25 GB NVMe |
| Contabo | Cloud VPS S | $6.99 | 4 | 8 GB | 200 GB SSD |
Hetzner at $4.59/mo gives you 2 dedicated vCPUs and 4GB of RAM on NVMe storage. That is more dedicated compute than any $15/mo shared plan will ever deliver. The Contabo VPS S at $6.99/mo gives you 4 vCPUs and 8GB RAM — genuinely absurd value if you do not need the lowest latency from US datacenters. Even Vultr and DigitalOcean at $6/mo with 1GB RAM will outperform shared hosting by a wide margin because the resources are guaranteed to you.
The client I mentioned at the top? They went from $14.99/mo shared to $12/mo VPS. Faster site, lower cost. That is $35.88/year in savings, plus the performance improvement that is worth far more in terms of SEO ranking signals and user experience.
3. Pre-Migration Checklist
I learned the hard way that the most common migration failures happen before the migration starts. Someone forgets their database credentials. Someone does not realize they have three subdomains. Someone has custom PHP settings they never documented. Spend 15 minutes gathering this information now, and the actual migration goes smoothly.
Open a text file and record all of the following. This is your migration manifest:
- Domains and subdomains: List every domain and subdomain hosted on the shared account. Check cPanel > Addon Domains and Subdomains.
- Database credentials: For every database — name, username, password. cPanel > MySQL Databases shows the first two; passwords are in your application config files (wp-config.php, .env, etc.).
- PHP version: Check cPanel > PHP Selector or MultiPHP Manager. Note the version and any custom php.ini settings.
- Cron jobs: cPanel > Cron Jobs. Copy every entry. These need to be recreated on the VPS.
- Email accounts: If you host email on shared hosting, list all accounts and forwarders. This is a separate migration.
- SSL certificates: Note if you are using AutoSSL (free from cPanel) or a paid certificate. On VPS, you will use Let's Encrypt via Certbot.
- Disk usage: cPanel > Disk Usage. Your VPS needs at least 2x this amount for comfortable operation.
- .htaccess rules: Download every .htaccess file. These will need conversion to Nginx format.
- Full backup: Take a complete cPanel backup (Backup Wizard > Full Backup). Download it locally as insurance.
4. Choose and Provision Your VPS
For a first VPS after shared hosting, the 2GB RAM tier is the sweet spot. It gives you headroom for the web server, database, and PHP-FPM processes without constantly worrying about memory. I generally recommend:
- If you want the best price-to-performance ratio: Hetzner CX22 at $4.59/mo (2 vCPU, 4GB RAM, Ashburn VA datacenter). Unbeatable value for US East Coast audiences.
- If you want the most US datacenter options: Vultr at $12/mo for 2GB (9 US locations) or Kamatera at $9/mo for 2GB (New York, Dallas, Santa Clara).
- If budget is the top priority: RackNerd or Contabo — more RAM per dollar but sometimes less consistent network performance.
Pick Ubuntu 24.04 LTS. It has the widest software compatibility, the most tutorials online, and long-term support through 2029. Provisioning takes 30-60 seconds. Once it is up, SSH in and continue.
# SSH into your new VPS
ssh root@203.0.113.10
# Create a non-root user (security best practice)
adduser deploy
usermod -aG sudo deploy
# Set up SSH key authentication for the new user
mkdir -p /home/deploy/.ssh
cp ~/.ssh/authorized_keys /home/deploy/.ssh/
chown -R deploy:deploy /home/deploy/.ssh
chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keys
# Basic firewall
ufw allow OpenSSH
ufw allow 'Nginx Full'
ufw enable
5. Install the LEMP Stack
LEMP stands for Linux, (E)nginx, MariaDB, PHP. This is the stack that powers the majority of high-performance websites, and it is the natural replacement for the Apache-based stack running on your shared host. The entire installation takes about 5 minutes.
# Update the system
apt update && apt upgrade -y
# Install Nginx
apt install -y nginx
# Install MariaDB (MySQL-compatible, often faster)
apt install -y mariadb-server
mysql_secure_installation
# Answer: Switch to unix_socket auth? Y
# Change root password? Y (set a strong password)
# Remove anonymous users? Y
# Disallow root login remotely? Y
# Remove test database? Y
# Reload privilege tables? Y
# Install PHP 8.3 with common extensions
apt install -y php8.3-fpm php8.3-mysql php8.3-curl php8.3-gd \
php8.3-mbstring php8.3-xml php8.3-zip php8.3-intl \
php8.3-bcmath php8.3-imagick php8.3-opcache
# Enable and start services
systemctl enable nginx mariadb php8.3-fpm
systemctl start nginx mariadb php8.3-fpm
# Verify everything is running
systemctl status nginx mariadb php8.3-fpm
One detail people miss: check the PHP version on your shared host and match it. If your site runs on PHP 8.1, install php8.1-fpm on the VPS. PHP version mismatches are the number one cause of mysterious 500 errors after migration. You can run multiple PHP versions side by side on a VPS — something shared hosting never lets you do per-directory.
6. Lower DNS TTL (Do This 24 Hours Before Migration)
This single step is the difference between a 5-minute cutover and a 24-hour anxiety spiral. I cannot stress it enough: if you remember nothing else from this guide, remember to do this one thing 24 hours before you migrate.
DNS TTL (Time To Live) tells every DNS resolver in the world how long to cache your A record. Most domains default to 86400 seconds — 24 hours. That means after you change your DNS to point to the new VPS, some visitors could still be routed to the old shared host for up to a full day. Lower the TTL to 300 seconds (5 minutes). Wait 24 hours for the old cached records to expire everywhere. Then when you change the A record IP, the whole world sees the new server within 5 minutes.
# In your DNS provider (Cloudflare, Namecheap, Route53, etc.):
# Find your A records for the domain and www subdomain
# BEFORE (typical default):
yourdomain.com. 86400 IN A old.shared.host.ip
www.yourdomain.com. 86400 IN A old.shared.host.ip
# CHANGE TTL to 300, keep the old IP for now:
yourdomain.com. 300 IN A old.shared.host.ip
www.yourdomain.com. 300 IN A old.shared.host.ip
# Verify the TTL change propagated:
dig yourdomain.com +noall +answer
# Should show TTL around 300 (it decreases as cache ages)
# NOW WAIT 24 HOURS before proceeding to the actual migration
# Set a calendar reminder. Do not skip the wait.
The most common migration mistake I see is skipping this step. People get excited, set up the VPS in an afternoon, change DNS, and then wonder why some users see the old site for hours. TTL is the mechanism. Respect it.
7. Transfer Files with rsync
This is where the move actually starts. rsync is the gold standard for server-to-server file transfer because it preserves permissions, handles interruptions gracefully, and — critically — only transfers files that have changed when you run it again. That second property is what makes the zero-downtime strategy work: do a full sync now, then a quick incremental sync right before cutover to catch any files that changed in between.
# From your VPS, pull files from the shared host:
# (Requires SSH access on the shared host)
# Create the web root
mkdir -p /var/www/yourdomain.com
# Initial full sync (this takes the longest — run it well before cutover)
rsync -avzP \
--exclude='*.log' \
--exclude='error_log' \
--exclude='.well-known/acme-challenge/' \
--exclude='wp-content/cache/' \
user@sharedhost.com:/home/user/public_html/ \
/var/www/yourdomain.com/
# Options breakdown:
# -a archive mode: preserves permissions, timestamps, symlinks, owner
# -v verbose: shows each file being transferred
# -z compress: compresses data during transfer (faster over network)
# -P progress + partial: shows transfer progress, resumes interrupted transfers
# --exclude: skip files that don't need to migrate
# After the initial sync completes, note the time.
# Right before DNS cutover, run rsync again — it only transfers changed files:
rsync -avzP \
user@sharedhost.com:/home/user/public_html/ \
/var/www/yourdomain.com/
# This second run typically takes under 30 seconds
If your shared host does not offer SSH access (some budget hosts still do not), you have two alternatives:
# Alternative 1: cPanel backup → upload to VPS
# In cPanel: Backup Wizard → Full Backup → Download
# Then from your local machine:
scp ~/Downloads/backup-yourdomain.tar.gz root@203.0.113.10:/tmp/
# On the VPS:
mkdir -p /var/www/yourdomain.com
cd /tmp && tar -xzf backup-yourdomain.tar.gz
# Navigate the extracted backup to find your public_html directory
# Copy it to the web root:
cp -a /tmp/homedir/public_html/* /var/www/yourdomain.com/
# Alternative 2: SFTP pull using lftp (handles FTP-only hosts)
apt install -y lftp
lftp -u username,password ftp.sharedhost.com -e "
mirror --parallel=4 /public_html/ /var/www/yourdomain.com/
exit
"
# After files are transferred, fix ownership:
chown -R www-data:www-data /var/www/yourdomain.com/
# Set correct permissions
find /var/www/yourdomain.com -type d -exec chmod 755 {} \;
find /var/www/yourdomain.com -type f -exec chmod 644 {} \;
# Verify file count matches (sanity check)
find /var/www/yourdomain.com -type f | wc -l
8. Migrate the Database with mysqldump
Your database is the one thing you absolutely cannot lose. Files can be re-uploaded, themes reinstalled, plugins re-downloaded. But your posts, your users, your order history, your form submissions — that data exists only in the database. Handle it carefully.
# ON THE SHARED HOST (via SSH or cPanel Terminal):
# Export a single database:
mysqldump -u dbuser -p'YourPassword' dbname \
--single-transaction \
--routines \
--triggers \
--add-drop-table \
> /tmp/dbname_backup.sql
# --single-transaction: consistent snapshot for InnoDB tables
# --routines: include stored procedures
# --triggers: include triggers
# --add-drop-table: clean import on VPS (drops existing tables first)
# Compress (saves transfer time for large databases):
gzip /tmp/dbname_backup.sql
# Check file size:
ls -lh /tmp/dbname_backup.sql.gz
# If you have multiple databases, export each one separately.
# DO NOT use --all-databases if you only need specific ones.
# Transfer the backup to VPS:
# (From VPS, pulling from shared host)
scp user@sharedhost.com:/tmp/dbname_backup.sql.gz /tmp/
# Create the database and user on the VPS:
mysql -u root -p <<'SQLEOF'
CREATE DATABASE yoursite_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'siteuser'@'localhost' IDENTIFIED BY 'StrongNewPassword!2026';
GRANT ALL PRIVILEGES ON yoursite_db.* TO 'siteuser'@'localhost';
FLUSH PRIVILEGES;
SQLEOF
# Import the database:
gunzip < /tmp/dbname_backup.sql.gz | mysql -u root -p yoursite_db
# Verify import succeeded:
mysql -u root -p yoursite_db -e "SHOW TABLES;"
# Confirm table count matches what you had on shared hosting
# For WordPress specifically, verify post count:
mysql -u root -p yoursite_db -e \
"SELECT COUNT(*) AS published_posts FROM wp_posts WHERE post_status='publish';"
For very large databases (over 1GB), consider using mysqldump with --quick and piping directly over SSH to avoid storing the dump file:
# Direct pipe: export from shared host and import to VPS in one step
ssh user@sharedhost.com "mysqldump -u dbuser -p'Password' dbname --quick --single-transaction" \
| mysql -u root -p yoursite_db
9. Configure Nginx Virtual Hosts
On shared hosting, Apache and .htaccess handled everything behind the scenes. On your VPS, you are in control of the web server configuration. That sounds intimidating but in practice it is a single config file per domain. Here is a clean starting point for a PHP site:
# Create /etc/nginx/sites-available/yourdomain.com
server {
listen 80;
listen [::]:80;
server_name yourdomain.com www.yourdomain.com;
root /var/www/yourdomain.com;
index index.php index.html;
# Logging
access_log /var/log/nginx/yourdomain-access.log;
error_log /var/log/nginx/yourdomain-error.log;
# Main location block — handles pretty URLs
location / {
try_files $uri $uri/ /index.php?$args;
}
# PHP processing via PHP-FPM
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_read_timeout 300;
}
# Security: deny access to hidden files and sensitive paths
location ~ /\. { deny all; }
location ~* /(?:uploads|files)/.*\.php$ { deny all; }
location = /xmlrpc.php { deny all; }
# Static file caching — reduces server load significantly
location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff2|svg|webp|avif)$ {
expires 30d;
add_header Cache-Control "public, no-transform";
log_not_found off;
access_log off;
}
# Gzip compression for text-based content
gzip on;
gzip_types text/plain text/css application/json application/javascript
text/xml application/xml image/svg+xml;
gzip_min_length 256;
# Upload size (match your PHP settings)
client_max_body_size 64M;
}
# Enable the site:
ln -s /etc/nginx/sites-available/yourdomain.com \
/etc/nginx/sites-enabled/yourdomain.com
# Remove the default Nginx page:
rm -f /etc/nginx/sites-enabled/default
# Test configuration (catches syntax errors):
nginx -t
# Expected: nginx: configuration file /etc/nginx/nginx.conf test is successful
# Reload Nginx:
systemctl reload nginx
If you are running multiple sites, create a separate config file for each domain. This is one of the advantages of VPS over shared hosting — you host unlimited domains at no additional cost, each with its own optimized configuration. The WordPress VPS guide has a more detailed Nginx configuration with FastCGI caching for WordPress-specific workloads.
10. Convert .htaccess Rules to Nginx
This is the step that catches people off guard. Nginx does not read .htaccess files. Period. If you have custom redirect rules, URL rewrites, or security directives in .htaccess, they need to be translated to Nginx syntax. The good news: most .htaccess rules in the wild are simple redirects that translate in a few lines.
# Common .htaccess → Nginx conversions:
# .htaccess: Redirect 301 /old-page /new-page
# Nginx:
location = /old-page { return 301 /new-page; }
# .htaccess: RewriteRule ^blog/(.*)$ /articles/$1 [R=301,L]
# Nginx:
rewrite ^/blog/(.*)$ /articles/$1 permanent;
# .htaccess: RewriteCond %{HTTPS} off
# RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
# Nginx (Certbot adds this automatically, but for reference):
server {
listen 80;
server_name yourdomain.com;
return 301 https://$host$request_uri;
}
# .htaccess: deny from all (protecting a directory)
# Nginx:
location /protected-dir/ { deny all; return 404; }
# .htaccess: Header set X-Frame-Options DENY
# Nginx:
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
For WordPress: the standard .htaccess pretty permalink rules are replaced entirely by the try_files $uri $uri/ /index.php?$args; line in the Nginx config above. You do not need to convert those manually. For complex rewrite maps, check out the security hardening guide for Nginx best practices.
11. Install SSL with Certbot
On shared hosting, SSL was probably an AutoSSL checkbox. On VPS, you get Certbot — which is honestly even easier and gives you more control. One command, free HTTPS from Let's Encrypt, automatic renewal forever. The only requirement is that DNS already points to your VPS (the HTTP-01 challenge requires the domain to resolve to the server requesting the certificate).
You have two options for timing:
- After DNS cutover: Run Certbot normally once DNS points to the VPS. HTTP will work during the brief window before SSL is configured.
- Before DNS cutover (DNS-01 challenge): If you use Cloudflare or another supported DNS provider, Certbot can verify ownership via DNS TXT record without the domain pointing to the VPS. This means SSL is ready before cutover.
# Install Certbot
apt install -y certbot python3-certbot-nginx
# Option A: After DNS cutover (most common)
certbot --nginx -d yourdomain.com -d www.yourdomain.com \
--non-interactive --agree-tos --email admin@yourdomain.com
# Certbot automatically:
# 1. Obtains the certificate from Let's Encrypt
# 2. Updates your Nginx config with SSL directives
# 3. Adds HTTP → HTTPS redirect
# 4. Configures a systemd timer for auto-renewal
# Option B: Before DNS cutover (requires Cloudflare DNS)
apt install -y python3-certbot-dns-cloudflare
# Create /root/.cloudflare-credentials with your API token
certbot certonly --dns-cloudflare \
--dns-cloudflare-credentials /root/.cloudflare-credentials \
-d yourdomain.com -d www.yourdomain.com
# Verify auto-renewal is working:
certbot renew --dry-run
# Check certificate expiry:
echo | openssl s_client -connect yourdomain.com:443 2>/dev/null \
| openssl x509 -noout -dates
12. Test Privately Before DNS Switch
This is the most satisfying step of the entire migration. You are about to see your site running on the new VPS — fast, clean, on your own infrastructure — while the rest of the world still sees the old shared host. It is like having a time machine.
# Edit your LOCAL machine's hosts file:
# macOS/Linux:
sudo nano /etc/hosts
# Add these lines (use your actual VPS IP):
203.0.113.10 yourdomain.com
203.0.113.10 www.yourdomain.com
# Windows (run Notepad as Administrator):
# Edit C:\Windows\System32\drivers\etc\hosts
# Add the same two lines
# Save. Now open yourdomain.com in your browser.
# You are hitting the VPS. Everyone else still sees shared hosting.
Test checklist — do not skip any of these:
- Homepage loads correctly (check all images, CSS, JavaScript)
- Internal pages and blog posts load
- Contact forms submit successfully
- Login pages work (admin, user accounts)
- Search functionality works
- E-commerce checkout flow (if applicable)
- Mobile view renders correctly
- Check browser console for JavaScript errors or mixed content warnings
# Also test from the command line:
# Check HTTP response:
curl -I http://203.0.113.10 -H "Host: yourdomain.com"
# Expected: HTTP/1.1 200 OK (or 301 if SSL redirect is active)
# Measure TTFB on the new server:
curl -o /dev/null -s -w "TTFB: %{time_starttransfer}s\n" \
http://203.0.113.10 -H "Host: yourdomain.com"
# Compare this to shared hosting TTFB — prepare to be impressed
# Check PHP is working:
php -v # On VPS, verify version matches expectation
# Verify database connectivity:
mysql -u siteuser -p yoursite_db -e "SELECT 1;"
# IMPORTANT: Remove the hosts file entry after testing!
# Leaving it in place means YOUR machine always hits the VPS
# regardless of DNS — which defeats the purpose of monitoring.
13. The DNS Cutover (5 Minutes)
Everything is tested. The VPS is ready. Your TTL has been at 300 seconds for 24 hours. This is the moment. And it is going to be anticlimactic in the best way possible.
# Step 1: Do one final rsync to catch any files changed since initial sync
rsync -avzP user@sharedhost.com:/home/user/public_html/ \
/var/www/yourdomain.com/
# Step 2: Do a final database sync if data has changed
# (For dynamic sites with new posts/orders since the initial dump)
# On shared host: mysqldump again, transfer, import
# For WordPress with low traffic: this is often unnecessary
# Step 3: Change DNS A records to VPS IP
# In your DNS provider's dashboard:
# yourdomain.com. 300 IN A 203.0.113.10 ← new VPS IP
# www.yourdomain.com. 300 IN A 203.0.113.10 ← new VPS IP
# Step 4: Monitor propagation
dig yourdomain.com @8.8.8.8 +short # Google DNS
dig yourdomain.com @1.1.1.1 +short # Cloudflare DNS
dig yourdomain.com @9.9.9.9 +short # Quad9
# All three should return 203.0.113.10 within 5 minutes
# (because TTL was already 300 seconds)
# Step 5: Once propagated, install SSL (if using HTTP-01 method)
certbot --nginx -d yourdomain.com -d www.yourdomain.com \
--non-interactive --agree-tos --email admin@yourdomain.com
Do the cutover during a low-traffic window. Early morning in your audience's primary timezone is ideal. And keep the shared hosting account alive for at least 2 weeks afterward. It is your safety net, and the $5-15/mo is cheap insurance while you verify everything is stable on the VPS.
14. Post-Migration Verification Checklist
The DNS has flipped. Traffic is flowing to the VPS. Do not close the laptop yet. Take 15 minutes to run through these checks. I keep this as a script I run after every migration:
# 1. Verify HTTPS is working:
curl -I https://yourdomain.com
# Expected: HTTP/2 200, server: nginx
# 2. Verify HTTP redirects to HTTPS:
curl -I http://yourdomain.com
# Expected: HTTP/1.1 301, Location: https://yourdomain.com/
# 3. Check SSL certificate validity:
echo | openssl s_client -connect yourdomain.com:443 2>/dev/null \
| openssl x509 -noout -dates -subject
# Verify dates and subject match your domain
# 4. Measure TTFB (the payoff):
for i in {1..5}; do
curl -o /dev/null -s -w "TTFB: %{time_starttransfer}s\n" \
https://yourdomain.com
done
# On a good VPS: under 200ms. Compare to old shared hosting TTFB.
# 5. Verify no redirect loops:
curl -L -I https://yourdomain.com | grep -E "HTTP|Location"
# 6. Check for mixed content (HTTP resources on HTTPS page):
curl -s https://yourdomain.com | grep -i "http://" | grep -v "https://"
# 7. Check Nginx error log for issues:
tail -50 /var/log/nginx/yourdomain-error.log
# Empty or only minor notices = success
# 8. Verify cron jobs are running (if you had any):
crontab -l
systemctl list-timers
# 9. Set up basic monitoring immediately:
# UptimeRobot (free): uptimerobot.com — add HTTP monitor for your domain
# This catches outages you would otherwise miss
Also check Google Search Console over the next few days. Confirm the crawl rate is normal and no new errors appeared. The DNS change is invisible to Googlebot if your TTL strategy was correct — there should be no crawl disruption.
15. The Rollback Plan
I have never needed to use this on a properly prepared migration, but I always have it ready. The rollback is simple, fast, and requires zero technical heroics. Your old shared hosting account is still running, completely untouched, with all your data intact.
# If something is broken on the VPS and you can't fix it quickly:
# Step 1: Change A record back to old shared hosting IP
# yourdomain.com. 300 IN A old.shared.host.ip
# Step 2: With 300s TTL, traffic returns to shared hosting in ~5 minutes
# No data is lost. The shared host has been serving traffic all along.
# Step 3: Diagnose and fix the issue on the VPS
# Common problems:
# - 500 errors: PHP version mismatch or missing extension
# Fix: apt install php8.x-{missing-extension}
# - 403 errors: Wrong file permissions
# Fix: chown -R www-data:www-data /var/www/yourdomain.com/
# - Database connection error: Wrong credentials in config
# Fix: Update wp-config.php or .env with VPS database credentials
# - SSL errors: Certificate not yet issued
# Fix: Wait for DNS to point to VPS, then run certbot
# Step 4: Re-test, re-cutover when ready
The peace of mind this provides is worth more than any technical detail in this guide. You are not burning bridges. You are building a new bridge and testing it before walking across.
16. Before and After: Real Numbers from Real Migrations
I keep a spreadsheet of migration results. Here are five actual migrations from the past year, anonymized but with real performance data:
| Site Type | Shared TTFB | VPS TTFB | Shared Cost | VPS Cost | VPS Provider |
|---|---|---|---|---|---|
| WordPress blog (800 posts) | 940ms | 118ms | $14.99/mo | $12/mo | Vultr |
| WooCommerce (2K products) | 2,300ms | 340ms | $24.99/mo | $18/mo | Kamatera |
| Laravel app | 650ms | 95ms | $9.99/mo | $6/mo | Hetzner |
| Static + PHP (portfolio) | 420ms | 42ms | $7.99/mo | $4/mo | Kamatera |
| Multi-site WP (3 domains) | 1,100ms | 165ms | $19.99/mo | $12/mo | Vultr |
Every single migration resulted in lower TTFB and lower or equal cost. The WooCommerce migration was the most dramatic — 2.3 seconds to 340ms because the shared host was throttling MySQL connections during checkout flows. On the VPS, the database had its own resources and connection pooling worked as designed.
The performance improvement is not magic. It is what happens when your web server, database, and PHP processes are not fighting 200 other sites for CPU time. You are getting the performance you were always supposed to get. Shared hosting was just stealing it from you.
For more on squeezing additional performance from your new VPS, see the performance tuning guide — sysctl tweaks, MySQL buffer sizing, and OPcache configuration that can shave another 30-50ms off those numbers. And if you are migrating WordPress specifically, the WordPress-to-VPS migration guide covers WP-CLI, Redis object caching, and the Nginx FastCGI cache that together make WordPress genuinely fast.
Frequently Asked Questions
How do I test my site on the new VPS before changing DNS?
Edit your local hosts file to point your domain to the new VPS IP. On macOS/Linux: sudo nano /etc/hosts and add 203.0.113.10 yourdomain.com www.yourdomain.com. On Windows: edit C:\Windows\System32\drivers\etc\hosts (requires Administrator). Your browser will hit the VPS while everyone else still sees shared hosting. Remove the entry after confirming the migration and completing DNS cutover.
What DNS TTL should I set before migrating?
Lower your A record TTL to 300 seconds (5 minutes) at least 24 hours before migration. This ensures old cached records expire from resolvers worldwide. When you change the A record IP, propagation completes in about 5 minutes instead of up to 24 hours. After migration is confirmed stable (give it a few days), raise TTL back to 3600 (1 hour) or higher to reduce DNS query load.
My shared host does not allow SSH — how do I transfer files?
Use cPanel's File Manager to create a zip or tar.gz archive of your entire site, download it to your local machine, then upload to the VPS with scp. Alternatively, use SFTP credentials from cPanel with FileZilla or lftp on the VPS. For large sites, the compressed archive method is fastest. Some hosts also support the sftp protocol even without full SSH shell access — check with your provider.
How do I migrate email when leaving shared hosting?
Two paths. External email service (recommended): Switch to Google Workspace ($7.20/mo) or Zoho Mail (free for up to 5 users), update MX records, and migrate existing mail via IMAP sync. Self-hosted: Install Mailcow or iRedMail on the VPS, update MX records, and use imapsync to transfer emails. Most people choose external email because self-hosting mail is an ongoing maintenance burden. Update MX records independently of A records.
How long does a shared-to-VPS migration actually take?
For a typical site under 5GB with one database: 2-3 hours total, including VPS provisioning, LEMP stack setup, file transfer, database migration, Nginx configuration, SSL, testing, and DNS cutover. The DNS TTL lowering adds a 24-hour advance preparation step. Large sites (>10GB, multiple databases, complex configurations) can take 4-6 hours. The actual user-facing disruption is under 5 minutes with proper TTL preparation.
Do I need to convert .htaccess rules to Nginx format?
Yes. Nginx does not read .htaccess files. Common conversions: RewriteRule becomes Nginx rewrite or try_files, redirect rules become return 301 statements, and header directives become add_header lines. For WordPress, the standard pretty permalink .htaccess rules are replaced by a single try_files $uri $uri/ /index.php?$args; line. Tools like winginx.com/en/htaccess help with automated conversion of simple rules.
What if my WordPress database credentials are different on the VPS?
Update wp-config.php with the new credentials:
define('DB_NAME', 'new_database_name');
define('DB_USER', 'new_username');
define('DB_PASSWORD', 'new_password');
define('DB_HOST', 'localhost');
If you changed domains, also run: wp search-replace 'oldsite.com' 'newsite.com' --all-tables. This handles serialized data that a SQL find-replace would corrupt. Verify with wp option get siteurl and wp option get home.
Should I use Apache or Nginx on the VPS?
Nginx for almost everyone. It uses less RAM per connection (critical on VPS where memory is limited), handles more concurrent connections, and serves static files faster. The only scenario where Apache makes sense on a VPS is if you have complex, heavily-tested .htaccess rules that would be risky to convert, or you depend on Apache-specific modules like mod_security. Most WordPress, Laravel, and general PHP sites run better on Nginx with PHP-FPM.
What is the cheapest VPS that can replace shared hosting?
Kamatera starts at $4/mo (1 vCPU, 1GB RAM), Hetzner at $4.59/mo (2 vCPU, 4GB RAM in Ashburn), and RackNerd frequently runs promotions under $3/mo. For a comfortable migration with headroom, I recommend the 2GB RAM tier at $6-12/mo. Even the cheapest VPS tier dramatically outperforms shared hosting because the resources are dedicated to your site alone. Use the VPS calculator to estimate what your site needs.