LINESERVE
All blog

Migrating a Site from Shared cPanel Hosting to a VPS

Move your site off shared cPanel hosting onto a VPS without downtime: test the new server before DNS moves, then cut over in minutes.

ArticleStephen NdegwaPublished Last updated 27 min read
Share

Shared hosting is fine until the day it isn’t. The site gets slower at the exact hours it matters, a plugin needs a PHP extension the host won’t enable, or a neighbour on the same box eats the CPU you’re paying for. Moving to a VPS fixes all of that. The hard part is doing it without your visitors ever seeing a broken page.

Here is the whole job, in order:

  1. Take stock of the old account, and lower your DNS TTL a day ahead.
  2. Download two files from cPanel: your site’s files, and its database.
  3. Build the stack on the VPS — web server, database, PHP.
  4. Put the files and the database in place, and point the site’s config at the new database.
  5. Test the site on the new server while DNS still points at the old one.
  6. Cut DNS over, then turn on HTTPS.

Step 5 is what makes this safe, and it is the step most migration guides skip. You can load your real domain, served by the new server, on your own computer, before a single visitor is affected. If something is broken you fix it with the old site still happily serving everyone else. Only when the new server is genuinely correct do you touch DNS — and then the switch is a two-minute change, not a leap of faith.

The walkthrough moves a WordPress site, because that’s what most shared cPanel accounts are running. The server build — steps 4, 6, 7 and 10 — is the same for any PHP application; the database, config and permalink steps are WordPress-specific. Version numbers and file sizes below come from Ubuntu 24.04; yours will differ.

What you give up: the control panel

On a plain VPS there is no cPanel. It is the first thing you notice, and nobody took it away from you: it was never there, and a cPanel licence is a recurring per-server cost you would be adding on top of the server itself.

What replaces each thing you used it for:

In cPanel On the VPS
File Manager scp and a text editor over SSH (steps 6 and 9)
phpMyAdmin the mariadb command line (steps 5 and 8)
Addon Domains one Apache virtual host file per site (step 10)
SSL/TLS Status certbot, which also renews for you (step 13)
Backup Wizard your own scheduled job, which nobody sets up for you

If you would rather keep a graphical panel, Webmin/Virtualmin and CyberPanel are free and install onto a bare Ubuntu box. The plain route below is fewer moving parts, and every command in it is one you can read and understand.

Before you start

You need:

  • A cPanel account you can log into, with the Backup Wizard available. Most shared hosts include it; a few disable it, in which case you will be downloading files over FTP instead and your host’s support can generate the database dump for you.
  • A VPS running Ubuntu 24.04, with its IP address, and a user you can log in as who can run sudo. If you have not created one yet, Getting Started with Lineserve Cloud: Launch Your First VPS covers ordering the server, and Initial Ubuntu 24.04 Server Setup: Users, SSH, Firewall covers creating that user and getting SSH keys working. Do those first: everything below starts from a working SSH login.
  • Access to wherever your domain’s DNS is managed. That is usually your domain registrar, sometimes Cloudflare, sometimes the shared host itself. Find out which before you start, not during the cutover.
  • An appetite for a bit of administration. A VPS hands you the whole machine, which also means nobody else is patching it or keeping intruders out. 8 Essential Security Steps for Every Production VPS is the short version of what that costs you.
  • A terminal. macOS and Linux have one built in; on Windows, use PowerShell or Windows Terminal, both of which include ssh and scp.

SSH is the encrypted connection you use to run commands on a server you do not have sitting in front of you. sudo is the prefix that runs a single command with administrator rights; you will type it a lot. DNS is the system that turns example.com into an IP address, and the A record is the specific entry that holds that address.

Which Ubuntu, and how big

Ubuntu 26.04 LTS has been the current long-term release since 23 April 2026, and 24.04 is supported until May 2029. Either is a reasonable choice and the steps below are identical on both. The difference that matters is the PHP version each one carries, which step 1 comes back to. On 26.04 the version numbers you see will read higher than the ones below.

For size, the figure to look up is on your cPanel dashboard: disk usage is what you are actually moving. A WordPress install with a theme and a modest number of posts takes about 2.7 GB including Ubuntu itself and runs comfortably in 2 GB of RAM.

