{"id":88,"date":"2026-09-28T00:32:12","date_gmt":"2026-09-28T00:32:12","guid":{"rendered":"https:\/\/admin.securecloudengineers.com\/?p=88"},"modified":"2026-09-28T02:15:00","modified_gmt":"2026-09-28T02:15:00","slug":"how-i-built-a-three-tier-wordpress-site-on-aws-and-what-broke","status":"publish","type":"post","link":"https:\/\/blog.securecloudengineers.com\/index.php\/2026\/09\/28\/how-i-built-a-three-tier-wordpress-site-on-aws-and-what-broke\/","title":{"rendered":"How I Built a Three-Tier WordPress Site on AWS (and What Broke)"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\"><strong>Stack:<\/strong> AWS CloudFormation, CloudFront with AWS WAF, Route 53 and ACM, Application Load Balancer, EC2 Auto Scaling, RDS MySQL with a read replica, ElastiCache Redis, Amazon EFS, AWS Backup, CloudWatch, and GitHub Actions with OIDC.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I wanted to see what it takes to run WordPress on AWS the way a real production site runs: deployed with CloudFormation, protected by a firewall, with a database nobody can reach from the internet.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The blog you are reading runs on it, so every problem in this post is one I hit myself.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The full code and deployment notes are on GitHub: <a href=\"https:\/\/github.com\/bdahiya2007\/three-tier-web-app-aws\">three-tier-web-app-aws<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What I built<\/h2>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"1140\" height=\"1050\" src=\"https:\/\/blog.securecloudengineers.com\/wp-content\/uploads\/2026\/09\/three-tier-real-architecture.png\" alt=\"Architecture of my three-tier WordPress setup on AWS: Route 53, CloudFront with AWS WAF, a load balancer, WordPress on EC2 across two Availability Zones, RDS MySQL with a read replica, ElastiCache Redis and EFS\" class=\"wp-image-89\" srcset=\"https:\/\/blog.securecloudengineers.com\/wp-content\/uploads\/2026\/09\/three-tier-real-architecture.png 1140w, https:\/\/blog.securecloudengineers.com\/wp-content\/uploads\/2026\/09\/three-tier-real-architecture-300x276.png 300w, https:\/\/blog.securecloudengineers.com\/wp-content\/uploads\/2026\/09\/three-tier-real-architecture-1024x943.png 1024w, https:\/\/blog.securecloudengineers.com\/wp-content\/uploads\/2026\/09\/three-tier-real-architecture-768x707.png 768w\" sizes=\"auto, (max-width: 1140px) 100vw, 1140px\" \/><figcaption class=\"wp-element-caption\">How my setup actually looks<\/figcaption><\/figure>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Edge:<\/strong> CloudFront with my own domain (Route 53 and an ACM certificate), and AWS WAF in front of everything.<\/li>\n\n\n\n<li><strong>Load balancer:<\/strong> an Application Load Balancer that only accepts traffic from CloudFront.<\/li>\n\n\n\n<li><strong>Web\/app tier:<\/strong> WordPress on EC2 in an Auto Scaling Group, 2 to 3 instances across two Availability Zones.<\/li>\n\n\n\n<li><strong>Data tier:<\/strong> RDS MySQL in private subnets, with a read replica in the second AZ.<\/li>\n\n\n\n<li><strong>Supporting services:<\/strong> ElastiCache Redis as an object cache, EFS so all instances share <code>wp-content<\/code>, AWS Backup (daily, kept for 30 days), and CloudWatch logs and a dashboard.<\/li>\n\n\n\n<li><strong>Deployment:<\/strong> GitHub Actions runs CloudFormation, using OIDC instead of stored AWS access keys.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">It costs around $57 to $61 a month while it&#8217;s running, so I scale it down when I&#8217;m not using it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The biggest thing I learned is how these services depend on each other. Most of my problems didn&#8217;t come from one service on its own, but from the places where two of them connect: GitHub and IAM, CloudFront and the load balancer, the WAF and the WordPress editor.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Problem 1: GitHub Actions couldn&#8217;t log in to AWS<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">I didn&#8217;t want AWS access keys saved in GitHub, so I set up OIDC. GitHub gives each workflow run a short-lived token, and AWS trusts that token through an IAM role.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The workflow kept failing with this:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Not authorized to perform sts:AssumeRoleWithWebIdentity\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The role, the OIDC provider and the secrets all looked right. The error doesn&#8217;t say which condition failed, and GitHub&#8217;s logs don&#8217;t show the token.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">CloudTrail had the answer. The denied <code>AssumeRoleWithWebIdentity<\/code> call is logged there, and <code>userIdentity.principalId<\/code> shows the exact <code>sub<\/code> claim that GitHub sent. Mine included the numeric IDs of my GitHub account and repo, but my trust policy only had the names, so the condition never matched.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The fix was to add the IDs to the trust policy condition:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>token.actions.githubusercontent.com:sub: !Sub repo:${GitHubOrg}@${GitHubOrgId}\/${GitHubRepo}@${GitHubRepoId}:ref:refs\/heads\/${GitHubBranch}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Right after that, the deploy failed again, this time on <code>cloudformation:GetTemplateSummary<\/code>. The <code>aws cloudformation deploy<\/code> command needs that permission behind the scenes, and I had left it out of my least-privilege policy.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">My takeaway: when IAM says &#8220;not authorized&#8221;, check CloudTrail before changing anything. It shows what AWS actually received.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Problem 2: My own WAF blocked me<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The WAF uses two AWS managed rule groups: the SQL injection rule set and the Core rule set, which includes XSS protection. It worked a little too well.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The first block<\/strong> came when I published my first post. WordPress showed &#8220;Updating failed. The response is not a valid JSON response.&#8221; The browser&#8217;s network tab showed a 403 from CloudFront, which meant the request never reached WordPress.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The rule was <code>SizeRestrictions_BODY<\/code>. It blocks any request body over 8 KB, and the WordPress editor sends the whole post as JSON every time it saves. Short drafts saved fine, but a real article didn&#8217;t.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I changed that rule to Count and raised the WAF&#8217;s body inspection limit to 64 KB. The second change is important. Without it, the SQL injection and XSS rules only look at the first 8 KB of a request. To test it, I sent a SQL injection string after 12 KB of padding, and it was still blocked.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The second block<\/strong> came a day later, when editing the site footer failed with a 403. This time it was <code>CrossSiteScripting_BODY<\/code>. The block editor adds <code>style=\"...\"<\/code> attributes to its HTML, and the rule treats that as a possible XSS attack. It also blocked a diagram I tried to add to a post.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I didn&#8217;t want to turn off XSS checks for the whole site. Instead, I set the rule to Count and added my own rule that blocks the same match everywhere except the WordPress REST API, which the editor uses:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>- Name: BlockXSSBodyExceptRestApi\n  Priority: 2\n  Action:\n    Block: {}\n  Statement:\n    AndStatement:\n      Statements:\n        - LabelMatchStatement:\n            Scope: LABEL\n            Key: awswaf:managed:aws:core-rule-set:CrossSiteScripting_Body\n        - NotStatement:\n            Statement:\n              ByteMatchStatement:\n                SearchString: \/wp-json\/wp\/v2\/\n                FieldToMatch:\n                  UriPath: {}\n                TextTransformations:\n                  - Priority: 0\n                    Type: NONE\n                PositionalConstraint: CONTAINS\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Then I tested both sides. The same payload now reaches WordPress on the REST API, which still requires a login, and it&#8217;s still blocked on other pages such as <code>\/wp-login.php<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">My takeaway: managed rules are a starting point. Test them against your real application, and when one gets in the way, make the smallest exception you can instead of switching it off.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">A few more things I ran into<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>A WAF can be bypassed.<\/strong> At first, the load balancer was open to the internet, so anyone could skip CloudFront and the WAF by using the ALB&#8217;s DNS name directly. I restricted the ALB&#8217;s security group to the AWS managed prefix list for CloudFront. Now a direct request just times out.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The endless \/wp-admin redirect.<\/strong> After I added CloudFront, <code>\/wp-admin<\/code> redirected to itself forever. CloudFront talks to the ALB over HTTP, so WordPress saw <code>X-Forwarded-Proto: http<\/code> and kept trying to &#8220;fix&#8221; the URL. The header I needed was <code>CloudFront-Forwarded-Proto<\/code>, and CloudFront only sends it when the origin request policy includes CloudFront headers.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Proving the read replica works.<\/strong> WordPress doesn&#8217;t support read replicas on its own, so I used HyperDB to send reads to the replica and writes to the primary. To check it, I compared the MySQL <code>Com_select<\/code> counter on both databases before and after 15 page loads. The replica went up by 399 and the primary by 1. A direct write to the replica failed with a read-only error, which confirms that writes can only go to the primary.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What I&#8217;d do differently<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">I would make security the priority from day one, starting with encrypting RDS.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I added the read replica before turning on encryption at rest. A replica has to match its source, so now both databases are unencrypted, and fixing it means restoring from an encrypted snapshot to a new database endpoint. If I started again, I would enable encryption at rest and require TLS between WordPress and the database before building anything else.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What&#8217;s next<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Encrypt the database:<\/strong> turn on encryption at rest for RDS and enforce TLS connections.<\/li>\n\n\n\n<li><strong>Move to private subnets:<\/strong> the web servers are in public subnets today to avoid NAT Gateway costs. Their security group only accepts web traffic from the load balancer, but I want them fully private and managed with Session Manager instead of SSH.<\/li>\n\n\n\n<li><strong>Add caching:<\/strong> caching is off in CloudFront right now, because caching WordPress pages at the edge could show one person&#8217;s logged-in page to someone else. The next step is caching static files such as images.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">I&#8217;ll write about each of these as I do them. If you&#8217;re building something similar or have questions about this setup, feel free to reach out on <a href=\"https:\/\/www.linkedin.com\/in\/balrajdahiya\/\">LinkedIn<\/a>.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Stack: AWS CloudFormation, CloudFront with AWS WAF, Route 53 and ACM, Application Load Balancer, EC2 Auto Scaling, RDS MySQL with a read replica, ElastiCache Redis, Amazon EFS, AWS Backup, CloudWatch, and GitHub Actions with OIDC. I wanted to see what it takes to run WordPress on AWS the way a real production site runs: deployed [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[3],"tags":[],"class_list":["post-88","post","type-post","status-publish","format-standard","hentry","category-cloud-architecture"],"_links":{"self":[{"href":"https:\/\/blog.securecloudengineers.com\/index.php\/wp-json\/wp\/v2\/posts\/88","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/blog.securecloudengineers.com\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/blog.securecloudengineers.com\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/blog.securecloudengineers.com\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/blog.securecloudengineers.com\/index.php\/wp-json\/wp\/v2\/comments?post=88"}],"version-history":[{"count":4,"href":"https:\/\/blog.securecloudengineers.com\/index.php\/wp-json\/wp\/v2\/posts\/88\/revisions"}],"predecessor-version":[{"id":94,"href":"https:\/\/blog.securecloudengineers.com\/index.php\/wp-json\/wp\/v2\/posts\/88\/revisions\/94"}],"wp:attachment":[{"href":"https:\/\/blog.securecloudengineers.com\/index.php\/wp-json\/wp\/v2\/media?parent=88"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/blog.securecloudengineers.com\/index.php\/wp-json\/wp\/v2\/categories?post=88"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/blog.securecloudengineers.com\/index.php\/wp-json\/wp\/v2\/tags?post=88"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}