SSD Nodes Learn Hosting plans →
Guides Matt ConnorBy Matt Connor

Moving from shared hosting to a VPS

Move off shared hosting without breaking the site: the inventory cPanel was hiding, a rehearsed cutover, low DNS TTLs, and why mailboxes stay put.

What changes when you move from shared hosting to a VPS

Moving from shared hosting to a VPS keeps your site exactly as it is and hands you the jobs the control panel was quietly doing behind it: the PHP version and its extensions, the TLS (transport layer security) certificate and its renewal, the cron jobs, the backups, the spam filtering, the mailboxes. None of your site's code has to change. What changes is that each of those background jobs now has an owner, and the owner is you.

The safe order is boring, and it works. Build the stack on the new server. Copy the files and the database. Test the new server by IP address before any DNS record moves. Lower the DNS TTL a day ahead. Switch the record. Then leave the old account running for a week while you read logs. If you are still deciding whether the move is worth making, the real trade between shared hosting and a VPS answers that question. This guide assumes you have decided.

The inventory nobody makes

cPanel and DirectAdmin hide a working server behind a set of forms. Before you can rebuild that server, you have to write down what those forms were configuring. Do it while the old account is still alive, because most of it is unreadable once the account is gone.

  • The PHP version, and the list of loaded extensions.
  • The database engine and version, plus every database name, user and password.
  • Every cron job: its schedule and its exact command line.
  • The TLS certificate: who issued it, when it expires, and whether the panel was renewing it.
  • Where the DNS zone is hosted, and which nameservers the domain uses.
  • Every mailbox, forwarder and autoresponder on the domain.
  • Every .htaccess file, including the ones in subdirectories.
  • The PHP limits the panel set for you: upload_max_filesize, post_max_size, memory_limit, max_execution_time.

Most of that is one SSH session on the old host, if your plan gives you shell access.

php -v
php -m
crontab -l
find ~/public_html -name .htaccess
dig NS example.com +short
mysql -u olduser -p -e 'SHOW DATABASES;'

With no shell access, upload a one line file to read the PHP configuration through the web.

<?php phpinfo();

Load it once in a browser, write down the PHP version and the Loaded Configuration File path, then delete it. A phpinfo page left online publishes your exact PHP build and the real paths on the box to anyone who finds it.

Pay attention to the nameserver line. If dig NS example.com +short returns your web host's own nameservers, the DNS zone lives inside the account you are planning to cancel. Cancelling takes the zone with it, and the domain then resolves to nothing no matter how ready the new server is. Move DNS to a provider that is not your web host before you touch anything else, and give the change a day to settle. how a resolver actually finds your records covers what that move is doing underneath.

Build the new stack before you touch DNS

Install the same major PHP version the old host was running. Matching it removes a whole class of failure from cutover day. You can upgrade PHP a week later, when the version is the only thing changing. As of September 2026, Ubuntu 24.04 LTS ships PHP 8.3 in its own repositories. For an older version, add the ppa:ondrej/php archive and install from there.

sudo apt update
sudo apt install -y nginx mariadb-server php8.3-fpm php8.3-mysql \
  php8.3-curl php8.3-gd php8.3-mbstring php8.3-xml php8.3-zip php8.3-intl
sudo mysql_secure_installation

Compare php -m on the new box against the list from your inventory. A missing extension does not announce itself. It shows up later as one broken feature, usually image resizing (gd) or a payment integration (curl).

This guide assumes the box is already yours: a login that is not root, an SSH key, a firewall. the first ten minutes on a new VPS covers that groundwork so this page does not have to repeat it.

Now the site itself. Replace deploy with your own login name and example.com with your domain.

server {
    listen 80;
    server_name example.com www.example.com;
    root /srv/example.com/public;
    index index.php;

    client_max_body_size 64m;

    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    }
}

Save that as /etc/nginx/sites-available/example.com, link it into sites-enabled, then run sudo nginx -t and sudo systemctl reload nginx. nginx -t printing syntax is ok and test is successful is the check. A reload with a broken config leaves the old config running, so a site that stops changing when you edit it usually means you skipped the test.

.htaccess is an Apache file, and nginx never reads it. Every rewrite and access rule your old host applied through .htaccess stops applying the moment nginx serves the site. The try_files line above replaces the standard WordPress rewrite block. Anything else in those files, hotlink protection or IP blocks, has to be rewritten as nginx directives. If the site carries a long generated .htaccess you cannot read, install apache2 with libapache2-mod-php8.3 instead and set AllowOverride All on the directory. The rules then keep working unchanged, which is worth more than nginx's edge in throughput on a site this size.

