
Web server migrations usually fail for boring, repeatable reasons. DNS cutover gets sloppy. A PHP extension is missing. File ownership is wrong. TLS looks “fine” until real browsers disagree.
This web server migration tutorial shows a controlled way to move Nginx + PHP-FPM sites to a new VPS. You’ll use a staging URL, repeatable syncs, and a rollback you can execute quickly.
The flow assumes one or more PHP sites on a Linux VPS (WordPress is common, but not required). You’ll build the new server, mirror Nginx and PHP-FPM behavior, sync content, validate HTTPS, and then switch traffic. Each step includes clear checks.
What you’ll migrate (and what you won’t)
Keep the scope tight. You’re moving the web tier and application files.
Don’t use migration day to “improve” everything.
- Move: Nginx site configs, PHP-FPM pools (if customized), website files, cron jobs, TLS certificates, and firewall rules.
- Optional: Database (if it lives on the same VPS) or repoint to a managed database.
- Do not change during migration: PHP major version, permalink structure, caching plugins, or CDN settings unless you’ve tested on staging.
If you’re moving from shared hosting to a VPS, the same cutover discipline applies. You’ll just do more low-level configuration.
If you want someone to run the move end-to-end, HostMyCode offers HostMyCode migrations.
Prerequisites and planning checklist
Before you touch the new server, document what the old server is actually running.
“Pretty sure it’s PHP 8.2” isn’t a plan.
- Access: SSH to old and new VPS as a sudo-capable user.
- OS target: Ubuntu 24.04 LTS or Debian 12 (both common and well supported in 2026).
- Web stack: Nginx 1.24+ and PHP 8.2/8.3/8.4 via PHP-FPM.
- DNS control: Ability to edit A/AAAA records for the domain (or control at HostMyCode Domains).
- Maintenance window: Even “minimal downtime” usually needs 1–5 minutes of write freeze for dynamic sites.
Inventory commands (run on the old server)
sudo nginx -v
php -v
php-fpm8.3 -v 2>/dev/null || true
sudo nginx -T | sed -n '1,120p'
ls -lah /etc/nginx/sites-enabled
sudo ss -lntp | egrep ':(80|443)\b'
Also capture PHP extensions. Most “mystery migration bugs” come from a missing module like php-intl or php-imagick.
php -m | sort
Step 1 — Provision the new VPS (sized for the real workload)
Migration day is the worst time to discover you under-provisioned. If the old VPS sits at 70–80% CPU during peak, size up first.
Optimize later, when you have time to measure.
For production, start with NVMe storage and enough RAM for PHP-FPM workers plus page cache.
If you don’t want to own patching and routine service upkeep, use managed VPS hosting. If you prefer full control, a standard HostMyCode VPS is the typical fit.
Baseline OS prep (new server)
sudo apt update
sudo apt -y upgrade
sudo apt -y install curl wget unzip ca-certificates gnupg lsb-release
sudo timedatectl set-timezone UTC
Step 2 — Install Nginx and PHP-FPM to match the old server
Keep versions as close as practical. Even a minor PHP jump can surface deprecated behavior in plugins, themes, or framework code.
sudo apt -y install nginx
sudo systemctl enable --now nginx
Install PHP-FPM and extensions based on your inventory. Example for PHP 8.3 on Ubuntu:
sudo apt -y install php8.3-fpm php8.3-cli php8.3-common \
php8.3-curl php8.3-gd php8.3-mbstring php8.3-mysql \
php8.3-xml php8.3-zip php8.3-intl
sudo systemctl enable --now php8.3-fpm
If you’re unsure which modules you need, mirror the old server’s php -m output. Then adjust based on real error messages.
Don’t randomly install packages and hope it sticks.
Step 3 — Recreate your Nginx site layout and upstreams
Most cutovers break on “almost the same” configs. Treat your Nginx configuration like code.
Copy it cleanly, then change only what you can justify.
Copy Nginx configs from old to new
On the new server, create a holding folder:
sudo mkdir -p /root/migrate/nginx
From your workstation (or from the new server using SSH access), pull key directories from the old server:
rsync -avz --progress root@OLD_SERVER_IP:/etc/nginx/ /root/migrate/nginx/etc-nginx/
Now review before replacing anything:
diff -ruN /etc/nginx/ /root/migrate/nginx/etc-nginx/ | head
Typical safe copy targets:
/etc/nginx/sites-available/and/etc/nginx/sites-enabled//etc/nginx/snippets/- Any custom include directory you created
Watch for distro defaults and old drift. If the old server has years of tweaks, copy your app-specific configs first.
Only bring over nginx.conf tuning you understand and can explain.
Validate config on the new server
sudo nginx -t
sudo systemctl reload nginx
If you hit unknown directive, you likely copied config that depends on modules not present on the new box.
Fix the module mismatch or remove the directive.
Don’t “comment it out until it starts” without understanding the impact.
If you want to harden after the cutover, keep changes small and reversible. Security headers are a common next step.
See this Nginx security headers configuration tutorial for a rollout pattern that won’t surprise your app.
Step 4 — Prepare website users, folders, and permissions
Decide how sites map to Linux users. If each site gets its own user (recommended for isolation), create those users now.
This keeps ownership consistent from the first sync.
Create a per-site system user
sudo adduser --disabled-password --gecos "" site1
sudo mkdir -p /var/www/site1
sudo chown -R site1:site1 /var/www/site1
Mirror your existing layout if you can. It reduces surprises in cron jobs, deploy scripts, and PHP open_basedir rules (if you use them).
Step 5 — Sync site files with rsync (repeatable, fast, safe)
Use rsync so the process stays repeatable. The first pass moves the bulk of the data.
Later passes are small delta updates.
Initial bulk sync (old → new)
rsync -avz --delete --progress \
root@OLD_SERVER_IP:/var/www/site1/ /var/www/site1/
If user-generated content changes constantly (for WordPress: wp-content/uploads), plan a short write freeze for the final sync.
Preserve ownership and tighten permissions
If you created a dedicated user like site1, reset ownership after rsync:
sudo chown -R site1:site1 /var/www/site1
sudo find /var/www/site1 -type d -exec chmod 755 {} \;
sudo find /var/www/site1 -type f -exec chmod 644 {} \;
Step 6 — Migrate your database (two practical options)
If your database already lives elsewhere (a separate DB server), skip ahead to TLS and cutover.
If it’s on the same VPS, pick one method and follow it straight through.
Option A: Dump and restore (simple, reliable)
On the old server:
mysqldump --single-transaction --routines --triggers \
-u DBUSER -p DBNAME | gzip > /root/DBNAME.sql.gz
Copy it to the new server:
scp /root/DBNAME.sql.gz root@NEW_SERVER_IP:/root/
On the new server (after installing MySQL/MariaDB and creating DB/user):
gunzip -c /root/DBNAME.sql.gz | mysql -u DBUSER -p DBNAME
Option B: Temporary replication (useful for larger sites)
For larger databases where you’re aiming for near-zero downtime, replication can keep the new host close to current.
You still do a short final lock and promote.
It works, but it adds moving parts. If you don’t run replication regularly, dump/restore is usually the calmer choice.
Step 7 — Install TLS certificates (don’t wait for cutover day)
Get HTTPS working early. Test the full TLS chain and browser behavior on the new VPS before real traffic arrives.
If you use Let’s Encrypt, request certificates once the server can answer the ACME challenge. Two common patterns:
- Staging hostname: use
staging.example.compointed at the new VPS. - Temporary hosts entry: map the domain to the new IP locally for browser tests (works for HTTP checks, not for ACME validation).
Certbot example (Nginx plugin)
sudo apt -y install certbot python3-certbot-nginx
sudo certbot --nginx -d staging.example.com
If renewals fail later, it’s usually a broken challenge path, DNS mismatch, or a firewall rule blocking port 80/443.
Keep a short runbook handy. This guide covers the common failure modes: TLS certificate renewal troubleshooting tutorial.
Step 8 — Put the new server behind a staging URL and test like production
“The homepage loads” is a weak test. Hit the same code paths your users hit.
Test login, checkout, uploads, contact forms, cron-driven jobs, and any API routes.
Create a staging DNS record
In your DNS zone, add:
staging.example.com→ A record toNEW_SERVER_IP- (Optional) AAAA if you serve IPv6
Wait for the record to resolve, then verify:
dig +short staging.example.com A
curl -I https://staging.example.com
Common tests that catch real migration bugs
- PHP-FPM socket/path: confirm Nginx fastcgi_pass points to the correct socket (often
/run/php/php8.3-fpm.sock). - Uploads: upload an image; check filesystem perms and PHP upload limits.
- Outgoing mail: contact form and password reset. Verify it doesn’t queue forever.
- Scheduled tasks: run any cron-driven jobs manually and confirm logs.
If mail is part of the move, test routing and authentication deliberately.
These two guides prevent the classic “everything sends, nothing lands” problem: email authentication setup guide and email routing troubleshooting tutorial.
Step 9 — Final sync and write-freeze (the 5-minute discipline)
Dynamic sites need a brief write freeze. Without it, you lose the exact things users care about.
That includes uploads, comments, orders, and form submissions.
Recommended sequence
- Lower DNS TTL ahead of time (e.g., 300 seconds) if you control DNS.
- Put the site into a temporary “read-only” mode (app-level maintenance mode if available).
- Run the final
rsyncfor files and final DB sync/restore. - Recheck Nginx/PHP logs on the new server.
- Cut DNS to the new VPS.
- Disable maintenance mode after you confirm traffic lands on the new IP.
Final rsync (run twice if you’re nervous)
rsync -avz --delete \
root@OLD_SERVER_IP:/var/www/site1/ /var/www/site1/
Step 10 — DNS cutover and verification (fast checks, not guesswork)
Update the A/AAAA records for example.com and www.example.com to the new IP.
Then confirm what the world sees using multiple resolvers.
Verify DNS answers
dig +short example.com A @1.1.1.1
dig +short example.com A @8.8.8.8
Verify you’re hitting the new server
Add a temporary header on the new server for 10 minutes:
# in the server{} block for the domain
add_header X-Migrated-To "NEW-VPS" always;
Reload and check:
sudo nginx -t && sudo systemctl reload nginx
curl -I https://example.com | grep -i x-migrated
Remove the header once you’ve confirmed the cutover.
Step 11 — Post-cutover cleanup and performance sanity checks
Once traffic shifts, focus on risk reduction. Watch errors, watch load, and keep the old server intact.
Keep it until you’re confident everything is stable.
Quick diagnostics (new server)
sudo tail -n 200 /var/log/nginx/error.log
sudo tail -n 200 /var/log/nginx/access.log
sudo journalctl -u php8.3-fpm -n 200 --no-pager
free -h
sudo ss -s
Fix the two common PHP-FPM pitfalls
- 502/504 errors: usually wrong socket path, too-low
pm.max_children, or timeouts. Use a structured approach; this guide is worth keeping: VPS troubleshooting tutorial for 502/504. - Slow first byte: often DNS, PHP worker saturation, or missing opcode cache. Confirm opcache is enabled and sized appropriately for your PHP version.
Don’t delete the old server yet
Keep it online (and firewalled to your IP if possible) for at least 48–72 hours.
It’s your rollback button and your reference copy.
Rollback plan (write it down before you cut over)
Rollback shouldn’t require improvisation. If something goes wrong, you want two clean actions.
Send traffic back. Then buy yourself investigation time.
- Re-enable maintenance mode (to stop writes while you decide).
- Point DNS A/AAAA back to
OLD_SERVER_IP. - Confirm traffic returns using
digand a temporary response header. - Document the exact failure: missing PHP module, wrong permissions, wrong upstream, TLS chain issue.
If your DNS TTL is still high, rollback takes longer.
That’s why TTL planning is part of the checklist, not a nice-to-have.
Hardening after the migration (small, safe steps)
Handle security and performance changes after the move. Each change deserves its own test and an easy undo.
- Access control: limit SSH and admin endpoints to known IPs; use keys, not passwords.
- Basic bot control: block obvious bad crawlers and rate-limit abusive paths.
- Monitoring: set resource alerts and log checks so you see issues before customers do.
For a practical setup, see Server monitoring tutorial.
Summary: a repeatable web server migration you can trust
This web server migration tutorial uses one principle throughout: repeat safe steps until the diff is close to zero.
Stage first. Sync more than once. Verify with headers and logs.
Change DNS only after you can prove the new server behaves the way the old one did.
If you want the move to be quicker and less stressful, run the destination on infrastructure you can scale and support.
Start with a HostMyCode VPS, or choose managed VPS hosting if you’d rather have experts handle patching, service health, and recovery work.
If you want a predictable cutover instead of a late-night scramble, HostMyCode is a practical place to land: fast NVMe VPS plans, clean Linux images, and migration help when you need it. Pick a HostMyCode VPS for full control, or choose managed VPS hosting to hand off maintenance and incident response.
FAQ
How much downtime should I expect with this process?
For most small-to-medium PHP sites, 1–5 minutes of write freeze is realistic. User-visible downtime can be close to zero if you stage properly and keep TTL low.
Can I migrate without lowering DNS TTL?
You can, but rollback becomes slow and some users will keep hitting the old IP for hours. If possible, lower TTL 24–48 hours before cutover.
What’s the safest way to confirm traffic is on the new VPS?
Add a temporary response header on the new server (like X-Migrated-To), reload Nginx, then check with curl -I. Remove it after you’ve verified the cutover.
Why do I get 502 errors only after migration?
Most often: Nginx fastcgi_pass points to the wrong PHP-FPM socket, PHP-FPM isn’t running, or PHP worker limits are too low for real traffic.
Should I keep the old server running after cutover?
Yes. Keep it for 48–72 hours as a rollback option and as a reference copy for configs and files. If possible, restrict access to your IP.