DEV Community

Cover image for How to move ecommerce infrastructure from a single VPS to HA without rewriting the application
binadit
binadit

Posted on Originally published at binadit.com

How to move ecommerce infrastructure from a single VPS to HA without rewriting the application

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"
Enter fullscreen mode Exit fullscreen mode

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'),
]);
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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;
Enter fullscreen mode Exit fullscreen mode

Check lag before cutover:

SHOW SLAVE STATUS\G
-- Seconds_Behind_Master should be 0
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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';
}
Enter fullscreen mode Exit fullscreen mode

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_timeout window and response times stay flat
  • Watch Seconds_Behind_Master during 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 DELETE that 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)