Copy the files

sudo install -d -o deploy -g www-data /srv/example.com/public
rsync -avzn olduser@oldhost.example.com:public_html/ /srv/example.com/public/
rsync -avz  olduser@oldhost.example.com:public_html/ /srv/example.com/public/

The first command with -n is a dry run: it lists what would move and copies nothing. Run it once, read the file count, then drop the -n. The trailing slash on the source matters. public_html/ copies the contents of that directory. public_html without the slash copies the directory itself into the target, so you end up with the site one level too deep and a 403 from nginx.

On shared hosting, PHP ran as your account user and owned every file, so permissions rarely came up. On the VPS the web server runs as www-data and your login is somebody else, which means ownership now decides what the site can read and write.

sudo chown -R deploy:www-data /srv/example.com/public
sudo find /srv/example.com/public -type d -exec chmod 750 {} +
sudo find /srv/example.com/public -type f -exec chmod 640 {} +
sudo chmod -R g+w /srv/example.com/public/wp-content/uploads

That gives www-data read access everywhere and write access only where uploads land. Do not shortcut it with chmod -R 777, and do not give www-data write access to the whole tree. A plugin flaw that can write a .php file into a directory the web server also serves is a shell on your box, which is the difference between a defaced page and a rebuilt server.

Move the database

Dump from the old host, transfer, then load.

mysqldump --single-transaction --quick --default-character-set=utf8mb4 \
  -u olduser -p olddb > site.sql

--single-transaction takes a consistent snapshot of InnoDB tables without locking the running site. It does nothing for MyISAM tables, so if the old site still has any (SHOW TABLE STATUS names the engine per table), put the site into maintenance mode for the dump or accept that a write during it may be lost.

CREATE DATABASE sitedb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'siteuser'@'localhost' IDENTIFIED BY 'a long random password';
GRANT ALL PRIVILEGES ON sitedb.* TO 'siteuser'@'localhost';
FLUSH PRIVILEGES;
mysql -u root -p sitedb < site.sql
mysql -u root -p -e 'SELECT COUNT(*) FROM sitedb.wp_posts;'

Compare that row count against the same query on the old host. Matching counts mean the load finished. A load that stopped part way still leaves a database full of tables, so "the tables are there" proves nothing on its own.

One error you will probably meet: a dump taken from MySQL 8 loaded into MariaDB stops with ERROR 1273 (HY000) at line 25: Unknown collation: 'utf8mb4_0900_ai_ci'. That collation is MySQL 8's default and MariaDB does not carry it, so the CREATE TABLE statement is rejected. Rewrite it in the dump before loading, with sed -i 's/utf8mb4_0900_ai_ci/utf8mb4_unicode_ci/g' site.sql, then load again into a freshly created database.

Last, point the application at the new database. In WordPress that is DB_NAME, DB_USER, DB_PASSWORD and DB_HOST in wp-config.php, with DB_HOST set to localhost. Because the domain is not changing, you do not need a search and replace across the database. Keep it that way. Change the domain and the server in the same step and you will not be able to tell which one broke the site.

Test against the new IP with a hosts entry

Your computer can be told to resolve the domain to the new server while the rest of the world still goes to the old one. Add this line to /etc/hosts on Linux or macOS, or to C:\Windows\System32\drivers\etc\hosts on Windows opened as administrator, using your server's real IP.

203.0.113.10 example.com www.example.com

For a check with no editing at all, curl can override resolution for one request.

curl -sI --resolve example.com:80:203.0.113.10 http://example.com/

HTTP/1.1 200 OK is what you want. The failures each have one cause. 502 Bad Gateway means nginx cannot reach PHP-FPM, so compare the fastcgi_pass socket path against ls /run/php/, which will show php8.3-fpm.sock if the version in your config is wrong. A 404 on every page except the homepage is the .htaccess problem: nginx has no rewrite rule, so /about/ never reaches index.php. 413 Request Entity Too Large on an upload is nginx's client_max_body_size, which defaults to 1 megabyte and is separate from PHP's own upload_max_filesize.

With the hosts entry in place, use the site properly. Log in to the admin, load a page with images, submit a form, upload a file, place a test order. This is the rehearsal, and it costs you nothing because the live site has not moved. Remove the hosts line when you are finished, or you will spend an hour next month wondering why your browser sees a different site than everyone else.

Get the certificate before you switch

