Skip to content

Running WordPress in production on AWS: from EC2+RDS to autoscaling cluster

Learn how to grow WordPress on AWS from a single server into a production cluster that adds and removes servers as traffic changes. This guide walks through each stage and shows how to move sessions, uploads, cache and the database off the servers so that losing one of them loses no data.

This guide is for engineering leads who already run WordPress on AWS (Amazon Web Services) and need it to handle more traffic without falling over. The warning signs are familiar. A single t3 server, a burstable EC2 virtual machine that slows down once it spends its CPU credits, stalls under launch traffic. Buying a bigger server only buys time, and one bad plugin update can set off a wave of 502 errors.

The answer is to make the WordPress servers stateless, meaning they keep no data of their own, and then run several of them behind a load balancer. Sessions and cache move to ElastiCache Redis (Amazon’s managed in-memory store), and uploads move to S3 (Amazon’s file storage). The database moves to RDS Multi-AZ, a managed MySQL with a standby copy in a second Availability Zone (a separate AWS data centre). CloudFront, Amazon’s content delivery network, then serves most pages and media from cache. The sections below follow the order most teams take. You go from one server to a separate database, then to an Auto Scaling Group (ASG) behind an Application Load Balancer (ALB), then to the cache and storage layers. By the end you will have a reference design that survives the loss of a server and grows by adding servers instead of buying bigger ones.

The wrong starting points

Single EC2 with MariaDB co-located. This is what every Bitnami AMI and Lightsail image ships. It works for a hobby site. In production it breaks the first time you reboot the instance: caches cold, MySQL needs mysql_upgrade, opcache primes from disk while traffic queues against PHP-FPM. There is no failover. The hourly snapshot you bought is your only recovery option, and restoring it loses every order placed since 04:00.

Lift-and-shift from Dreamhost / WP Engine / shared cPanel. Teams who outgrew their shared host often clone the file tree to one big EC2 and call it a migration. Same anti-pattern: a fat /wp-content/uploads/ on EBS that grows past 200 GB, plugins that write session files into /tmp, cron jobs running inside the PHP request path. You have inherited every choice the shared host hid from you, plus a much larger bill.

Trying to autoscale a stateful WordPress. This is the classic failed migration. Engineering wraps the existing EC2 in a Launch Template, points the ALB at it, sets the ASG to two-to-eight instances, and watches sessions evaporate as users bounce between nodes, uploads vanish into ephemeral instance storage, and plugin updates only land on one of the four servers. Autoscaling without first making the app stateless is wasted IaC.

The fix is structural: pin WordPress to the role of a stateless request handler and move every piece of state — sessions, uploads, cache, DB — into a managed service that lives outside the instance lifecycle.

Reference architecture

Tier by tier

Edge / DNS — Route 53, CloudFront, ACM

The DNS zone lives in Route 53 with apex A and www CNAME records aliased to the CloudFront distribution. The cert for CloudFront must be issued in us-east-1 regardless of where the rest of your stack lives — CloudFront only consumes ACM certificates from the global endpoint. Issue a second ACM cert in the home region (e.g. ca-central-1) for the ALB.