What the examples stand for

Substitute your own values for these throughout:

What it is The value used below Where yours comes from
Your domain example.com Your own domain name
Your VPS’s IP address 98.84.122.189 Your VPS provider’s control panel
The user you log into the VPS as ubuntu Whatever you named it during server setup
Your cPanel username janedoe Top-right of the cPanel dashboard
The files archive from cPanel backup-7.19.2026_janedoe.tar.gz Whatever cPanel names your download
The database dump from cPanel janedoe_wp.sql Whatever cPanel names your download
New database name example_wp You choose it, in step 5
New database user example_wpuser You choose it, in step 5
New database password Kx7#mBv2qLp9 You generate it, in step 5

Step 1: Write down what the old account is running

In cPanel, open MultiPHP Manager and note the PHP version your domain is set to.

[SCREENSHOT: cPanel MultiPHP Manager, showing the domain list with the PHP version dropdown for the site being migrated]

Ubuntu 24.04 ships PHP 8.3 and 26.04 ships 8.5, so unless your host is unusually current you are moving up. Current WordPress and most themes handle that fine; an abandoned plugin may not. If the old account is on something older than 8.0, be thorough at step 11 — old plugins are where a version jump shows up, and step 11 is your last look before visitors see it.

Then open phpMyAdmin, which sits in cPanel’s Databases section, and note the name of your site’s database. On cPanel it is nearly always your account username, an underscore, and a short suffix — janedoe_wp in this walkthrough.

[SCREENSHOT: cPanel phpMyAdmin, with the left-hand database list showing the site’s database name]

Step 2: Lower your DNS TTL, a day early

TTL (time to live) is how long the rest of the internet is allowed to remember your domain’s current IP address. If it is set to 14400, a visitor’s internet provider can keep sending them to your old shared host for four hours after you change the record.

Go to wherever your DNS is managed, find the A record for your domain, and change the TTL to the lowest value offered — 300 seconds is typical.

[SCREENSHOT: DNS management panel showing the A record for the domain with the TTL field being changed to 300]

Lowering the TTL does not take effect immediately: resolvers keep honouring the old TTL they already cached. Change it, then ask a resolver straight away what it thinks. dig reads DNS records; it does not ship by default, so on macOS install it with brew install bind, and on Windows use Resolve-DnsName example.com -Type A instead. (On the VPS it arrives with the step 4 install line.)

dig +noall +answer example.com

Still 3600, still the old address. The example server answers to a test domain of its own, so read migrate.lab.lineserve.dev as example.com wherever it shows up in output below — it is never a name to type:

migrate.lab.lineserve.dev. 3600 IN  A   192.0.2.1

The new, shorter TTL only starts applying once the old one has run down. So lower it a day before you plan to cut over, and by the time you do, the internet will forget the old address in five minutes instead of four hours.

Step 3: Download the two backups from cPanel

cPanel’s Backup Wizard offers a full backup and several partial ones. Take the partial ones — the two you actually need. A full backup is a single large archive that cannot be restored from within cPanel anyway, and you would only be extracting these same two things out of it.

In cPanel, open Backup Wizard and click Back Up.

[SCREENSHOT: cPanel Backup Wizard opening screen, with the Back Up and Restore options]

Choose Partial Backup, then click Home Directory. The download starts immediately and arrives as a .tar.gz archive — every file in your hosting account, including public_html, which holds the site itself.

[SCREENSHOT: cPanel Backup Wizard partial backup screen, showing the Home Directory, MySQL Databases and Email Forwarders and Filters download buttons]

On the same screen, under MySQL Databases, click the name of your site’s database. That downloads a .sql file. Some hosts compress it and hand you a .sql.gz instead; if yours does, note it now, because step 8 has one extra line for you.

You should now have two files in your Downloads folder. Keep them there for the moment.

Step 4: Install the stack on the VPS

Open a terminal on your own computer and connect to the server, substituting your user and your server’s IP:

ssh [email protected]

The very first time you connect to any new server, SSH stops to ask whether you trust it:

The authenticity of host '98.84.122.189 (98.84.122.189)' can't be established.
ED25519 key fingerprint is SHA256:Ao68gz1pl9kxgpvwH69BCLxUK3t51zsIvEkrk7yFujA.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])?