The HTTP-01 challenge, which certbot uses by default, asks the certificate authority to fetch a file over port 80 at the real domain name. That only works once DNS already points at the new server, which leaves a window where visitors reach a box with no certificate and get a browser warning.

There are two honest ways around it. Issue the certificate ahead of the cutover with a DNS-01 challenge, which proves control by publishing a TXT record instead of serving a file: issuing a certificate with the DNS-01 challenge walks through the provider plugins. Or accept a short window, cut over, and run certbot within a minute or two while leaving the HTTP to HTTPS redirect switched off until the certificate exists.

sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
sudo certbot renew --dry-run
systemctl list-timers 'certbot*'

Renewal is exactly the kind of work the panel was doing without telling you. Two checks say it will keep happening: the dry run ending in Congratulations, all simulated renewals succeeded, and certbot.timer appearing in systemctl list-timers with a next run date. A certificate that expires 89 days after a migration is one of the most common ways these moves go wrong, because by then nobody connects the outage to the move.

Lower the DNS TTL a day before the switch

The TTL (time to live) on your A record tells every resolver on the internet how long it may keep the answer it already has. If the TTL is 86400 seconds, a resolver that looked up your name one minute before you changed it can serve the old IP for another 24 hours. That visitor is writing comments and orders into the old database while you watch a quiet log on the new server.

Ask the authoritative nameserver, because a normal dig shows you whatever is left of a cached copy.

dig @$(dig +short NS example.com | head -1) example.com A +noall +answer

The number in the fourth column is the TTL in seconds. Set it to 300 at least one full old TTL period before the cutover, so 24 hours ahead if it was a day. The lowered value has to propagate before it can help you. A few days after the move, raise it back to 3600 or so: a permanent 300 second TTL means every resolver in the world re-queries your nameservers every five minutes, and it slows down the first visit for anyone with a cold cache.

The cutover, step by step

  1. Put the old site into maintenance or read-only mode if it takes orders, comments or signups. Writes that land on the old database after this point are writes you will lose.
  2. Run rsync again, this time with --delete, to pick up whatever changed since the first copy and to remove anything you deleted on the new side by mistake.
  3. Dump and reload the database again. This second dump is the one that goes live.
  4. Change the A record to the new IP, and the AAAA record too if you have one. Leave the MX records alone.
  5. Watch it take effect with dig example.com +short from a machine that has not cached the old answer, or from a phone on mobile data.
  6. Load the site with no hosts entry, click the padlock, and confirm the certificate covers both the bare domain and the www name.

The second copy is quick because the first one moved the bulk. That is the entire reason for copying twice, and it is what keeps the write-freeze down to minutes instead of hours.

Resist the urge to tune anything on cutover day. Get the site serving exactly as it did before, then change one thing at a time afterwards. Performance work belongs in its own week, when a slow page can only have one new cause: a Redis object cache for WordPress is a good first change once the migration itself is boring.

Mail is a separate project, and most readers should skip it

Moving a website is routine work with visible failures. Moving mailboxes is a different project, and its failure is silent. Mail does not bounce with a red error. It lands in other people's spam folders, and you learn about it three weeks later from a customer who says they replied and heard nothing.

Running your own mail server means owning SPF, DKIM and DMARC records, reverse DNS on the VPS IP address, a spam filter that has to work in both directions, storage for the mailboxes, and a sending reputation on a fresh IP that starts at zero. Many VPS providers also block outbound port 25 by default, so the very first message never leaves the box. whether self-hosting email is still worth it in 2026 goes through that honestly. For a website migration the answer is simpler: mail should not move on the same day as the site, and for most readers it should not move at all.

So leave the MX records exactly where they are. If your mailboxes live on the shared host, keep a small mail-only plan there, or move them to a dedicated mail provider on a completely separate schedule. Two changes at once means an inbox problem and a website problem you cannot tell apart.

The site still needs to send mail, though: password resets and order confirmations. On shared hosting PHP's mail() worked because a mail server was already running on the same machine. A fresh VPS has none, so mail() returns false and WordPress reports The email could not be sent. Possible reason: your host may have disabled the mail() function. Do not fix that by installing a mail server. Send through an authenticated SMTP relay instead, your mail provider's submission service on port 587, configured with an SMTP plugin in WordPress or with msmtp set as the system sendmail. Then make sure that relay is listed in your domain's SPF record, because a message sent from a host the SPF record does not cover is the exact thing receiving servers file as spam.

Watch the logs for a week before you cancel the old account

