Built to keep running: high availability for a 15k-user WordPress Multisite
We moved a 15,000-user, write-heavy WordPress Multisite from a managed WP service to a high-availability stack on AWS that we run end to end. The managed service was cheaper, and still is, but it ran the platform as a single environment: when one piece failed, the site went down. On AWS every layer runs in at least two availability zones, so losing a server, the database primary or a whole zone no longer takes the platform offline.
| Managed WP service | High availability on AWS | |
|---|---|---|
| Topology | Single environment | Every layer in two or more availability zones |
| Web server failure | Outage until recovered | Replaced automatically, no downtime |
| Database failure | Outage until restored | Replica promoted, brief pause |
| Zone failure | Outage | The other zone keeps serving |
| Patching | Maintenance windows | Rolling, no maintenance window |
| Traffic spikes | Capped by the plan | Auto Scaling and read replicas |
| Monthly cost | Lower | Higher |
Context
The platform is a subdirectory WordPress Multisite with around 15,000 users spread across about 40 subsites. It is write-heavy: most traffic is logged-in people publishing, editing and uploading, so full-page caching helps little and almost every admin request reaches PHP and the database.
The managed WP service ran it well day to day, and for the price it was hard to beat. The problem was the shape of the setup: one environment, with no automatic failover. A failed server, a database problem or a zone outage meant the site was down until the provider restored it, and patching meant maintenance windows.
For a platform where thousands of people publish every day, each hour of downtime is lost work, lost trust and a full support queue. We decided resilience was worth paying for, and that we wanted to own it: one highly available stack, defined in Terraform, that we operate and can explain end to end.
Constraints
- No single point of failure. Every layer must survive losing an instance or an availability zone without a human stepping in.
- No planned downtime after cutover. Deploys, OS patching and database maintenance happen while the site is serving.
- No data loss. Every post, user and upload on every subsite had to arrive intact, including media history going back years.
- A short, planned cutover. One four-hour maintenance window on a Saturday night, with a rollback path to the managed WP service until DNS settled.
- No SSH and no long-lived keys on hosts. All shell access goes through SSM Session Manager and is logged.
- Everything in Terraform. If it isn't in code, it can't be rebuilt in another zone.
- Minimal changes to WordPress itself. The application gained only what the infrastructure needed: WP Offload Media, a Redis object-cache drop-in and a database drop-in for read/write splitting.
What high availability means here
High availability isn't one feature. It is the answer to a list of specific failures, so we wrote that list first and designed each layer against it.
In a single environment, any server or database failure becomes an outage, however good the provider is. A zone failure is rare but real, and it takes down everything in that zone at once. Everything in the architecture below exists to turn those rows into "no downtime".
Architecture
Stateless web servers sit between a shared edge and shared state, and every stateful layer keeps a second copy in another availability zone. Any instance can die, and the next one boots from the same launch template, mounts the same code and talks to the same data.
- Edge: CloudFront, which has no single zone to lose, with two origins: the ALB for dynamic pages and wp-admin, and S3 for offloaded media. A CloudFront Function rewrites Multisite paths on the way in.
- Load balancer: an ALB spanning availability zones, health-checking every instance and guarded by AWS WAF.
- Web tier: an Auto Scaling group of Apache + PHP instances spread across zones. An instance that fails its health check is replaced automatically. No key pair, no port 22: shell access is SSM Session Manager only.
- Code: WordPress core, themes and plugins on EFS, which stores data across zones and mounts through an access point, so every instance serves the same release.
- Uploads: WP Offload Media writes new media straight to S3, so no upload lives on an instance that might disappear.
- Database: Aurora MySQL with the writer and readers in different zones, behind RDS Proxy. If the writer fails, a reader is promoted.
- Object cache: ElastiCache for Redis with a replica in a second zone and automatic failover.
- Everything above: Terraform, from the VPC to the CloudFront Function code.
Key decisions and trade-offs
Each decision below either removes a single point of failure or keeps the platform usable while a failed part is replaced.
Stateless instances: code on EFS, media on S3
An instance that holds anything unique is a single point of failure, however many copies of it run. So nothing unique lives on an instance: code comes from EFS, uploads go to S3, the object cache lives in Redis and the data in Aurora. An instance is just a launch template plus a mount, which is what lets the Auto Scaling group replace one in minutes without anyone deciding whether it mattered.
- Trade-off: code reads over the network. PHP reading thousands of files from EFS is slower than reading from local disk. OPcache absorbs most of it, with timestamp checks relaxed so PHP doesn't ask EFS whether every file changed on every request; deploys reset the cache.
Read/write splitting with RDS Proxy and Aurora
WordPress assumes a single database. We kept the writer for writes and moved reads to Aurora's replicas, using the LudicrousDB drop-in to route each query: writes to RDS Proxy's default endpoint, reads to its read-only endpoint, which spreads them across the readers.
The proxy earns its place twice, and the first reason is availability. During a writer failover it holds client connections while a reader is promoted, so PHP sees a short pause instead of a burst of database errors. Day to day it also pools connections: PHP opens one per request, so every scale-out multiplied connections on Aurora, and the proxy absorbs that.
- Trade-off: replica lag. A user who saves a post and immediately reloads must not read a stale replica. Once a request has written, the drop-in sends that request's remaining reads to the writer.
- Trade-off: cost and pinning. RDS Proxy is billed per database vCPU, and some session-level statements pin a client to one connection, which erodes pooling. The pinning metric is on the dashboard for that reason.
The next three decisions act on the same request, one after another, as it crosses the edge.
CloudFront Function URI rewriting for Multisite
In a subdirectory Multisite, every subsite serves the same core files under its own prefix: /site-a/wp-includes/js/jquery.js and /site-b/wp-includes/js/jquery.js are one file. Left alone, CloudFront caches a copy per subsite and its path-based cache behaviours can't match them.
A CloudFront Function on viewer request strips the subsite prefix from core-asset paths before the cache lookup. One cached object now serves every subsite, and the static behaviours match cleanly. We chose a CloudFront Function over Lambda@Edge because this is a string rewrite: it runs in under a millisecond at the edge, costs a fraction as much, and needs no regional deploy.
It helps availability too. Every request served from the edge cache never reaches the origin, so a traffic spike on static assets lands on CloudFront rather than on PHP.
WAF-based ALB allowlisting
The ALB has to be public for CloudFront to reach it, which means anyone could bypass the CDN and hit it directly. A WAF web ACL on the ALB blocks by default and allows only requests that carry CloudFront's secret origin header, plus a small IP set for direct testing from the office.
The alternative was a security group referencing CloudFront's managed prefix list. That list counts as dozens of rules against the security group quota, and it can't express "this header or that IP". WAF can, and it logs every block, so a rejected request is easy to explain.
mod_remoteip for correct client IPs
Behind CloudFront and the ALB, Apache sees the ALB's private address as the client. That breaks access logs, login throttling, comment spam checks and anything else that keys on IP.
mod_remoteip reads X-Forwarded-For and walks it back through proxies we trust: the VPC range for the ALB, and CloudFront's published ranges. The first untrusted address is the real client. Trusting only known proxies matters; trusting the header blindly would let anyone forge their IP.
What high availability costs
High availability on AWS costs more each month than the managed WP service did, and we went in knowing that. The managed price covered one environment; ours also pays for redundancy that sits idle on most days, which is exactly its job.
The extra spend comes from four places:
- Second copies of everything. Web capacity, an Aurora reader and a Redis replica in a second zone, sized so one zone can carry the load alone.
- Managed building blocks. RDS Proxy, EFS, WAF and the load balancer each add a line that a single server doesn't have.
- Cross-zone networking. Traffic between zones is billed, and so is a NAT gateway in each zone.
- Our own time. Patching, upgrades and on-call are now ours rather than the provider's.
We justified it by comparing the monthly premium with the cost of one bad outage. For a platform thousands of people use to publish every day, a multi-hour outage costs more in lost work, support load and trust than the difference in hosting, and high availability turns those outages into non-events. Auto Scaling keeps the premium in check by running fewer instances off-peak.
What broke
None of these took the site down. All of them were cases where the new platform did exactly what it was told, and what it was told was incomplete.
Cron that stopped without a sound
wp-config.php came over with DISABLE_WP_CRON set to true. On the managed WP service that was correct, because the provider ran WordPress cron from a system scheduler. That scheduler didn't come with us, so after cutover nothing ran scheduled events at all: no scheduled posts, no queued emails, no cleanup. Nothing logged an error, because nothing was failing; it just wasn't happening.
Fix: a single scheduled job that runs due events for every subsite with WP-CLI. One job, not a crontab per instance, or an Auto Scaling group of N instances runs cron N times. Lesson: migrate behaviour, not just configuration. A flag that disables something assumes something else replaces it.
EFS access points override root
An EFS access point enforces its own POSIX user on every operation that passes through it, root included. Our deploy steps ran as root and expected root's powers; on the access point, root became the web server's user. chown failed with permission errors and new files didn't get the owners the scripts asked for.
Fix: treat the access point as the ownership model. Its user and group are the web server's, its root directory is created with those owners, and the deploy no longer tries to change ownership on EFS. Lesson: an access point is an identity override, not just a mount path.
Aurora's require_secure_transport and MYSQLI_CLIENT_SSL
We enforced TLS on Aurora with require_secure_transport = ON. The mysql client from a shell connected fine, because it prefers TLS by default. WordPress didn't: mysqli doesn't ask for TLS unless told to, so Aurora refused every connection as insecure transport.
Fix: define( 'MYSQL_CLIENT_FLAGS', MYSQLI_CLIENT_SSL ); in wp-config.php. Lesson: a successful test from the shell proves the shell works. That theme comes back below.
The single-site .htaccess and the redirect loop
The .htaccess that came over with the codebase was WordPress's single-site default. A subdirectory Multisite needs extra rewrite rules that map /site-a/wp-admin/ and friends back to the real core files. Without them, subsite admin URLs bounced between WordPress and Apache until the browser gave up.
Fix: the Multisite subdirectory rewrite block, now kept in the repo and deployed with the code. Lesson: diff every config file against the install type, not just against the old server.
Shell is not PHP: the site-16 upload failure
Uploads failed on one subsite, site 16. Every check from an SSM shell passed: the paths existed, the bucket accepted a test object, the credentials resolved. The upload still failed, because the shell checked the paths we expected, and PHP used the path WordPress gave it. Site 16 still had an upload_path option from years earlier, set to an absolute path on the managed WP service's server. WP Offload Media writes each file locally before copying it to S3, so PHP tried to write into a directory that didn't exist on the new instances.
Fix: delete the stale upload_path and upload_url_path options with WP-CLI, then audit every subsite's options table for absolute paths from the old host. Two more subsites had the same leftover and were fixed before anyone noticed. Lesson: reproduce inside the runtime. Run the test as the web server's user, through PHP and with PHP's configuration, before trusting the result. After the cron, TLS and upload issues, "does it work from PHP?" became the first question in every investigation.
Results
Since cutover in March 2026, the platform has come through every failure we've seen or caused without an infrastructure outage.
- Availability: 99.98% uptime since cutover. The one incident that reached users was short and did not come from a failed component.
- Instance failures: the Auto Scaling group has replaced unhealthy instances several times, with no user-visible errors and nobody paged.
- Failover drills: we terminated web instances, forced Aurora failovers and took a zone out of the ALB during low traffic. Aurora failovers paused writes for about 20 to 30 seconds, which RDS Proxy turned into slower requests rather than errors.
- Maintenance: no planned downtime since cutover. OS patches roll out through instance refresh, and Aurora minor upgrades go through a failover.
- Admin performance: wp-admin p95 dropped from about 2.4 s to about 650 ms on the five busiest subsites, a side effect of moving reads to replicas and objects into Redis.
- Cost: higher than the managed WP service, as planned and explained above.
The gains that don't fit in a number matter as much. A failure at 3 a.m. is now a log line in the morning rather than a phone call. Every change arrives as a reviewed Terraform plan, nobody holds an SSH key, and when something degrades we can see which layer is responsible.
What I'd do differently
- Schedule failover drills from day one. We tested failover before cutover and then only now and then. A monthly drill that kills an instance and fails over the database would keep confidence current and catch drift early.
- Narrow the cost gap sooner. A Compute Savings Plan and reserved Aurora capacity for the always-on baseline, plus Graviton instances for the web tier, would have reduced the HA premium from the first month.
- Inventory what the old host did for free. The cron failure came from behaviour the managed WP service provided without it ever appearing in our code. A written list of "what does the host do for us" (schedulers, rewrite rules, PHP limits) would have surfaced it in staging.
- Make the ALB private with CloudFront VPC origins. CloudFront can now reach an internal ALB directly. That removes the public endpoint, and with it most of the reason for the WAF allowlist.
- Smoke-test from inside PHP, per subsite. A post-deploy check that uploads a file and schedules an event on every subsite would have caught the cron and site-16 issues in minutes.