Type the word yes in full and press Enter — y alone will not do. You will only see this once per server.

Ubuntu then greets you with a release line, a summary of the machine’s load and disk, and finally a prompt:

Welcome to Ubuntu 24.04.4 LTS (GNU/Linux 6.17.0-1019-aws x86_64)
...
  System load:  0.5               Processes:             119
  Usage of /:   30.7% of 6.71GB   Users logged in:       0
...
ubuntu@web-vps:~$

That trailing $ is the shell prompt, and everything from here until step 9 is typed at it.

Shared hosting handed you Apache, MySQL and PHP already running. On a VPS you install them yourself — that is the trade you made for control:

sudo apt update
sudo apt install -y apache2 mariadb-server php libapache2-mod-php php-mysql php-curl php-gd php-xml php-mbstring php-zip nano dnsutils

The first time you use sudo in a session it may ask for your own login password, and it deliberately prints nothing at all as you type — no dots, no stars. Type it blind and press Enter. (If your server uses SSH keys with passwordless sudo, you will not be asked; that is normal too.) Then, part-way through the install, Ubuntu may throw up a full-screen purple dialog asking which services to restart. The pre-selected answers are correct — press Enter to accept them and the install continues.

apt prints a few hundred lines. Ask each piece directly what you ended up with:

apache2 -v | head -1
mariadb --version
php -v | head -1
systemctl is-active apache2 mariadb

The | in those lines sends one command’s output into another, and head -1 keeps only the first line — these programs print a paragraph and you only want the version. You want three version strings followed by active twice:

Server version: Apache/2.4.58 (Ubuntu)
mariadb  Ver 15.1 Distrib 10.11.14-MariaDB, for debian-linux-gnu (x86_64) using  EditLine wrapper
PHP 8.3.6 (cli) (built: May 25 2026 13:12:06) (NTS)
active
active

If either service says inactive or failed, stop here and run systemctl status apache2 to see why; nothing further will work until both are running. Compare that PHP version against what you wrote down in step 1 — this is the version jump you will be testing for.

MariaDB is the drop-in MySQL replacement Ubuntu ships. A WordPress database moves between the two without complaint, because WordPress writes its own collation into the schema rather than relying on whatever the server would have picked.

Check the outside world can reach it

Apache is running, but that does not mean anyone can get to it. Many providers put a firewall or security group in front of the machine, and a fresh one often allows only SSH. Run this from your own computer, not the server — and in Windows PowerShell type curl.exe with the extension, because plain curl there is an alias for something else entirely:

curl -I http://98.84.122.189/

Any HTTP response line means port 80 is open. A blocked port gives no status code at all — a stall, then:

curl: (28) Connection timed out after 10002 milliseconds

If you get that, open TCP ports 80 and 443 to the internet in your provider’s firewall or security-group panel before going further. It is much easier to fix now than to diagnose in step 11, where it looks exactly like a broken migration.

[SCREENSHOT: VPS provider firewall or security group panel, with inbound rules allowing TCP 80 and 443 from anywhere]

Step 5: Create the database and its user

Your site needs a database on the new server, and a user with rights to it. Open the database prompt:

sudo mariadb

You are now in MariaDB rather than the shell — the prompt changes to MariaDB [(none)]>. Type these four lines, one at a time, each ending in a semicolon. Change the password to something you generate yourself:

CREATE DATABASE example_wp CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'example_wpuser'@'localhost' IDENTIFIED BY 'Kx7#mBv2qLp9';
GRANT ALL PRIVILEGES ON example_wp.* TO 'example_wpuser'@'localhost';
FLUSH PRIVILEGES;
EXIT;

The whole session, prompts and all, looks like this — each statement answered with Query OK:

Welcome to the MariaDB monitor.  Commands end with ; or \g.
Your MariaDB connection id is 33
Server version: 10.11.14-MariaDB-0ubuntu0.24.04.1 Ubuntu 24.04

MariaDB [(none)]> CREATE DATABASE example_wp CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
Query OK, 1 row affected (0.000 sec)