Keep the old hosting account alive for at least a week after the switch. It is the cheapest rollback you will ever buy. If something turns out to be badly broken, you point the A record back and you are running on a server that still works, with a five minute TTL making the return fast.

sudo tail -f /var/log/nginx/error.log
awk '$9 == 404 {print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20

The 404 list is the one that finds real damage. It shows the paths the old server was serving and the new one is not, which almost always means an .htaccess rewrite you have not translated, or a directory rsync skipped because it sat outside public_html.

PHP fatal errors will not appear in the browser, because display_errors is off in Ubuntu's production php.ini. They travel to the nginx error log through FastCGI, as lines beginning FastCGI sent in stderr: "PHP message: PHP Fatal error: Uncaught Error: Call to undefined function ...". That is what a version jump from PHP 7.4 to 8.3 looks like from outside: a blank page or an HTTP 500, with the actual cause visible only in the log.

Your cron jobs do not move themselves. Recreate each one from the inventory, using absolute paths for both the interpreter and the script, because cron runs with a minimal PATH and none of your shell profile.

sudo crontab -u www-data -e
# */5 * * * * /usr/bin/php /srv/example.com/public/wp-cron.php >/dev/null 2>&1
journalctl -u cron --since today

journalctl showing a CMD line every five minutes means the schedule is live. For WordPress specifically, that cron line only helps once you have also switched off the built in scheduler, and replacing WP-Cron with a real system cron job explains why the default fires on page views and stops firing on a quiet site.

What the panel used to absorb, and now costs you

The shared hosting bill covered labour as well as disk space. That labour did not disappear when you moved. It changed owner.

  • Patching. sudo apt update && sudo apt upgrade on a schedule you actually keep, plus unattended-upgrades so security updates land without you. An unpatched VPS is the most common way a small site gets defaced, and nothing on the box will remind you.
  • Backups. A provider snapshot is a disk image held by the same provider on the same account. Treat it as a convenience, not a backup. Take a mysqldump and a file archive to storage somewhere else, then restore one into a scratch directory before you need it. An untested backup is a guess.
  • Monitoring. Nothing tells you the site is down. An external uptime check and an alert on free disk space cover the two failures that take sites offline quietly, because a full disk stops MariaDB writing and the site returns errors while the server itself looks healthy.

If you want the panel experience back on your own hardware, you can install one: the control panels worth running on a VPS compares the options. A panel takes the daily clicking off your plate. It does not take responsibility for the machine, and it becomes one more thing to patch.

Once this move is behind you, the next one is much shorter. Server to server migration skips the whole inventory step, because you wrote the configuration yourself and it is sitting in files you can read: moving a running server to a new VPS is the compressed version of this process for that case.

FAQ

How long does the DNS change take to reach everyone?

It depends entirely on the TTL your A record carried before you edited it, not on when you made the change. A resolver that cached the old answer keeps serving it until that TTL expires, so a record published with a 24 hour TTL can send visitors to the old server for a full day. Lower the TTL to 300 seconds at least one old-TTL period ahead, and the switch then completes for almost everyone within five minutes. Check what you are actually publishing with dig @$(dig +short NS example.com | head -1) example.com A +noall +answer.

Should I move my email to the VPS at the same time as the site?

No. Leave the MX records pointing where they point today and move only the website. Self-hosted mail brings SPF, DKIM, DMARC, reverse DNS and a brand new sending reputation, and its failure mode is silent delivery into spam folders rather than a visible error. Keep the mailboxes on a mail-only plan at the old host or on a dedicated mail provider. Your site can still send password resets through an authenticated SMTP relay on port 587, which needs no mail server on the VPS.

Why do all my pages except the homepage return 404 after the move?

Because the rewrite rules lived in .htaccess, and nginx does not read .htaccess files at all. On Apache those rules sent every unmatched path to index.php, which is how pretty permalinks work. Under nginx the request for /about/ looks for a real file, does not find one, and returns 404. Add try_files $uri $uri/ /index.php?$args; to the location / block, run sudo nginx -t, then reload. If the site relies on many other .htaccess rules you cannot easily translate, install Apache with AllowOverride All instead and they keep working unchanged.

Can I switch back if the new server turns out to be broken?

Yes, as long as you kept the old account alive and the TTL low. Point the A record back to the old IP and traffic returns within the TTL window. The limit is data, not DNS: anything written on the new server after cutover, new orders, new comments, new uploads, does not exist on the old one, and rolling back abandons it. That is why you freeze writes during the cutover and why you check the site within minutes rather than days. After about a week of clean logs, the rollback stops being realistic and you can cancel the old plan.