CloudFront sits in front of everything that is not the admin path. Cache behaviors:

  • / and content paths — cache by URL + accepted languages, respect Cache-Control from origin, default TTL 1 hour, min 0, max 1 day.
  • /wp-admin/*, /wp-login.php, /wp-json/* — cache disabled, all headers forwarded, all cookies forwarded.
  • /wp-content/uploads/* — cache forever, ignore query strings except a small allowlist used for image resizing.

Add AWS WAF to the distribution with the managed rule sets AWSManagedRulesCommonRuleSet, AWSManagedRulesKnownBadInputsRuleSet, and AWSManagedRulesPHPRuleSet. Custom rules: rate-limit POST /wp-login.php to 10 requests per IP per 5 minutes, and POST /xmlrpc.php to zero unless you have a documented need.

Application tier — ALB + ASG (Launch Template), instance shape, AMI strategy

The ALB is internet-facing in public subnets, listens on 443 with the regional ACM cert, has an HTTP→HTTPS redirect on 80, and forwards to a target group of WordPress nodes. Health check path: /health.php (a one-line file that returns 200 only when PHP-FPM, Redis, and the writer DB respond; do not point the health check at / because every health check then hits the DB and CPU spikes during a fleet refresh). Deregistration delay: 30 seconds — long enough for in-flight admin requests to complete, short enough that scale-in does not stall.

The Auto Scaling Group spans both AZs, minimum 2, desired 2, maximum 6 (right-sized to your traffic floor and ceiling). Instance type: start with t3.medium for nodes under 200 RPS; move to m6i.large or c6i.large when PHP-FPM saturates a single instance. Avoid burstable types for any tier where credit-balance exhaustion translates to user-visible latency — keep t3 for staging, not production.

AMI strategy is the decision that separates fast deploys from slow ones:

  • Golden AMI. Build the AMI in CI with Packer. WordPress core, plugins, theme code, PHP, nginx, and the WordPress codebase are all baked in. Cloud-init only writes wp-config.php env values from SSM Parameter Store / Secrets Manager. Boot time: ~45 seconds from instance launch to in-service.
  • Cloud-init from a base AMI. Each instance pulls code from S3 or git on boot. Boot time: 3-6 minutes. Risk: the moment your S3 deploy bucket is unreachable, the whole ASG fails to scale.

Use golden AMIs. The Packer build runs in CI on every merge to main, tags the resulting AMI with the git SHA, and the Launch Template version is bumped to point at the new AMI. Then a rolling ASG instance refresh swaps the fleet.

Graceful drain on terminate: connect the ASG lifecycle hook to an SSM document that sends SIGQUIT to PHP-FPM, waits 25 seconds, then proceeds with termination. This combined with the ALB’s 30-second deregistration delay keeps in-flight requests from being killed mid-response.

Cache tier — ElastiCache Redis

A single ElastiCache Redis replication group with one primary and at least one replica in the second AZ, Multi-AZ failover enabled. Engine 7.x. Encryption at rest and in transit on. The Redis cluster carries three workloads:

  • Sessions. wp-session-manager or any drop-in that writes WordPress sessions to Redis instead of _options rows. Key prefix sess:, TTL 1 hour rolling.
  • Object cache. The standard object-cache.php drop-in from WP Redis or Object Cache Pro. Key prefix obj:, no global TTL — eviction is allkeys-lru. This is the single biggest performance lever for an admin-heavy WordPress.
  • Full-page cache. Optional but high-value if your traffic is mostly anonymous reads. Batcache or a custom page-cache plugin that stores rendered HTML in Redis keyed by URL + cookie-state.

Pin a single Redis primary unless you actively need cluster mode for capacity reasons (>50 GB data set or >100K ops/sec). Cluster mode introduces hash-slot constraints that break any plugin doing KEYS sess:* or MULTI/EXEC across unrelated keys. Object Cache Pro handles cluster mode correctly; many free plugins do not.

Data tier — RDS Multi-AZ vs Aurora; reader endpoints

The default safe choice is RDS for MySQL with Multi-AZ enabled, db.r6g.large minimum for production. Automated backups on with a 14-day retention. Snapshot policy: daily, plus on-demand before every WordPress core update. Performance Insights on. The Multi-AZ standby is a full hot standby — sub-60-second failover, no application code change required, the writer endpoint just resolves to the new instance.

Aurora MySQL becomes the better choice once you cross any of: more than two reader replicas needed, write IOPS consistently above what gp3 sustains at your provisioned size, multi-region read replicas required, or you want the auto-snapshot + clone workflow for staging environments. Aurora writers are not Multi-AZ in the RDS sense — the storage layer is replicated across three AZs but the writer instance lives in one AZ. Failover replaces the writer with a reader, taking 30-120 seconds.

WordPress can use a writer + reader split via HyperDB or LudicrousDB, but most production stacks just write through the writer endpoint and skip read replicas at the application layer, relying on the object cache to absorb reads. Only introduce a reader split when DB CPU on the writer is the binding constraint and you have proven that adding object-cache nodes does not move the needle. See the data-tier comparison article linked at the bottom for the decision framework between RDS, Aurora, and Aurora Serverless v2.

Object storage — S3 + WP Offload Media

The uploads bucket lives in the same region as the ALB. Block all public access, use a CloudFront origin access control to allow CloudFront to read from the bucket, and serve media through the same CloudFront distribution under /wp-content/uploads/*. The plugin of choice is WP Offload Media Lite (free, sufficient) or its paid edition for additional features like delivery-by-default to S3.

Two recurring gotchas:

  • ACL on legacy buckets. Newer S3 buckets have ACLs disabled by default (“Object Ownership: Bucket owner enforced”). Older buckets accept ACL writes; if the plugin tries to set public-read on each upload and ACLs are disabled, the upload silently fails to be served. Fix: set the plugin to “no ACL” mode and serve through CloudFront with OAC.
  • CORS for editor previews. Block editors uploading via the media library see broken thumbnails when the CloudFront distribution does not forward the Origin header. Either forward it explicitly, or configure the bucket CORS to allow GET from your editor origin and configure CloudFront to honor the CORS response.

Search — OpenSearch only when needed

WordPress search is LIKE %query% against the posts table. It collapses past about 50,000 posts. If your search is critical to UX (publisher, news, e-commerce), add OpenSearch via ElasticPress and an OpenSearch domain in the same VPC. For everything else, skip OpenSearch — the cost and operational surface is not worth it. Object cache will absorb the search load until the corpus is genuinely large.

Making WordPress stateless (the bit most guides skip)

This is where most migrations fail. The architecture above only works if WordPress itself stops writing state to its own filesystem. The checklist:

  • wp-config.php reads from environment. No hardcoded DB credentials, no hardcoded Redis password, no hardcoded keys. getenv('DB_PASSWORD') for every secret. The Launch Template’s user-data writes those env values from SSM Parameter Store (or Secrets Manager for credentials with rotation). The same applies to WP_CACHE_KEY_SALT, WP_REDIS_PASSWORD, and the AUTH_KEY family.
  • Sessions in Redis, not on filesystem. Drop a session-to-Redis plugin in mu-plugins/ so it loads before any other plugin can write to /tmp.
  • Uploads in S3. WP Offload Media set to “copy to S3 + remove from local” so the EBS volume never carries production uploads. Pre-existing uploads must be migrated with the plugin’s tools before you start scaling the ASG.
  • Cron external. Add define('DISABLE_WP_CRON', true); to wp-config.php. Set up an EventBridge rule every 5 minutes that POSTs to https://example.org/wp-cron.php?doing_wp_cron=1 through the ALB. Without this, every visitor’s request gets a chance to trigger cron, which then runs across whichever node the request landed on — non-deterministic and resource-spiky.
  • Logs to CloudWatch. The CloudWatch Agent ships nginx access/error logs and PHP-FPM logs to a log group. Set up a metric filter for HTTP 5xx and a CloudWatch alarm on the 5xx rate.
  • Plugin and theme code baked into the AMI, not installed at runtime. No plugin updates from /wp-admin/. The update workflow is: developer updates composer.json or the deployment manifest, CI builds a new AMI, CI bumps the Launch Template, an ASG instance refresh rolls the change. WordPress core auto-updates: disable with define('WP_AUTO_UPDATE_CORE', false); and add_filter('automatic_updater_disabled', '__return_true');.

If any one of these is skipped, the architecture leaks state and the symptoms appear in production as: users logged out on scale events, half the fleet has the new plugin and half does not, search results vary by node, or media uploads from the editor never appear for visitors.

Deploy pattern

Blue/green ASG with health checks and a rolling refresh:

  1. CI builds a Packer AMI tagged with the git SHA.
  2. CI creates a new Launch Template version pointing at the new AMI.
  3. CI updates the ASG to use the new Launch Template version.
  4. CI triggers start-instance-refresh with --preferences MinHealthyPercentage=90,InstanceWarmup=120.
  5. ASG terminates one instance at a time, the ALB drains it, a new instance launches from the new AMI, registers with the target group, becomes healthy, then the next instance is replaced.

What goes in user-data vs the AMI: only the items that must vary per instance go in user-data (instance metadata, hostname, environment-specific SSM parameter prefix). Everything that does not vary — WP code, plugins, nginx config, PHP-FPM pool config — is in the AMI. User-data should fit in a screen of bash; if it grows beyond 50 lines, you have leaked AMI work into user-data and your boot times will reflect that.

Observability

The CloudWatch alarms baseline that catches every common failure:

AlarmThresholdNotify
ALB 5xx rate>1% over 5 minutesSev-2
ALB target group p95 latency>2s over 5 minutesSev-3
ALB unhealthy host count>=1 for 2 minutesSev-3
RDS / Aurora writer CPU>80% for 10 minutesSev-3
RDS / Aurora replication lag>30s for 5 minutesSev-2
ElastiCache evictions>0 sustained for 10 minutesSev-4
ElastiCache CPU>80% for 10 minutesSev-3
ASG group min size breachdesired < min for 5 minutesSev-2
CloudFront 5xx rate>0.5% over 5 minutesSev-2

The eviction alarm is the most useful: it tells you the object cache is too small for the working set, which is the leading indicator that admin-heavy operations are about to start hitting the DB instead of Redis.

Cost shape

Reserved Instances or Savings Plans cover the steady-state ASG baseline — typically the two-instance floor that never scales down. The marginal instances above the floor stay on-demand. Aurora has a per-IOPS pricing model that crosses RDS gp3 cost somewhere around the third reader replica or when sustained write IOPS exceed what a single RDS gp3 volume sustains at the same provisioned storage. Below that crossover, RDS is cheaper; above it, Aurora is cheaper because you stop overpaying for provisioned IOPS you do not use.

S3 + CloudFront cost for media is almost always cheaper than EBS-backed media on the application instances, because EBS scales by volume size while S3 scales by stored bytes. CloudFront egress is the line item most often missed — turn on CloudFront standard logs and review the top 10 paths by bytes-out monthly.

Stack Harbor’s approach is to scope to drivers — request volume, admin-vs-anonymous mix, media library size, plugin set — and propose a fixed monthly quote after a discovery review. Published per-instance pricing on a WordPress production stack would be misleading because three of the cost drivers are decided by your traffic mix, not by AWS list price.

Pitfalls

  • wp-admin sticky sessions. WordPress admin makes heavy use of nonces tied to the session. If your session store is not Redis-backed, you must enable ALB sticky sessions for the admin path. Better fix: move sessions to Redis and disable stickiness so any node can serve any admin request.
  • .htaccess on ALB. Apache .htaccess does not exist on nginx. Translate every rewrite rule and access rule into the nginx site config, bake into the AMI. The most common gotcha is the WordPress permalink rewrite — nginx needs try_files $uri $uri/ /index.php?$args; in the right location block.
  • Media uploads to ephemeral EBS. Even with WP Offload Media, plugins that bypass the upload handler (e.g. some PDF generators, some import tools) write directly to /wp-content/uploads/ on the local instance. When the ASG replaces that instance, the file is gone. Solve by mounting /wp-content/uploads/ as an EFS share if you cannot guarantee every writer goes through the offload plugin, or by auditing plugins to confirm they all use wp_upload_dir() correctly.
  • Plugins that write to /wp-content/. Some caching, log, and security plugins write outside /wp-content/uploads/ — for example to /wp-content/cache/, /wp-content/wflogs/, or /wp-content/upgrade/. Either make those paths writable on the AMI and accept the data is per-instance, or configure the plugin to use Redis or S3 instead. Wordfence’s WAF firewall is the most common culprit — move it to “extended protection” mode that does not write to the local filesystem.
  • Connection pool exhaustion. WordPress holds one DB connection per PHP-FPM worker per node. With four nodes at 20 workers each, you can hit 80 connections; RDS db.r6g.large defaults to a max of 1000, so you have headroom — but if you bump PHP-FPM pool sizes during a traffic spike, you can exhaust connections. RDS Proxy in front of the writer pools connections and absorbs spikes; introduce it before you ever need it.

When this is the wrong architecture

If your team is two engineers, your traffic floor is 50 RPS, and your release cadence is “every six weeks”, the architecture above is overkill. A single well-tuned cPanel-style host with a CDN in front of it will serve you better — see Stack Harbor’s cPanel server administration for that path. If your application is mostly editor-driven and traffic is bursty rather than sustained, a managed WordPress platform with the same statelessness guarantees, but operated by someone else’s SRE team, will move faster — that’s what Stack Harbor’s enterprise WordPress support offering is built for.

The architecture in this article is the right one when you need horizontal scaling on commodity AWS primitives, want to keep root in your own AWS account, and have a release cadence that justifies the CI/AMI/IaC investment. Stack Harbor operates that pattern under AWS Management — senior engineers own the design and Sev-1 calls, the day-to-day operates against an SLA, and pricing is scoped to drivers and quoted after a discovery review rather than published per instance.

See also