MariaDB [(none)]> CREATE USER 'example_wpuser'@'localhost' IDENTIFIED BY 'Kx7#mBv2qLp9';
Query OK, 0 rows affected (0.001 sec)

MariaDB [(none)]> GRANT ALL PRIVILEGES ON example_wp.* TO 'example_wpuser'@'localhost';
Query OK, 0 rows affected (0.001 sec)

MariaDB [(none)]> FLUSH PRIVILEGES;
Query OK, 0 rows affected (0.000 sec)

MariaDB [(none)]> EXIT;
Bye

EXIT; puts you back at the shell prompt. Write down the database name, user and password you just chose; step 9 needs all three.

An error here is nearly always a missing semicolon: MariaDB shows a -> continuation prompt and waits instead of running your statement. Type ; and press Enter.

Step 6: Upload the two backups

The files are on your own computer, and they need to be on the server. scp copies them over the same SSH connection you already use.

Open a second terminal window on your own computer. If you are still connected to the VPS in your first window, leave it be; you will come back to it. Then go to wherever the downloads landed and send both files:

cd ~/Downloads
scp backup-7.19.2026_janedoe.tar.gz janedoe_wp.sql [email protected]:~/

scp reports progress per file, then a completed line for each:

backup-7.19.2026_janedoe.tar.gz               100%   26MB   9.7MB/s   00:02
janedoe_wp.sql                                100%  101KB 971.3KB/s   00:00

The :~/ on the end means “into that user’s home directory on the server”. A 26 MB site takes seconds; a media-heavy one with years of uploads can take considerably longer, and this is the step to run over a connection you trust.

Now switch back to the terminal window that is connected to the server.

Step 7: Unpack the files and put them where Apache looks

On the server, confirm both files arrived:

cd ~
ls -lh

Both, sitting in your home directory:

total 27M
-rw-rw-r-- 1 ubuntu ubuntu  26M Jul 19 19:00 backup-7.19.2026_janedoe.tar.gz
-rw-rw-r-- 1 ubuntu ubuntu 102K Jul 19 19:00 janedoe_wp.sql

Before extracting an archive, look inside it. Archives from different hosts wrap their contents differently, and one line tells you how deep your site actually sits. tar -tzf lists what is in a compressed archive without unpacking any of it:

tar -tzf backup-7.19.2026_janedoe.tar.gz | head -8

Every path begins with public_html/, so the site is one directory down:

public_html/
public_html/index.php
public_html/wp-activate.php
public_html/wp-blog-header.php
public_html/license.txt
public_html/wp-config-sample.php
public_html/wp-content/
public_html/wp-content/index.php

If yours starts with homedir/public_html/ instead, everything below still works — just add that extra directory to the paths. Now extract into a staging directory, so a surprise inside the archive cannot scatter files across your home directory. Here -x extracts and -C sends the results into the directory you name:

mkdir -p ~/restore
tar -xzf ~/backup-7.19.2026_janedoe.tar.gz -C ~/restore
ls ~/restore/public_html

The familiar shape of a WordPress install:

index.php
license.txt
readme.html
wp-activate.php
wp-admin
wp-blog-header.php
wp-comments-post.php
wp-config-sample.php
wp-config.php
wp-content
wp-cron.php
wp-includes
wp-links-opml.php
wp-load.php
wp-login.php
wp-mail.php
wp-settings.php
wp-signup.php
wp-trackback.php
xmlrpc.php

wp-config.php must be in that listing. It holds your database credentials and WordPress will not start without it. If it is missing, your host excluded it from the backup and you will need to fetch it over FTP before continuing.

Apache serves files out of /var/www. Create a directory named for your site and copy the contents in — the . at the end of the source path copies what is inside public_html, not the folder itself:

sudo mkdir -p /var/www/example.com
sudo cp -a ~/restore/public_html/. /var/www/example.com/

Those files currently belong to you, not to Apache, which runs as the www-data user. Hand them over, then set permissions. The two find lines mean “for every directory below this point, run chmod 755“, and the same for every file with 644 — the {} stands for each item found, and the \; on the end is required punctuation, not a typo:

sudo chown -R www-data:www-data /var/www/example.com
sudo find /var/www/example.com -type d -exec chmod 755 {} \;
sudo find /var/www/example.com -type f -exec chmod 644 {} \;

