Project Overview
A Delhi-Based Computer Hardware Retailer sells computer hardware and gaming PCs through a WooCommerce store with about 2,000 products and more than 9,000 historical orders. Most of its traffic comes from daily Google Ads and Google Shopping campaigns, so every hour the checkout is broken costs real money.
The client wanted to move the store from a Linux VPS to its own office server: an 8-core Ryzen machine with 48 GB of RAM running Windows Server 2022. The same machine also runs the business's Tally accounting, which five or six staff use remotely every day. NIMU Technologies planned and carried out the server migration over four days, then stabilised, sped up and secured the new setup.
| Item | Details |
|---|---|
| Store size | About 2,000 products, 9,000+ historical orders, traffic mainly from Google Ads |
| From | Linux VPS (Nginx-style stack, MySQL, Redis) |
| To | Windows Server 2022, IIS 10, PHP 8.4, MySQL 8.4 |
| Constraint | Same server runs Tally; no paid licences or extra hosting |
| Timeline | Four days, from cutover through stabilisation and security hardening |
The Challenge
The old VPS was being limited by its provider, so once cutover began there was no going back. The brief came with four hard constraints:
- Sales could not stop. The store runs paid Google campaigns every day, so a broken checkout burns ad spend every hour.
- No lost orders. Customers kept buying on the old server until the switch, and every paid order had to reach the new one.
- No new costs. Free services only: no extra hosting, VPN subscriptions or paid licences.
- Tally must keep working. Accounting staff connect to the same machine every day.
There was also a security problem to solve. Before the project, the office router forwarded every port to the server, including remote desktop and file sharing. The store's back office was effectively on the open internet.
Phase 1: Going Live Without Inbound Ports
The first cutover failed with Cloudflare error 525, even though the server answered HTTPS perfectly from inside the office. The evidence pointed outside the server:
- IIS, PHP, MySQL and the Cloudflare Origin Certificate all worked locally, returning HTTP 200.
- Tests from the server to its own public IP passed, but these were hairpin NAT tests that never left the building.
- A real outside test from a mobile network failed the TLS handshake, and a packet capture showed the traffic never reached the server's network card.
- A raw TCP listener on a second port, unrelated to IIS, also received nothing, while remote desktop traffic from the same tester did arrive.
Something between the internet and the server, either the ISP or the fibre router, was intercepting web traffic, and neither was under our control. So instead of opening ports, we stopped needing them. We installed Cloudflare Tunnel (cloudflared) as a Windows service. The server makes an outbound connection to Cloudflare, and visitors reach the store through it.
- Published routes for the root domain and www, pointing to the local IIS site over HTTPS with the correct origin hostname.
- A Cloudflare redirect rule sends www to the root domain with a 301.
- The service restarts on failure, so it recovers from crashes and reboots without anyone logging in.
The store was live within an hour of choosing this route.
Phase 2: Stabilising WordPress on IIS
Most of the hardest bugs came from one Windows-specific behaviour: when PHP writes to stderr, IIS replaces the whole page with that text and returns an error.
| Symptom | Root cause | Fix |
|---|---|---|
| Admin pages replaced by PHP notices | Debug mode left on, and the log file was not writable, so notices went to stderr | Turned debug off; gave PHP a dedicated, writable error log outside the web root |
| "W3 Total Cache Error" on every admin page | Cache drop-in file copied from Linux, plugin folder missing | Set the orphaned drop-in aside (renamed, not deleted) |
| Blank admin after that | Redis object-cache drop-in, but no Redis on Windows | Set the drop-in aside; WordPress fell back to its built-in cache |
| Dashboard and orders list blank, no error logged | A marketing plugin's admin-menu badge counter ended the request silently | Traced it with a temporary must-use plugin, then disabled only that counter |
| Checkout payment step failing, REST API returning 500 | A payment plugin logged a debug line on every REST call; with no log file, IIS turned it into a 500 | Set error_log in php.ini, which fixed REST, payments and pixel tracking at once |
How we found a silent crash. With no error in any log, we added a small temporary must-use plugin that ran only when a secret query string was present. At shutdown, it recorded which WordPress hook was running. It pointed straight at one plugin's admin-menu counter, so we disabled that single filter instead of the whole plugin, keeping the client's Google Ads integration live.
Phase 3: No Order Left Behind
The database snapshot was taken a day before cutover, so any order placed in between existed only on the old server. We found exactly one paid order and moved it across under its original order number, without sending the customer a duplicate email.
- Measured the gap first. On the old server, we listed every order and customer account created after the snapshot time. Result: one paid order, no new accounts.
- Checked for number collisions. The new database's next ID was lower than the missing order's number, so a future order could have reused it. We confirmed the order number and its line-item IDs were still free.
- Exported the order as SQL, not by retyping it. The order row, its 61 details, 4 line items, 33 line-item details and 4 order notes were exported with quoted values, so addresses and payment references stayed exact.
- Imported it in one transaction. If any statement had failed, nothing would have been saved.
- Verified in the admin. Customer, items, shipping and total matched the original to the rupee.
- Cleaned up. The SQL file and notes, which held customer data, were deleted from both servers.
Because the import went straight into the database, WooCommerce sent no emails, and the customer saw nothing unusual.
Phase 4: Recovering from a Power Cut
On the first morning, the home page loaded but "Add to cart" failed with a database error. MySQL had stopped and refused to start. The cause was two separate events:
- Two unexpected shutdowns overnight. Windows logs showed the server lost power twice in five hours, with no crash code and no power-button press.
- An earlier permissions change. Folder permissions on the data drive had been reset, removing the MySQL service account's write access to its own data files. MySQL kept running until the first power cut, then could not restart.
The MySQL error log named the exact file it could not write. We restored write access for the MySQL service account on the database folder only, started the service, and let InnoDB's crash recovery finish. No data was lost, and the reconciled order from Phase 3 was intact. Both MySQL and the tunnel service now restart automatically on failure.
The same permission reset explained why plugin updates kept leaving the site in maintenance mode: WordPress could not write its own files. We gave the web server write access to wp-content only, kept wp-config.php and WordPress core read-only, and turned off automatic plugin updates so changes happen deliberately.
Phase 5: Speed, Images and the Google Shopping Feed
Once the store was stable, a slowdown showed that a Linux-to-Windows move breaks things that never appear as errors. Each fix below came from a log, not a guess.
| Problem | What the logs showed | Fix |
|---|---|---|
| Product and banner images missing | 803 image requests failing with IIS 404.3: IIS had no MIME type for .webp | Added the WebP MIME type at server level, after backing up the IIS config |
| Site design lost after a restart | Cached pages referenced optimised CSS files that did not exist, because the plugin could not write them | Disabled the CSS optimiser and cleared the stale page cache |
| Whole site slow, 10 to 23 seconds per page | A bot opened 2,148 add-to-cart and remove links in 30 minutes, with no referrer, filling all 30 PHP workers | A Cloudflare custom rule challenges cart links without a store referrer; bot traffic fell from about 70 to 3 requests a minute |
| Pages still 3.5 to 4 seconds | PHP OPcache was off, as it is by default on Windows | Enabled OPcache with a file cache fallback; home page to about 1.8 seconds |
| Google for WooCommerce could not reach Google | PHP had no certificate bundle, so every outbound HTTPS call failed with cURL error 60 | Installed Mozilla's CA bundle (checksum verified) and pointed PHP at it |
| Product feed stuck since cutover | The Linux server ran WordPress's scheduler from cron; nothing replaced it on Windows | A Windows scheduled task runs the scheduler every minute; 1,098 products synced the same afternoon |
The product feed matters most to this client: their Google Shopping campaigns depend on Google seeing current prices and stock.
Phase 6: Closing the Server to the Internet
The Windows security log recorded more than 25,000 failed remote desktop logins in a single day, from addresses around the world. The audit found that every successful login came from the client's own staff, network-level authentication was on, and account lockout had stopped the guessing. We moved fast anyway, because one leaked password would expose the store, the database and the accounts system.
Using only free tools, we built:
- Cloudflare Zero Trust (free plan, up to 50 users). The existing tunnel now also carries a private route to the server's office IP.
- Email one-time PIN sign-in in the Cloudflare One Client. Only named staff email addresses can enrol a device.
- Split tunnelling, which sends only the server's address through Cloudflare, so staff internet speed is unaffected.
- Two-way testing from outside the office: with the client disconnected, the server was unreachable; connected, remote desktop and Tally both worked.
- A closed router. DMZ off, and all seven port forwards disabled rather than deleted, so they can be restored if ever needed.
- External verification. Remote desktop, Tally, file sharing, HTTP and HTTPS ports all report closed. The website still works, because it never needed an open port.
Staff received a one-page WhatsApp guide to install the client and switch their remote desktop and Tally address to the server's private IP. Read more about our approach to cloud security.
Results
| Measure | Before | After |
|---|---|---|
| Home page server response, uncached | 20+ s (under bot load) | About 1.8 s |
| Product page server response, uncached | 20+ s | About 1.85 s |
| Inbound ports open to the internet | All (router DMZ) | None |
| Failed remote desktop logins | 25,000+ per day | No public remote desktop port to attack |
| Google Shopping products active | Feed stalled since cutover | 1,098 active, 0 disapproved |
| Orders lost in migration | Risk of 1 paid order | 0 (reconciled under its original number) |
| Added monthly cost | n/a | Rs 0 (free Cloudflare plans, built-in Windows tools) |
Linux to Windows WooCommerce Migration Checklist
A WordPress site that works on Linux will not simply work on Windows and IIS: the defaults differ in ways that fail silently. These are the checks we now run on every such move.
Before cutover
- Test from a genuinely outside network, never from the server or office LAN (hairpin NAT hides problems)
- Prefer an outbound tunnel over inbound port forwarding when the ISP or router is not under your control
- Set
error_login php.ini to a writable file, so warnings never reach stderr and break pages - Enable OPcache and point PHP at a CA certificate bundle
- Add MIME types for .webp and other modern formats in IIS
- Replace the Linux cron job with a Windows scheduled task if
DISABLE_WP_CRONis set - Remove cache drop-ins (
advanced-cache.php,object-cache.php) whose backends do not exist on the new server - Grant the web server write access to
wp-contentonly, and the database service to its data folder only
At cutover
- Note the exact snapshot time, then list orders and accounts created after it on the old server
- Move missing orders by exported SQL in one transaction, checking ID collisions first
- Disable automatic plugin updates until permissions and backups are confirmed
After cutover
- Read the IIS log for 404.x and 500 patterns, and for traffic floods on cart and search URLs
- Check that background jobs complete and that ad and shopping feeds sync
- Close every inbound port and move staff access to a zero-trust client
- Put the server on a UPS and set every critical service to restart on failure
What's Next
The store is live, fast and closed to the internet. The remaining work is routine hardening:
- Staged plugin updates, one at a time, each with a backup and a test checkout
- Re-enabling CSS optimisation now that the cache folder is writable
- Further isolating the web server and database service accounts
- A UPS for the server, to end unplanned power-offs
NIMU Technologies' Role
NIMU Technologies planned and carried out the migration end to end:
- Diagnosed the ISP-level block and published the store through Cloudflare Tunnel with no inbound ports
- Stabilised WordPress and WooCommerce on IIS, fixing PHP logging, cache drop-ins and a silent plugin crash
- Found and reconciled the one missing paid order under its original number
- Recovered MySQL after a power cut with no data loss and made critical services self-healing
- Brought page response from 20+ seconds to about 1.8 seconds and restored the Google Shopping feed
- Replaced open remote desktop and Tally ports with Cloudflare Zero Trust access for staff
Planning a Server Move for Your Store?
Whether you are moving WooCommerce to new hosting, your own hardware or a different operating system, NIMU Technologies can plan the cutover, keep orders flowing and secure the new server. Explore our server migration, WooCommerce development and performance optimisation services.