SCALING
August 5, 2026

How to Move from One Server to a Load-Balanced Setup

7 min read
Author
CloudStick Team
Server Infrastructure
Share this article
How to Move from One Server to a Load-Balanced Setup
CloudStick
Going
Multi-Server

What Actually Changes When You Add a Second Server

Going load-balanced is not "add a bigger box" or even "add a second box" — it is a change to where state lives. On a single server, PHP-FPM writes session files to local disk, uploaded images land in that site's home directory, and the database sits right there too, so every request has everything it needs on one machine. The moment a second, identical app server joins the pool, a browser's next request can land on either one, and if a session file or an uploaded file only exists on the first machine, requests to the second machine start failing in ways that look random: users get logged out mid-session, an image upload succeeds but then 404s on reload, a shopping cart empties itself.

The database is usually the one piece that does not get duplicated. Most load-balanced setups keep MariaDB on its own dedicated server that both app servers connect to over a private network, rather than running MySQL locally on each web node and dealing with replication and split-brain risk. If you have not already moved the database off the app server, do that first — it is a simpler, separate migration, and a load-balanced setup with a database still living on "server one" just recreates the single point of failure you were trying to remove.

Choosing and Configuring a Load Balancer

Nginx's upstream module is enough for most setups: it runs as a reverse proxy in front of two or more identical app servers and distributes requests using round robin by default. Round robin sends requests to each backend in turn, which works well when requests cost roughly the same amount of CPU and time. If your traffic mixes cheap static requests with heavy dynamic PHP pages, least_conn is usually the better algorithm — it sends the next request to whichever backend currently has the fewest active connections, so a backend stuck processing a slow request does not keep getting piled on.

upstream backend {
least_conn;
server 10.0.0.11:80 max_fails=3 fail_timeout=30s;
server 10.0.0.12:80 max_fails=3 fail_timeout=30s;
}
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

A dedicated Nginx box as the load balancer works fine and costs you one more small server. The alternative is a managed load balancer from your cloud provider — a DigitalOcean Load Balancer or an AWS Application Load Balancer — which terminates TLS for you, runs health checks natively, and does not become its own single point of failure the way one self-managed LB server can. Either way, the backend app servers should be identical: same PHP version, same site code, same environment configuration, listening on their own private IP so the load balancer can reach them without exposing each one directly to the internet.

Handling Sessions and Shared State

This is the part that breaks sites when it is skipped, and it has to be solved before traffic hits two servers, not after users start reporting logouts. There are three practical paths: sticky sessions, a shared session store, or a fully stateless app design, and most real deployments end up combining the last two.

Sticky sessions, configured with Nginx's ip_hash, pin a given visitor's requests to the same backend based on their IP address, so their local session file always exists on the server they land on. It is the fastest fix but it defeats even load distribution — one backend can end up overloaded if a disproportionate share of active users hash to it, and it breaks entirely if that specific backend goes down, since the visitor's session was never anywhere else.

upstream backend {
ip_hash;
server 10.0.0.11:80;
server 10.0.0.12:80;
}

The more durable fix is a shared session store: point PHP-FPM's session handler at Redis instead of local disk, so every backend reads and writes the same session data regardless of which one handles a given request. Uploaded files need the same treatment — either mount shared storage (NFS) at the same path on every app server, or better, push uploads to object storage (S3-compatible) and serve them from there instead of from local disk on any web node. Once sessions and uploads are shared, any backend can serve any request and round robin or least_conn works as intended.

PREREQUISITE

Do not point a load balancer at two servers until session storage and file storage are shared between them. Solving this after you add the second server means solving it while users are actively logged out and uploads are actively 404ing — solve it first, on the single server, then add the second one.

Health Checks and Failover

A load balancer that keeps sending traffic to a dead backend is worse than no load balancer at all, because now a fraction of every visitor's requests silently fail. Open-source Nginx handles this with passive health checks: the max_fails and fail_timeout parameters shown above tell it to stop sending traffic to a backend after a number of failed attempts, and retry it again after the timeout expires.

That is reactive — it removes a backend only after real requests have already failed against it. Managed load balancers (DigitalOcean, AWS ALB) instead run active health checks: they poll a lightweight endpoint like /healthz on each backend every few seconds and pull a backend out of rotation the moment it stops responding correctly, before it costs a real user a failed request. If you are self-managing Nginx as the load balancer and want the same behavior, a small external script or systemd timer hitting each backend's health endpoint and rewriting the upstream block (or switching to Nginx Plus, which adds active checks natively) closes that gap. Either way, the health check endpoint should verify the app can actually reach its dependencies — PHP-FPM responding and the database connection working — not just that Nginx itself is up, since a backend that answers HTTP but cannot reach the database is still a dead backend from the user's perspective.

Keeping Deploys Consistent Across Servers

Deploying code now means shipping the same commit to every backend server at effectively the same time, not SSHing into one box and running a git pull. If server one is running yesterday's code and server two is running today's, the load balancer will happily route different visitors to different versions of your site, and you will spend an afternoon debugging a bug that "only happens sometimes" before realizing it only happens on one specific backend.

A deploy script that loops over every backend's IP and runs the same pull-and-restart sequence on each is the minimum viable version of this. This is also where managing multiple servers from a single place stops being a nice-to-have: CloudStick connects all of your backend servers to one dashboard, so you can see which server is running which deployed version, push identical changes across the pool, and use Server Transfer to move a site to a new node when you are scaling the pool up, without losing track of which box has drifted from the others. If your team is more than one person, the Teams feature keeps everyone deploying against the same connected set of servers instead of someone quietly SSHing into just one.

A Practical Migration Checklist

Work through this in order rather than adding the second server first and patching problems as they surface:

Move the database onto its own dedicated server, reachable from app servers over a private network, if it is not there already. Set up a shared session store (Redis) and point PHP-FPM at it instead of local disk. Move uploaded files to shared storage — NFS mounted at the same path on every backend, or object storage referenced by URL. Provision a second, identical app server: same OS, same PHP version, same site code, same environment variables, listening on a private IP. Stand up the load balancer, choose round robin or least_conn based on how uniform your request costs are, and configure max_fails/fail_timeout (or a managed LB's active health checks) so a dead backend is automatically pulled from rotation. Write or adapt a deploy script that pushes the same code to every backend in one pass, and test a full deploy before you rely on it under real traffic. Finally, kill one backend on purpose during low-traffic hours and confirm the site keeps serving requests without a visible blip — that single test tells you more about whether the setup actually works than reading any config file twice.

Leave a comment
Full Name
Email Address
Message
Contents