That gives the web server ownership of the whole tree, which is what lets WordPress update itself and install plugins from the dashboard. It is a real trade rather than a free win: code the web server can write is code an exploited plugin can rewrite. WordPress’s own hardening guidance prefers that you own the files and grant write access only where it is needed, and if you are content to manage updates over SSH instead, that is the stricter arrangement.

One file should not be readable by everyone on the machine either way, because it is about to contain your database password:

sudo chmod 640 /var/www/example.com/wp-config.php

Check all of it took:

ls -ld /var/www/example.com
ls -l /var/www/example.com/wp-config.php

www-data on both lines, drwxr-xr-x on the directory, and -rw-r----- on the config file, which closes it to everyone else:

drwxr-xr-x 5 www-data www-data 4096 Jul 19 18:23 /var/www/example.com
-rw-r----- 1 www-data www-data 1938 Jul 19 18:24 /var/www/example.com/wp-config.php

If either still says ubuntu ubuntu, the chown did not run — WordPress will load but will refuse to update itself or install plugins.

Step 8: Import the database

If your download arrived as janedoe_wp.sql.gz rather than janedoe_wp.sql, uncompress it first with gunzip -k janedoe_wp.sql.gz, which leaves the original alongside the extracted file and prints nothing. Otherwise go straight to the import:

cd ~
sudo mariadb example_wp < janedoe_wp.sql

A successful import says nothing at all. Ask the database what it now contains instead:

sudo mariadb example_wp -e "SHOW TABLES;"

There should be a dozen or so, all prefixed wp_:

Tables_in_example_wp
wp_commentmeta
wp_comments
wp_links
wp_options
wp_postmeta
wp_posts
wp_term_relationships
wp_term_taxonomy
wp_termmeta
wp_terms
wp_usermeta
wp_users

An empty result means the import did not take at all — but an import that stopped halfway also leaves tables behind, so count the posts:

sudo mariadb example_wp -e 'SELECT COUNT(*) AS published_posts FROM wp_posts WHERE post_status="publish";'

This should match roughly what the old site shows in Posts → All Posts. If it is zero, or far short, the import stopped partway and the error will have been the last thing printed when you ran it:

published_posts
5

Step 9: Point wp-config.php at the new database

The site’s config still names the old host’s database. Open it:

sudo nano /var/www/example.com/wp-config.php

