
A smooth migration is mostly preparation. Copying data is rarely the hard part. Avoiding DNS lag, lost form submissions, and mail surprises is.
This server migration tutorial uses a repeatable workflow to move a typical Linux-hosted site (Nginx/Apache + PHP + uploads + a database) to a new VPS with minimal downtime.
The examples assume Ubuntu 24.04 LTS on both servers. The same workflow translates cleanly to Debian 12/13, AlmaLinux 9/10, and Rocky Linux.
You’ll do an initial rsync for files and an initial database import while the old site stays live. Then you’ll run a final sync and use a short maintenance window for the cutover.
What you’ll migrate (and what to decide before you start)
Before you touch DNS, write down what the site actually needs to run. “A website” is usually several moving pieces. They need to land together.
- Web root / app files: WordPress core, plugins, themes, or your custom app.
- User uploads: typically
wp-content/uploadsor a storage directory. - Database: MySQL/MariaDB or PostgreSQL. (This guide uses MySQL/MariaDB examples.)
- Web server config: Nginx server blocks or Apache virtual hosts, plus PHP-FPM pool settings.
- TLS certificates: Let’s Encrypt files and renewal method.
- Crons/background tasks: WordPress cron, Laravel scheduler, queue workers, etc.
- Email: If the domain uses the server for mail, you must treat MX records and mailboxes as first-class migration items.
Make two calls up front. They keep the rest of the work predictable.
- Are you changing the IP only, or also changing the stack? Keep the migration boring. Do PHP upgrades and major refactors after you’re stable on the new VPS.
- Will DNS be your cutover mechanism, or will you switch via reverse proxy? Most small-to-mid sites cut over via DNS. That’s the path used below.
Prep the destination VPS (base OS, users, firewall, and time sync)
Start with a clean VPS sized for the workload you actually run. CPU and RAM matter. Disk I/O and storage headroom often decide whether the site feels “snappy” after the move.
If you want the least operational overhead, consider a managed VPS hosting plan from HostMyCode.
If you prefer to run everything yourself, a standard HostMyCode VPS is a good fit for hands-on migrations.
1) Update packages and create an admin user
sudo apt update && sudo apt -y upgrade
sudo adduser deploy
sudo usermod -aG sudo deploy
2) Lock down SSH and the firewall
At a minimum, open SSH and the web ports. If you haven’t configured UFW recently, use the HostMyCode guide. Then return here:
UFW Firewall Setup Tutorial (2026): lock down an Ubuntu VPS for web hosting
Quick baseline (adjust SSH port if you use a non-standard one):
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verbose
3) Ensure time is correct (TLS and logs depend on it)
timedatectl
sudo timedatectl set-timezone UTC
Lower DNS TTL ahead of the cutover (your biggest downtime reducer)
The classic “why is traffic still hitting the old server?” issue is DNS caching. Lowering TTL ahead of time avoids most of it.
- Go to where your DNS zone is hosted (registrar DNS, Cloudflare, or your DNS provider).
- Set TTL for the website record(s) to 300 seconds (5 minutes). If your provider allows 120 seconds, that’s fine too.
- Do this at least 12–24 hours before you cut over.
If you manage your domain at HostMyCode, you can keep DNS and hosting together with HostMyCode domains and DNS. That reduces the odds of editing the wrong record during a late-night cutover.
Install the same web stack on the new VPS (match versions first)
Most migration “mystery bugs” come from version drift. Common causes are different PHP defaults, missing extensions, or a module that used to exist.
During the migration window, match the old server as closely as you can. Modernize after the site serves traffic reliably.
On Ubuntu 24.04, a common baseline is Nginx + PHP-FPM + MariaDB client tools:
sudo apt -y install nginx php-fpm php-cli php-mysql php-curl php-xml php-mbstring php-zip php-gd
sudo systemctl enable --now nginx
If you use Apache instead, install it and match your modules:
sudo apt -y install apache2 libapache2-mod-fcgid
sudo a2enmod proxy_fcgi setenvif rewrite headers http2 ssl
sudo systemctl enable --now apache2
Copy website files with rsync (first pass)
rsync is the workhorse here. Do one large copy early. Then do a much faster “delta” sync right before the cutover.
This is how you keep downtime to minutes instead of hours.
1) Set up SSH key auth from new → old
On the new server (as deploy), generate a key:
sudo -iu deploy
ssh-keygen -t ed25519 -a 64
Copy the public key to the old server user that can read the web root:
ssh-copy-id user@OLD_SERVER_IP
2) Identify the correct web root
Examples:
- Nginx:
/var/www/example.comor/srv/www/example.com - WordPress: often
/var/www/htmlor a per-site directory
3) Run rsync (preserve permissions, compress, show progress)
sudo rsync -aHAX --numeric-ids --info=progress2 \
-e "ssh" user@OLD_SERVER_IP:/var/www/example.com/ /var/www/example.com/
Tip: If the old host is shared hosting and you only have SFTP access, you may still be able to use rsync over SSH (some providers allow it).
If not, you’ll fall back to sftp or a control panel backup. Plan for a longer maintenance window.
Migrate the database (clean export + import)
For MySQL/MariaDB, export with consistent options and import on the new server. Run the first import while the old site is still serving users.
1) Create the database and user on the new server
Install client/server as needed. If the database runs on the same VPS, install MariaDB:
sudo apt -y install mariadb-server
sudo systemctl enable --now mariadb
Create a database and user:
sudo mysql
CREATE DATABASE exampledb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'exampleuser'@'localhost' IDENTIFIED BY 'STRONG_PASSWORD_HERE';
GRANT ALL PRIVILEGES ON exampledb.* TO 'exampleuser'@'localhost';
FLUSH PRIVILEGES;
EXIT;
2) Export from the old server
On the old server:
mysqldump --single-transaction --routines --triggers \
--default-character-set=utf8mb4 \
-u exampleuser -p exampledb | gzip > /tmp/exampledb.sql.gz
Copy it to the new server:
scp /tmp/exampledb.sql.gz deploy@NEW_SERVER_IP:/tmp/
3) Import on the new server
gunzip -c /tmp/exampledb.sql.gz | mysql -u exampleuser -p exampledb
If you hit max_allowed_packet errors during import, increase it temporarily in /etc/mysql/mariadb.conf.d/50-server.cnf:
[mysqld]
max_allowed_packet=256M
sudo systemctl restart mariadb
Recreate web server vhosts and test locally before DNS changes
Don’t cut over because “Nginx is running.” Cut over because the site behaves correctly on the new VPS.
1) Nginx server block example
Create /etc/nginx/sites-available/example.com:
server {
listen 80;
server_name example.com www.example.com;
root /var/www/example.com;
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
location ~* \.(css|js|png|jpg|jpeg|gif|svg|ico|woff2?)$ {
expires 30d;
add_header Cache-Control "public";
}
}
Enable and test:
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
2) Test with your workstation hosts file
This lets you validate the new server without changing public DNS.
On Linux/macOS, edit /etc/hosts; on Windows, edit C:\Windows\System32\drivers\etc\hosts. Add:
NEW_SERVER_IP example.com www.example.com
Now load the site in a browser. Check login, forms, uploads, checkout flows, and any external callbacks or webhooks you depend on.
Set up SSL on the new server (and keep renewals working)
After the site responds correctly over HTTP, set up HTTPS. In 2026, the simplest route is still Let’s Encrypt with Certbot.
For the full step-by-step and common pitfalls, use this guide:
SSL Certificate Setup Guide (Tutorial) for Ubuntu VPS: Nginx + Let’s Encrypt in 2026
Common Nginx approach:
sudo apt -y install certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
Confirm renewals:
sudo certbot renew --dry-run
Plan the cutover window (short maintenance beats data corruption)
“Near-zero downtime” still needs a brief write freeze for most real sites. Orders, comments, submissions, and uploads can’t safely land on two servers at once.
Aim for a short maintenance window (often 1–5 minutes). Avoid a risky live switch.
Use this cutover checklist
- Lowered DNS TTL to 300 seconds at least 12–24 hours ago
- New server verified via hosts-file test
- SSL issued and renewals tested
- Background jobs/cron identified
- Rollback plan documented (switch DNS back, keep old server online)
Final sync + database delta: the “freeze, copy, switch” sequence
This is the part users notice. The site comes back quickly, and nothing goes missing.
1) Put the old site into maintenance mode
Options:
- WordPress: enable a maintenance plugin, or use a static
maintenance.htmlvia Nginx/Apache. - Custom app: temporary 503 page at the web server level.
Nginx quick 503 method on the old server (example):
location / {
return 503;
}
error_page 503 @maintenance;
location @maintenance {
root /var/www/maintenance;
rewrite ^(.*)$ /maintenance.html break;
}
Reload Nginx after editing.
2) Final rsync (fast because most files already copied)
sudo rsync -aHAX --delete --numeric-ids --info=progress2 \
-e "ssh" user@OLD_SERVER_IP:/var/www/example.com/ /var/www/example.com/
3) Final database export/import
With the site frozen, this dump represents the final consistent state.
mysqldump --single-transaction --routines --triggers \
--default-character-set=utf8mb4 \
-u exampleuser -p exampledb | gzip > /tmp/exampledb-final.sql.gz
scp /tmp/exampledb-final.sql.gz deploy@NEW_SERVER_IP:/tmp/
On the new server, replace the database:
gunzip -c /tmp/exampledb-final.sql.gz | mysql -u exampleuser -p exampledb
If you need a clean slate (often safer for WordPress), drop and recreate the DB before import. Just confirm credentials in your app config first.
4) Update app config on the new server
Typical edits:
- WordPress:
/var/www/example.com/wp-config.php(DB name/user/password/host) - Laravel:
.env(DB settings, cache/queue drivers)
DNS cutover (A/AAAA records) and verification commands
Update your A record (and AAAA if you use IPv6) to the new server IP. Keep the old server up until you validate everything end-to-end.
Verify propagation from multiple resolvers
dig +short example.com A
dig +short example.com A @1.1.1.1
dig +short example.com A @8.8.8.8
Check that requests land on the new server:
curl -I https://example.com
Add a quick identifier header on the new server during migration. Remove it later. Nginx example:
add_header X-Migrated-To "new-vps" always;
Post-cutover fixes that prevent “it works for me” problems
As soon as traffic starts arriving at the new VPS, run these checks. They catch common issues before users do.
1) Permissions and ownership
Uploads failing is usually a permissions issue. For a typical Nginx + PHP-FPM setup:
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 {} \;
Adjust ownership if your deployment model uses a different user.
2) PHP extensions and limits
If you see 500 errors after the move, compare php -m between servers.
The usual suspects: mbstring, curl, xml, zip, gd.
Also confirm upload_max_filesize and post_max_size in:
/etc/php/8.3/fpm/php.ini/etc/php/8.3/fpm/pool.d/www.conf(if you use pool overrides)
Reload after changes:
sudo systemctl reload php8.3-fpm
3) Redirect loops and mixed content
Redirect loops usually come from double-forcing HTTPS. They can also come from proxy headers not being passed through.
If you run a reverse proxy in front of the VPS, pass X-Forwarded-Proto and configure your app to trust it.
If you do need a reverse-proxy design, this tutorial is a solid reference:
Nginx reverse proxy setup guide (2026): SSL, caching, and WebSockets
4) Cron jobs and scheduled tasks
Copy crontabs and make sure they execute on the new server.
On the old server:
sudo crontab -l
crontab -l
Recreate them on the new server, then confirm logs.
For system cron, check:
/etc/crontab/etc/cron.d/
Email and DNS records: avoid breaking mail during a web migration
If your email is hosted elsewhere (Google Workspace, Microsoft 365, a transactional provider), keep MX records unchanged and move on.
If your old server handled mail, treat mail as its own project. You can migrate the website first and mail later.
Trying to do both in one cutover is where outages tend to happen.
At minimum, verify these records before and after the DNS change:
- MX: where inbound mail goes
- SPF (TXT): authorized senders
- DKIM (TXT): signing key
- DMARC (TXT): policy and reporting
If you’re not confident in the current DNS layout, keep the move web-only and leave mail untouched. That one decision prevents most business-impacting surprises.
Troubleshooting: the 8 fastest checks when the migrated site misbehaves
- Confirm DNS points to the new IP:
dig +short example.com A - Confirm the web server is serving the right vhost: check
server_nameand enabled site symlinks. - Check logs first:
- Nginx:
/var/log/nginx/error.log - Apache:
/var/log/apache2/error.log - PHP-FPM:
journalctl -u php8.3-fpm --since "15 min ago"
- Nginx:
- Fix file ownership: uploads and cache directories must be writable.
- Verify DB credentials: match
wp-config.phpor.env. - Look for missing PHP extensions: compare
php -m. - Disable caching temporarily: page cache and object cache can mask changes.
- Test from the server itself:
curl -I http://127.0.0.1andcurl -I https://example.com.
Cleanup and hardening after the migration (don’t skip this)
After the site runs cleanly on the new VPS, spend a little time reducing the chance of a repeat incident.
- Remove the hosts-file override on your workstation.
- Raise DNS TTL back to 3600 or 14400 seconds, depending on your change cadence.
- Enable automatic security updates (or schedule patch windows).
- Set up backups immediately: file + database, plus restore testing.
- Keep the old server for 3–7 days (powered on, not public-facing if possible) as a rollback source.
Summary: a repeatable migration you can run again next year
This process stays consistent: lower TTL, do an initial rsync and DB import, validate via a hosts-file test, freeze writes, run the final sync, then flip DNS.
Most failed moves skip the write freeze. Others cut over without a real verification pass.
If you want a predictable platform for future changes, start on a HostMyCode VPS or hand off the ops work to managed VPS hosting. Either approach gives you room to grow without re-architecting under pressure.
If you want fewer variables during a move, run your site on a HostMyCode VPS sized for your traffic and storage. If you’d rather have the migration done with tighter guardrails (plus ongoing OS and server maintenance), managed VPS hosting is the simplest route.
FAQ
How much downtime should I expect with this server migration tutorial?
If your TTL is lowered ahead of time and the final sync is small, many sites only need 1–5 minutes in maintenance mode. That window covers the last DB dump/import and a quick config verification.
Do I need to migrate SSL certificates, or should I re-issue them?
Re-issuing is usually cleaner. Let’s Encrypt certificates are quick to obtain, and it avoids copying private keys between servers.
Can I migrate without changing DNS (same IP)?
Only if you control routing at the network layer, which is uncommon for small deployments. DNS cutover is the normal method for VPS and dedicated server moves.
What if I’m moving from shared hosting and don’t have SSH access?
You can still migrate, but you’ll rely on control panel backups and SFTP downloads/uploads. Plan for a longer freeze window and test carefully, especially for large upload directories.
How long should I keep the old server running after cutover?
Keep it for at least 72 hours, and ideally a week, unless cost or policy prevents it. It’s your fastest rollback option if you discover a missing config or file path.