Zero-Downtime Migration: Moving From Legacy PHP to React/Node
"We need to modernize our application but we can't afford any downtime. Our clients are in different time zones, so there's never a good maintenance window."
This is how most migration projects start. The client had a monolithic PHP application from 2012 running their business. It worked, but it was becoming impossible to maintain and extend.
They wanted React + Node.js. They wanted zero downtime. They were sure it was impossible.
It took 6 weeks. We had zero minutes of downtime. Here's how.
The Old Application
A classic PHP monolith:
- Single codebase handling everything
- MySQL database with 87 tables
- jQuery spaghetti on the frontend
- Server-side rendering for everything
- No automated tests (obviously)
The application had been running for 11 years with only minor updates. It was slow but stable.
The Strategy: Strangler Fig Pattern
Instead of a "big bang" rewrite, we used the strangler fig pattern. Named after a type of vine that gradually grows around a tree until it can stand on its own.
The plan:
- Put a reverse proxy (nginx) in front of everything
- Build new features in React/Node
- Migrate existing features one route at a time
- The proxy decides which backend handles each request
- Eventually, all routes point to the new system
Users never notice the transition because it happens gradually.
Step 1: Set Up the Proxy
First, I put nginx in front of the PHP application. The nginx config looked like this:
upstream php_backend {
server localhost:8080;
}
upstream node_backend {
server localhost:3000;
}
server {
listen 80;
server_name app.client.com;
# New API routes go to Node
location /api/ {
proxy_pass http://node_backend;
}
# Everything else goes to PHP (for now)
location / {
proxy_pass http://php_backend;
}
} At this point, 100% of traffic still goes to PHP. But now we have control.
Step 2: Shared Database Access
Both the old PHP app and new Node app needed to access the same MySQL database during the transition. I created a read-only user for the Node app initially to prevent accidents.
For writes, I built a simple API wrapper in PHP that Node could call. This let us migrate reads first (safer) before migrating writes (riskier).
Step 3: Build the New Authentication
First feature to migrate: user authentication. This was critical because it touched everything.
The approach:
- Node.js reads from the same user table PHP uses
- Password hashing had to match (PHP's
password_hashis bcrypt) - Sessions stored in Redis instead of PHP sessions
- JWT tokens for API authentication
Both systems could now authenticate users. I deployed this but didn't route any traffic to it yet.
Step 4: Migrate One Route at a Time
I started with the simplest pages: static content and read-only views.
For each route:
- Build the React component
- Build the Node.js API endpoint
- Test thoroughly in staging
- Update nginx config to route that path to Node
- Deploy with
nginx -s reload(zero downtime) - Monitor for issues
Updated nginx config example:
# Dashboard now handled by React/Node
location /dashboard {
proxy_pass http://node_backend;
}
# Reports page migrated
location /reports {
proxy_pass http://node_backend;
}
# Everything else still PHP
location / {
proxy_pass http://php_backend;
} Each deployment took about 30 seconds. Users on other pages never noticed.
Step 5: Database Migrations
The scariest part: changing the database schema while both systems are live.
The process:
- Add new columns/tables (never remove old ones yet)
- Make Node write to new schema
- Make Node read from new schema
- Keep PHP writing to old schema
- Background job copies old data to new format
- Once all PHP routes migrated, remove old columns
This meant running dual writes for a few weeks, but it was safe.
Step 6: The Complex Features
Some features were too complex to migrate in one shot. For these, I used feature flags.
Users could be gradually moved to the new version:
// Feature flag in Node
if (user.beta_features || Math.random() < 0.1) {
// New React version
return renderNewCheckout();
} else {
// Proxy to old PHP version
return proxyToPhp();
} This let me roll out risky changes to 10% of users first, then 50%, then everyone.
Step 7: Monitoring Everything
I set up aggressive monitoring during the migration:
- CloudWatch alerts for error rate spikes
- Response time tracking per route
- Database query performance monitoring
- User session tracking across both systems
Any route that showed increased errors got rolled back immediately.
The Timeline
| Week | Work |
|---|---|
| Week 1 | Proxy setup, database access, auth migration |
| Week 2 | Static pages, read-only features (30% migrated) |
| Week 3 | Simple CRUD operations (60% migrated) |
| Week 4 | Complex features with feature flags (80% migrated) |
| Week 5 | Remaining edge cases (95% migrated) |
| Week 6 | Final routes, turn off PHP backend |
The Results
What Went Wrong
Not everything was smooth:
- Session conflicts: PHP and Node both tried to set cookies with the same name. Fixed by namespacing them.
- Date formatting: PHP and JavaScript handle timezones differently. Standardized on UTC everywhere.
- File uploads: The old system stored files on the server filesystem. Had to migrate to S3 mid-project.
- Background jobs: PHP had cron jobs that needed rewriting. Moved to a proper job queue.
Each issue was caught in staging or by the 10% rollout, never in full production.
Key Lessons
Never do a "big bang" migration if you can avoid it.
Gradual migrations are lower risk, easier to debug, and let you learn as you go. Big rewrites fail more often than they succeed.
Proxy layers give you incredible flexibility.
nginx was the MVP here. Being able to route traffic at the URL level meant I could test in production with real traffic without risking the whole application.
Database changes are the hardest part.
Schema migrations with two systems writing to the same database require careful planning. Always add before you remove. Keep old columns around longer than you think you need to.
Feature flags are essential for risky changes.
Being able to roll out to 10% of users first saved us multiple times. Issues that weren't obvious in staging became clear with real user traffic.
Would I Do It Again?
Yes. Zero-downtime migrations are more work upfront, but they're worth it for production applications that can't afford maintenance windows.
The client was skeptical at first. By week 3, when we were halfway migrated and users hadn't noticed anything, they were believers.
The old PHP app served its purpose. The new React/Node app will serve its purpose. And someday, someone will migrate away from it too. That's just how software works.