nano is a plain text editor: arrow keys to move, type to edit, Ctrl+O then Enter to save, Ctrl+X to quit. Near the top you will find four define( lines — the database constants WordPress reads at every page load:

define( 'DB_NAME', 'janedoe_wp' );
define( 'DB_USER', 'janedoe_wpuser' );
define( 'DB_PASSWORD', 'oldpass' );
define( 'DB_HOST', 'localhost' );

Replace the first three with the values you chose in step 5. Leave DB_HOST as localhost — the database is on the same machine as the site now, which is what localhost means.

Save and exit, then check the four lines read the way you intended:

sudo grep "DB_NAME\|DB_USER\|DB_PASSWORD\|DB_HOST" /var/www/example.com/wp-config.php

Three of them should have changed, and DB_HOST deliberately should not:

define( 'DB_NAME', 'example_wp' );
define( 'DB_USER', 'example_wpuser' );
define( 'DB_PASSWORD', 'Kx7#mBv2qLp9' );
define( 'DB_HOST', 'localhost' );

A typo in any of the first three produces “Error establishing a database connection” in step 11, and this is the file to come back to.

Step 10: Tell Apache about the site

Apache needs to know that requests for your domain are answered from your new directory. That definition is a virtual host file. Create it:

sudo nano /etc/apache2/sites-available/example.com.conf

Type this in, with your own domain on the ServerName and ServerAlias lines:

<VirtualHost *:80>
    ServerName example.com
    ServerAlias www.example.com
    DocumentRoot /var/www/example.com

    <Directory /var/www/example.com>
        AllowOverride All
        Require all granted
    </Directory>

    ErrorLog ${APACHE_LOG_DIR}/example.com-error.log
    CustomLog ${APACHE_LOG_DIR}/example.com-access.log combined
</VirtualHost>

AllowOverride All is the line that lets WordPress’s .htaccess file work; leaving it out is covered at the end of this guide.

Save and exit. Now turn on URL rewriting, enable your site, and switch off the Ubuntu placeholder page that currently owns port 80:

sudo a2enmod rewrite
sudo a2ensite example.com
sudo a2dissite 000-default

Each command confirms itself and reminds you it has not taken effect yet:

Enabling module rewrite.
To activate the new configuration, you need to run:
  systemctl restart apache2
Enabling site example.com.
To activate the new configuration, you need to run:
  systemctl reload apache2
Site 000-default disabled.
To activate the new configuration, you need to run:
  systemctl reload apache2

Those messages disagree with each other — one says restart, two say reload. Use reload: it re-reads the whole configuration, newly enabled modules included, and does it without dropping anyone mid-request. But check the configuration first, because a syntax error in a virtual host file will take Apache down:

sudo apache2ctl configtest

You are looking for exactly two words:

Syntax OK

Anything else names the file and line number to fix. With Syntax OK, apply it:

sudo systemctl reload apache2
systemctl is-active apache2

The reload prints nothing and hands the prompt straight back. The second command is there so you get an answer:

active

Step 11: Test the real domain before DNS moves

Your computer checks a local file called the hosts file before it asks DNS anything. Add one line to it and your browser will fetch example.com from the new server, while every visitor in the world still lands on the old shared host.

On macOS or Linux, in a terminal on your own computer:

sudo nano /etc/hosts

On Windows: Start menu → type Notepad → right-click it → Run as administrator. Then File → Open, paste C:\Windows\System32\drivers\etc\hosts, and change the file-type dropdown from “Text Documents (*.txt)” to All Files, or the folder will look empty — hosts has no file extension.

[SCREENSHOT: Windows Start menu with Notepad right-clicked, showing the Run as administrator option]

[SCREENSHOT: Notepad Open dialog at C:\Windows\System32\drivers\etc with the file-type dropdown set to All Files and the hosts file visible]

Add one line at the bottom — your VPS’s IP, then both forms of your domain:

98.84.122.189 example.com www.example.com

Include the www form even if you never use it. If your site redirects between the two, leaving one out sends you quietly back to the old host and you test the wrong server without realising.

Save it, then clear your computer’s DNS cache, or it may keep serving the answer it already has. On macOS:

sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder

On Windows, ipconfig /flushdns. Both print little or nothing. Then load http://example.com in a private or incognito window, which sidesteps the browser’s own cache. Confirm which machine actually answered:

curl -s -o /dev/null -w "reached %{remote_ip} -> HTTP %{http_code}\n" http://example.com/

The IP in that line is the proof — it is your VPS, not the old host:

reached 98.84.122.189 -> HTTP 200

Now go through the site properly:

  • The homepage, and a few individual posts or pages.
  • Images — broken images usually mean the chown in step 7 did not run.
  • A contact form, a search, anything that writes to the database.
  • /wp-admin/ — log in and check the dashboard loads.
  • If the old host was on an older PHP version, click through anything a plugin powers.

If the site does not load at all, work through it in this order: your browser reaching the old host anyway (cache, or a missing www line), then ports 80 and 443 blocked at your provider, then a database credential typo from step 9.

Anything wrong, fix it now. Nobody else can see it. When everything is right, remove the line you added to the hosts file and save — leave it in and you will spend a confusing afternoon later wondering why your browser disagrees with everyone else’s.

Tidy up before you cut over

You now have a second full copy of your site in ~/restore, and a plain-text dump of your database — user rows included — sitting in your home directory. Neither is doing anything for you, and the dump is the one to care about:

rm -rf ~/restore
rm ~/janedoe_wp.sql

Keep the two original downloads on your own computer. Those are your backup.

Step 12: Cut DNS over

Write down the current value of the A record before you change it. It is the address of your old shared host, and it is your way back. Once you overwrite the field, nothing on the page will tell you what it used to be.

Then edit the A record for your domain and replace that address with your VPS’s.

[SCREENSHOT: DNS management panel showing the A record being edited, with the old shared hosting IP replaced by the VPS IP address]

If your domain has a second A record for www, change that too — you want it pointing at the VPS before step 13 in any case.

Watch it take effect:

dig +short example.com

Once it returns your VPS’s address, the change has reached the resolver you are asking:

98.84.122.189

Because you lowered the TTL a day ago, most of the internet catches up within minutes. Some stragglers take longer — which is why the next part matters.

Leave the shared hosting account running for at least 48 hours. During those 48 hours anyone still being sent to the old IP gets the old site rather than an error. It also keeps the migration reversible: if something is badly wrong, put the old address back in the A record and, with a 300-second TTL, you are back on the working site within minutes. Cancel the old account only once your new server’s access log shows the traffic has fully moved.

Step 13: Turn on HTTPS

Your old host almost certainly provided a certificate. Your VPS does not have one yet, and the reason to do this now rather than earlier is that the default --apache flow proves you control the domain by answering an HTTP request on it — and until DNS moved, that request landed on the old host. (There is a way around this: a dns-01 challenge validates by adding a DNS record instead, so a certificate can be issued before cutover. It needs a DNS plugin or a manual step, which is why most people simply do it in this order.)

On the server:

sudo apt install -y certbot python3-certbot-apache
sudo certbot --apache -d example.com -d www.example.com

Name only the domains that actually point at this server. Certbot validates them as a single request, so including a www that does not resolve to the VPS fails the whole thing and issues nothing at all — if you have no www record, drop that second -d argument.

Certbot asks three things the first time: an email address for expiry warnings, whether you accept the terms of service, and whether you want to join the EFF mailing list. Then it does the work:

Requesting a certificate for migrate.lab.lineserve.dev

Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/migrate.lab.lineserve.dev/fullchain.pem
Key is saved at:         /etc/letsencrypt/live/migrate.lab.lineserve.dev/privkey.pem
This certificate expires on 2026-10-17.
These files will be updated when the certificate renews.
Certbot has set up a scheduled task to automatically renew this certificate in the background.

Deploying certificate
Successfully deployed certificate for migrate.lab.lineserve.dev to /etc/apache2/sites-available/example.com-le-ssl.conf
Congratulations! You have successfully enabled HTTPS on https://migrate.lab.lineserve.dev

Certbot wrote a second virtual host file for port 443 and added a redirect to the first, so http:// now sends visitors to https://. Confirm both halves:

curl -sI http://example.com/ | head -1
curl -sI https://example.com/ | head -1

Plain HTTP should now answer with a permanent redirect, and HTTPS with a 200:

HTTP/1.1 301 Moved Permanently
HTTP/1.1 200 OK

That scheduled renewal is worth confirming rather than trusting, because a certificate that silently fails to renew takes the site down in ninety days:

sudo certbot renew --dry-run

A rehearsal of the real renewal, without touching the live certificate:

Congratulations, all simulated renewals succeeded: 
  /etc/letsencrypt/live/migrate.lab.lineserve.dev/fullchain.pem (success)

Tell WordPress it is on HTTPS now

WordPress stores its own address in the database, and that stored value still says http://. Correct both rows so the stored value matches reality:

sudo mariadb example_wp -e 'UPDATE wp_options SET option_value="https://example.com" WHERE option_name IN ("siteurl","home");'
sudo mariadb example_wp -e 'SELECT option_name, option_value FROM wp_options WHERE option_name IN ("siteurl","home");'

The second command reads the rows back, and both should now be on https://:

option_name option_value
home    https://example.com
siteurl https://example.com

(If your wp-config.php defines WP_SITEURL or WP_HOME, those override the database values and you should edit them there instead.)

When the homepage works but every post 404s

Of the things that go wrong after a migration, this is the one to know by sight: the front page loads perfectly, and every single post returns a “Not Found” page that does not even look like your site.

homepage:  HTTP 200
blog post: HTTP 404

The 404 comes from Apache itself rather than WordPress, which is the tell:

<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>404 Not Found</title>
</head><body>
<h1>Not Found</h1>
<p>The requested URL was not found on this server.</p>
<hr>
<address>Apache/2.4.58 (Ubuntu) Server at migrate.lab.lineserve.dev Port 443</address>
</body></html>

WordPress’s pretty permalinks work through rewrite rules in a .htaccess file, and Apache ignores .htaccess entirely unless a <Directory> block says otherwise — its default is AllowOverride None, and with that set the server does not even look for the file. The homepage still works because it needs no rewriting; everything else silently stops existing.

Check what your virtual host files actually say. grep searches files for a line, -h stops it printing the filename in front of each hit, and the * in the path matches both your own file and the one Certbot generated — that second file is the easy one to forget:

sudo grep -h AllowOverride /etc/apache2/sites-available/example.com*.conf

If either line says None, that is your answer. sed -i "s/None/All/" rewrites the first piece of text into the second directly inside those files and prints nothing as it goes, so run the grep again afterwards to see that it worked:

sudo sed -i "s/AllowOverride None/AllowOverride All/" /etc/apache2/sites-available/example.com*.conf
sudo grep -h AllowOverride /etc/apache2/sites-available/example.com*.conf

Both files, both fixed:

        AllowOverride All
        AllowOverride All

Then check and reload as before:

sudo apache2ctl configtest
sudo systemctl reload apache2

The same post, immediately afterwards:

blog post: HTTP 200

If posts still 404 with AllowOverride All in place, the .htaccess file itself is missing — log into /wp-admin/, go to Settings → Permalinks, and click Save Changes without altering anything. That makes WordPress write the file.

[SCREENSHOT: WordPress dashboard, Settings → Permalinks screen with the Save Changes button]

Your email did not move

Changing the A record moves your website. It does not move your email — and this is the one that catches people. Mail is routed by MX records, which are separate entries, and they still point wherever they pointed before.

If your shared host also handles your mail and you want to keep that arrangement, do nothing — leave the MX records alone and mail keeps flowing, even though the website has moved. That works fine, but it does mean you cannot cancel the shared hosting account.

Check what you have before you touch anything:

dig +short A example.com
dig +short MX example.com

Two separate answers, and only the first one changed during this migration:

98.84.122.189
10 mail.example.com.

(Ask for one record type per command — dig example.com A MX quietly returns only the last type you named.)

Running your own mail server on the VPS is a genuinely separate project, and not one to start on cutover day. Pointing the MX records at a hosted mail provider is the usual alternative.

After the cutover

In the first week, most urgent first:

Set up a firewall. Your VPS is exposed directly to the internet in a way a shared account never was, and nothing on the machine itself is filtering it. How to Set Up a Firewall with UFW on Ubuntu 24.04 is a ten-minute job and the most urgent thing on this list.

Confirm it survives a restart. Both services are set to start at boot — systemctl is-enabled apache2 mariadb answers enabled twice — but that is the configuration, not the proof. Reboot once at a quiet hour:

sudo reboot

Your SSH session will freeze and then print something like client_loop: send disconnect: Broken pipe. That is the server going down, not a mistake on your part. Wait about a minute, reconnect with the same ssh command from step 4, and ask:

systemctl is-active apache2 mariadb

Both services should have come back on their own:

active
active

Add swap if you are on a 2 GB server. Apache, MariaDB and PHP together will use most of it, and a traffic spike that would once have been someone else’s problem is now yours. Swap Space on a VPS: When You Need It and How to Add It explains when it helps and when it does not.

What size VPS you actually need

A WordPress site of this size runs comfortably on 2 vCPU and 2 GB of RAM, and occupies about 2.7 GB of disk once Ubuntu is counted. Memory is the constraint that binds; the second core sat idle throughout.

For a low-traffic site that makes Lineserve S1 the sensible entry point: 1 vCPU, 2 GB RAM, 20 GB SSD and 1 TB of transfer, at KES 1,746 ($13.51) a month. If your site carries years of images, S2 keeps the same processor and memory but doubles the disk to 40 GB and the transfer to 2 TB, for KES 2,372 ($18.35). Only step up to M1 — 2 vCPU, 4 GB RAM, 80 GB SSD, KES 4,418 ($34.18) — if your cPanel dashboard already shows sustained CPU pressure, because that is the one thing shared hosting was genuinely hiding from you. All three come with a dedicated IPv4 address, which is what the A record in step 12 actually needs, and all three are billed in shillings through M-Pesa or bank transfer with no foreign card required.