Your VPS will fail at the worst possible time
Here's an uncomfortable truth: that single VPS running your store isn't a matter of if it fails, it's when. A kernel update reboot, a Black Friday memory spike, a disk that fills up overnight. None of these are exotic failure modes, they're Tuesday.
The good news: fixing this is an infrastructure problem, not a rewrite. If you're running PHP, Node, or Python against a relational database, you can go from one box to a highly available setup without touching your application code. This applies whether you're on WooCommerce, Magento, or something custom.
Before you touch anything
Check these boxes first:
- Your app can run statelessly across multiple servers (or can be made to, sessions and uploads are the usual culprits)
- You have root, not just FTP
- You've scheduled a maintenance window for DNS and DB cutover
- You have a tested, recent backup
- You know your traffic pattern: peak RPS, DB connections, payload size
This walkthrough assumes a LAMP/LEMP-ish stack: Nginx or Apache, PHP-FPM or Node, MySQL or Postgres, Redis. Swap tooling as needed for other stacks, the pattern holds.
Step 1: Get state off local disk
This is the real blocker to scaling horizontally. Local disk state has to go before you add a second server.
Sessions → move to Redis:
; php.ini
session.save_handler = redis
session.save_path = "tcp://10.0.0.5:6379"
Uploaded media → object storage (S3-compatible) or shared NFS. For WooCommerce, WP Offload Media handles this out of the box. For custom apps, swap filesystem writes for an SDK call:
$s3->putObject([
'Bucket' => 'store-uploads',
'Key' => $filename,
'Body' => fopen($tmpPath, 'r'),
]);
Cache → Redis or Memcached instead of local disk/OPcache-only.
Step 2: Put a load balancer in front, even with one backend
Spin up a small VM or a managed LB now, before you have a second server. Point DNS at the LB's IP immediately. This decouples DNS from your server count forever, no more DNS changes for future scaling.
upstream app_servers {
server 10.0.0.10:80 max_fails=3 fail_timeout=30s;
server 10.0.0.11:80 max_fails=3 fail_timeout=30s backup;
}
server {
listen 443 ssl;
server_name shop.example.com;
location / {
proxy_pass http://app_servers;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Step 3: Clone the app server
Once sessions and media are externalized, your app server is basically stateless. Build a second node from the same provisioning script, Ansible, Docker, or a shell script, doesn't matter, just make it reproducible.
# minimal provisioning sanity check
php -v
nginx -v
composer --version
cat /etc/php/8.2/fpm/pool.d/www.conf | grep pm.max_children
Add it to the upstream block, drop the backup flag, confirm both nodes handle real traffic before moving to the database layer.
Step 4: Replicate the database
Highest-risk step. Set up primary-replica replication or move to a managed cluster.
# primary my.cnf
server-id = 1
log_bin = /var/log/mysql/mysql-bin.log
binlog_do_db = shop_production
# replica my.cnf
server-id = 2
relay-log = /var/log/mysql/mysql-relay-bin.log
CHANGE MASTER TO
MASTER_HOST='10.0.0.5',
MASTER_USER='replicator',
MASTER_PASSWORD='***',
MASTER_LOG_FILE='mysql-bin.000003',
MASTER_LOG_POS=154;
START SLAVE;
Check lag before cutover:
SHOW SLAVE STATUS\G
-- Seconds_Behind_Master should be 0
Once stable, cut over writes using a virtual IP or ProxySQL, not a manual config edit during an incident.
Step 5: Real health checks, not port pings
upstream app_servers {
server 10.0.0.10:80 max_fails=2 fail_timeout=10s;
server 10.0.0.11:80 max_fails=2 fail_timeout=10s;
}
# haproxy / nginx plus
option httpchk GET /health
http-check expect status 200
Your /health endpoint needs to actually check dependencies:
try {
$pdo = new PDO($dsn, $user, $pass);
$redis = new Redis();
$redis->connect('10.0.0.5', 6379);
http_response_code(200);
echo 'ok';
} catch (Exception $e) {
http_response_code(503);
echo 'unhealthy';
}
Prove it actually works
Don't trust the config, test it:
- Kill a node during low traffic, confirm the LB routes around it inside your
fail_timeoutwindow and response times stay flat - Watch
Seconds_Behind_Masterduring peak checkout load, not just idle - Load test both nodes:
ab -n 5000 -c 50 https://shop.example.com/and diff the access logs - Simulate a DB failover in staging, measure reconnect time (should be seconds, not minutes)
Baseline to aim for: zero customer-visible errors when one app node dies, database failover under 30 seconds with retry logic in place.
Mistakes that will bite you
- Sticky sessions instead of externalized sessions: works fine until a node dies and half your logged-in users get booted
- Duplicate cron jobs: order processing and cache warming running on both nodes doubles the work, pin them to one node or use a job queue
- Shallow health checks: pinging port 80 says nothing about whether the DB connection is alive
-
Replication as a backup strategy: it protects against hardware failure, not against a bad
DELETEthat replicates instantly - Never testing failover: the first failover shouldn't happen during a real outage
Full walkthrough with more context here: How to move ecommerce infrastructure from a single VPS to HA without rewriting the application
Originally published on binadit.com
Top comments (0)