{"id":3308,"date":"2025-10-10T16:53:00","date_gmt":"2025-10-10T16:53:00","guid":{"rendered":"https:\/\/www.antpace.com\/blog\/?p=3308"},"modified":"2026-02-15T17:46:19","modified_gmt":"2026-02-15T17:46:19","slug":"cloudfront-lightsail-wordpress-how-i-put-antpace-com-behind-a-cdn-caching-ssl-bot-defense-and-deploy-invalidation","status":"publish","type":"post","link":"https:\/\/www.antpace.com\/blog\/cloudfront-lightsail-wordpress-how-i-put-antpace-com-behind-a-cdn-caching-ssl-bot-defense-and-deploy-invalidation\/","title":{"rendered":"CloudFront + Lightsail WordPress: How I Put AntPace.com Behind a CDN (Caching, SSL, Bot Defense, and Deploy Invalidation)"},"content":{"rendered":"\n<p>I put CloudFront in front of my Lightsail site for a few reasons: faster load times globally, less load on my instance, and better options for defending against bot traffic at the edge. I also wanted a setup where Lightsail is just the origin and CloudFront is the public front door, which makes future changes easier.<\/p>\n\n\n\n<p>This is what I did, what broke, and what fixed it.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">What I started with<\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n<li>A Lightsail Linux instance running Apache<\/li>\n\n\n\n<li>The main site is mostly PHP pages plus CSS\/JS\/images<\/li>\n\n\n\n<li>The blog lives at <code>antpace.com\/blog<\/code> (WordPress)<\/li>\n\n\n\n<li>DNS already in Route 53<\/li>\n\n\n\n<li>HTTPS already working on the instance using Certbot<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">The mental model<\/h3>\n\n\n\n<p>CloudFront is the front door. Lightsail becomes the origin behind it.<\/p>\n\n\n\n<p>That means two separate HTTPS concerns:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Visitors hitting <code>antpace.com<\/code> need a certificate attached to CloudFront (ACM)<\/li>\n\n\n\n<li>CloudFront talking to the origin also needs HTTPS on an origin hostname<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">Setting up the origin hostname<\/h3>\n\n\n\n<p>CloudFront won\u2019t accept an IP address as an origin. It needs a domain name. So the first move was creating:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>backdoor.antpace.com<\/code> \u2192 Lightsail static IP (Route 53 A record)<\/li>\n<\/ul>\n\n\n\n<p>Then I made sure the origin worked over HTTPS using the same mechanism I already had on the instance.<\/p>\n\n\n\n<p>To confirm what I was actually using:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo certbot certificates\nsudo sed -n '1,200p' \/etc\/letsencrypt\/renewal\/antpace.com.conf\n<\/code><\/pre>\n\n\n\n<p>That confirmed Certbot and showed the exact webroot path being used for renewals.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Route 53 record for <code>backdoor.antpace.com<\/code><\/li>\n\n\n\n<li><code>certbot certificates<\/code> output<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">Getting the CloudFront certificate (ACM)<\/h3>\n\n\n\n<p>CloudFront requires an ACM certificate in <code>us-east-1<\/code>, even if your origin is in a different region.<\/p>\n\n\n\n<p>So I requested a cert in ACM for:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>antpace.com<\/code><\/li>\n\n\n\n<li><code>www.antpace.com<\/code><\/li>\n<\/ul>\n\n\n\n<p>Then validated it through Route 53.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>ACM certificate request page showing the domains and DNS validation records<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">Creating the distribution and choosing policies<\/h3>\n\n\n\n<p>This is where the setup becomes worth it, but it\u2019s also where you need to treat the main site and WordPress differently.<\/p>\n\n\n\n<p>My main site is basically \u201cstatic-ish\u201d content, but <code>\/blog<\/code> is WordPress. So I split behaviors and used different policies.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">Default behavior (<code>*<\/code>) for the main site<\/h4>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Viewer protocol policy: Redirect HTTP to HTTPS<\/li>\n\n\n\n<li>Allowed methods: GET, HEAD<\/li>\n\n\n\n<li>Cache policy: <code>CachingOptimized<\/code> (managed)<\/li>\n<\/ul>\n\n\n\n<p>I verified caching was working with:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>curl -I https:\/\/antpace.com | egrep -i 'x-cache|age|via'\ncurl -I https:\/\/antpace.com | egrep -i 'x-cache|age|via'\n<\/code><\/pre>\n\n\n\n<p>First request was a Miss, second request was a Hit with an <code>age<\/code> header.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>CloudFront distribution details page<\/li>\n\n\n\n<li>Behaviors tab showing Default (*)<\/li>\n<\/ul>\n\n\n\n<h4 class=\"wp-block-heading\">Blog behaviors for WordPress (<code>\/blog\/*<\/code>)<\/h4>\n\n\n\n<p>I kept WordPress safe by disabling caching on the blog paths and forwarding what WordPress needs.<\/p>\n\n\n\n<p>Behaviors I added:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>\/blog\/wp-admin\/*<\/code><\/li>\n\n\n\n<li><code>\/blog\/wp-login.php<\/code><\/li>\n\n\n\n<li><code>\/blog\/*<\/code><\/li>\n<\/ul>\n\n\n\n<p>I know the above is redundant, but in the future I may allow some cacheing on the blog post pages, but always keep the other two routes disabled.<\/p>\n\n\n\n<p>For all three:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Viewer protocol policy: Redirect HTTP to HTTPS<\/li>\n\n\n\n<li>Allowed methods: All<\/li>\n\n\n\n<li>Cache policy: <code>CachingDisabled<\/code><\/li>\n\n\n\n<li>Origin request policy: <code>AllViewer<\/code><\/li>\n<\/ul>\n\n\n\n<p>This is the \u201cdon\u2019t get fancy yet\u201d setup. It keeps logins\/admin sane and avoids CloudFront caching anything dynamic.<\/p>\n\n\n\n<p>Behaviors list showing the <code>\/blog\/*<\/code> paths and policies<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Bot defense at the edge<\/h3>\n\n\n\n<p>One of the underrated reasons to do this is that CloudFront gives you an edge layer for basic bot defense. Even on the free plan, you can enable rate limiting\/monitoring so random traffic is handled earlier, instead of everything slamming your Apache box directly.<\/p>\n\n\n\n<p>I turned on the recommended rate limiting settings (it starts in monitor mode), then I can tighten it later if I need to.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Security \/ rate limiting settings screen<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">The redirect loop issue (and why it happened)<\/h3>\n\n\n\n<p>After switching DNS, browsers started throwing \u201ctoo many redirects.\u201d<\/p>\n\n\n\n<p>The fastest way to see what was happening:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>curl -IL https:\/\/www.antpace.com | head -n 40\n<\/code><\/pre>\n\n\n\n<p>It was an endless chain of 301s. The cause was not CloudFront. It was my origin.<\/p>\n\n\n\n<p>I had unconditional Apache redirects living in two configs:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>\/opt\/bitnami\/apache\/conf\/bitnami\/bitnami.conf<\/code><\/li>\n\n\n\n<li><code>\/opt\/bitnami\/apache\/conf\/bitnami\/bitnami-ssl.conf<\/code><\/li>\n<\/ul>\n\n\n\n<p>And the line was:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Redirect permanent \/ https:\/\/www.antpace.com\/\n<\/code><\/pre>\n\n\n\n<p>That redirect is too blunt once CloudFront is in front. CloudFront can cache the redirect and then you\u2019ve got a fast global redirect loop.<\/p>\n\n\n\n<p>This command found it immediately:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo grep -R --line-number -E \"RewriteEngine|RewriteCond|RewriteRule|Redirect\" \\\n\/opt\/bitnami\/apache\/conf\/bitnami \/opt\/bitnami\/apache\/conf\/vhosts 2&gt;\/dev\/null\n<\/code><\/pre>\n\n\n\n<p>Fix was removing those redirects from both files.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>grep output showing the redirect line in both files<\/li>\n\n\n\n<li>browser redirect error page<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">Canonical redirect at the edge (CloudFront Function)<\/h3>\n\n\n\n<p>I still wanted apex \u2192 www, but I didn\u2019t want Apache doing it anymore.<\/p>\n\n\n\n<p>So I created a CloudFront Function and attached it to the Default behavior on Viewer request:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>function handler(event) {\n  var request = event.request;\n  var host = request.headers.host.value;\n\n  if (host === 'antpace.com') {\n    var qs = request.querystring;\n    var loc = 'https:\/\/www.antpace.com' + request.uri + (qs &amp;&amp; qs.length ? ('?' + qs) : '');\n    return {\n      statusCode: 301,\n      statusDescription: 'Moved Permanently',\n      headers: {\n        location: { value: loc }\n      }\n    };\n  }\n\n  return request;\n}\n<\/code><\/pre>\n\n\n\n<p>This makes the redirect logic obvious and centralized, and it keeps the origin hostname out of the equation.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>CloudFront Function code + association on the behavior<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">Invalidate CloudFront cache on deploy (GitHub Actions)<\/h3>\n\n\n\n<p>My deploy process is GitHub Actions. It SSHes into Lightsail and runs my deploy script. With caching enabled, I wanted updates to show up immediately after a push.<\/p>\n\n\n\n<p>So I added a CloudFront invalidation step after deploy:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>- name: Configure AWS credentials (OIDC)\n  uses: aws-actions\/configure-aws-credentials@v4\n  with:\n    role-to-assume: ${{ secrets.AWS_ROLE_ARN }}\n    aws-region: us-east-1\n\n- name: Invalidate CloudFront\n  run: |\n    aws cloudfront create-invalidation \\\n      --distribution-id \"${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }}\" \\\n      --paths \"\/*\"\n<\/code><\/pre>\n\n\n\n<p>That\u2019s the simple version. I can narrow it later, but <code>\/*<\/code> makes \u201cdeploy means live\u201d true.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>GitHub Actions run showing the invalidation step<\/li>\n\n\n\n<li>CloudFront invalidations tab<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">What I got out of this<\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Faster global delivery of the main site via edge caching<\/li>\n\n\n\n<li>Less load on Lightsail<\/li>\n\n\n\n<li>WordPress stays safe because <code>\/blog\/*<\/code> is not cached and forwards the right stuff<\/li>\n\n\n\n<li>Better options for bot defense at the edge<\/li>\n\n\n\n<li>Canonical redirects handled at CloudFront instead of server config files<\/li>\n\n\n\n<li>Automated deploy invalidation so changes show up right away<\/li>\n<\/ul>\n\n\n\n<p>If you\u2019re doing this on a mixed site (static-ish pages plus WordPress), the split behaviors are the whole thing. Treating everything the same is how you end up caching logins or debugging redirects at 2am.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"465\" src=\"https:\/\/www.antpace.com\/blog\/wp-content\/uploads\/2026\/02\/Screen-Shot-2026-02-14-at-11.52.14-AM-1024x465.png\" alt=\"\" class=\"wp-image-3314\" srcset=\"https:\/\/www.antpace.com\/blog\/wp-content\/uploads\/2026\/02\/Screen-Shot-2026-02-14-at-11.52.14-AM-1024x465.png 1024w, https:\/\/www.antpace.com\/blog\/wp-content\/uploads\/2026\/02\/Screen-Shot-2026-02-14-at-11.52.14-AM-300x136.png 300w, https:\/\/www.antpace.com\/blog\/wp-content\/uploads\/2026\/02\/Screen-Shot-2026-02-14-at-11.52.14-AM-768x349.png 768w, https:\/\/www.antpace.com\/blog\/wp-content\/uploads\/2026\/02\/Screen-Shot-2026-02-14-at-11.52.14-AM.png 1448w\" sizes=\"auto, (max-width: 767px) 89vw, (max-width: 1000px) 54vw, (max-width: 1071px) 543px, 580px\" \/><\/figure>\n\n\n\n<p><br>When I went to save this post in WordPress, it kept failing with the classic editor error: \u201cUpdating failed. The response is not a valid JSON response.\u201d At first it looked like a WordPress problem, but the actual response coming back was a CloudFront-generated 403 \u201cRequest blocked\u201d HTML page, which meant the request never made it to WordPress at all. The weird part was it only happened with certain content. Normal edits saved fine, but as soon as I pasted in code-heavy sections (Apache config blocks, rewrite rules, YAML, JS), CloudFront\u2019s built-in WAF protections flagged the request body as suspicious and blocked it. The fix was simple once we knew what was happening: I enabled WAF \u201cmonitor mode\u201d on the CloudFront distribution so it would log potential blocks instead of enforcing them, and after the change finished deploying across CloudFront, saves started working again. I kept rate limiting on for bot defense, but left the common-threat protection in monitor mode until I eventually switch to a full WAF Web ACL where I can add exceptions for WordPress editor endpoints.<\/p>\n\n\n\n<p>One extra thing I did on the WordPress side was add a tiny mu-plugin as a guardrail. I like mu-plugins for infrastructure-style fixes because they always load and they cannot be accidentally disabled in the admin UI. I did not put anything \u201cin wp-admin\u201d because the block editor issue is really about REST requests and editor endpoints, and WordPress updates can overwrite admin code anyway. Also, I don&#8217;t track that file in version control.The mu-plugin lives in <code>wp-content\/mu-plugins\/<\/code> and keeps the behavior consistent no matter what theme or normal plugins are doing.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>I put CloudFront in front of my Lightsail site for a few reasons: faster load times globally, less load on my instance, and better options for defending against bot traffic at the edge. I also wanted a setup where Lightsail is just the origin and CloudFront is the public front door, which makes future changes &hellip; <\/p>\n<p class=\"link-more\"><a href=\"https:\/\/www.antpace.com\/blog\/cloudfront-lightsail-wordpress-how-i-put-antpace-com-behind-a-cdn-caching-ssl-bot-defense-and-deploy-invalidation\/\" class=\"more-link\">Continue reading<span class=\"screen-reader-text\"> &#8220;CloudFront + Lightsail WordPress: How I Put AntPace.com Behind a CDN (Caching, SSL, Bot Defense, and Deploy Invalidation)&#8221;<\/span><\/a><\/p>\n","protected":false},"author":1,"featured_media":3317,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[4,5],"tags":[],"class_list":["post-3308","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-technology","category-web-development"],"_links":{"self":[{"href":"https:\/\/www.antpace.com\/blog\/wp-json\/wp\/v2\/posts\/3308","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.antpace.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.antpace.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.antpace.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.antpace.com\/blog\/wp-json\/wp\/v2\/comments?post=3308"}],"version-history":[{"count":9,"href":"https:\/\/www.antpace.com\/blog\/wp-json\/wp\/v2\/posts\/3308\/revisions"}],"predecessor-version":[{"id":3326,"href":"https:\/\/www.antpace.com\/blog\/wp-json\/wp\/v2\/posts\/3308\/revisions\/3326"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.antpace.com\/blog\/wp-json\/wp\/v2\/media\/3317"}],"wp:attachment":[{"href":"https:\/\/www.antpace.com\/blog\/wp-json\/wp\/v2\/media?parent=3308"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.antpace.com\/blog\/wp-json\/wp\/v2\/categories?post=3308"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.antpace.com\/blog\/wp-json\/wp\/v2\/tags?post=3308"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}