Author: Aditya Sharma

  • WordPress Redis Object Cache: Measure First, Because Often The Answer Is No

    WordPress Redis Object Cache: Measure First, Because Often The Answer Is No

    Short answer: a wordpress redis object cache is genuinely valuable on sites with authenticated traffic, stores, or measured query time above roughly 100 milliseconds. On an ordinary brochure site behind a working page cache it adds a service that can fail in order to save time nobody was losing.

    The measurement takes ten minutes and decides it for you.

    Measuring before deciding

    Table of measured queries per page load on a WordPress install with WooCommerce. The homepage runs 70 queries in 14.3 milliseconds, the shop page 77 in 10.7 milliseconds, and a post 76 in 7.6 milliseconds
    Measured with SAVEQUERIES on 7 September 2026. Total database time is between 8 and 15

    milliseconds per request. A perfect object cache can only ever remove a portion of that.*

    On this site, the honest recommendation is to do nothing. Nobody perceives eight milliseconds.

    On a site running 400 queries taking 300 milliseconds, the identical change is worth a project. The technique is the same and its value differs by two orders of magnitude, which is exactly why this decision should not come from a checklist.

    Measure it yourself by adding define('SAVEQUERIES', true); to wp-config.php temporarily, then reading $wpdb->num_queries and the summed query time on shutdown. Turn it off afterwards, as it stores every query in memory and is itself a performance problem on a busy site.

    What a WordPress Redis object cache actually changes

    Table comparing default WordPress against Redis or Memcached. Both cache objects within a request. Only the persistent cache survives to the next request or is shared between web servers. It rarely helps logged-out cached pages and clearly helps logged-in users, carts, checkouts and admin
    The last two rows are the real argument. A full-page cache already protects anonymous

    visitors by not running PHP at all. An object cache earns its keep on the requests a page cache cannot serve.*

    Default WordPressRedis / Memcached
    cache within one requestyesyes
    cache survives to the next requestnoyes
    shared between web serversnoyes
    helps logged-out cached pagesn/ararely, a page cache already skipped PHP
    helps logged-in users, cart, checkout, adminn/ayes, these bypass the page cache entirely

    This is the point most often missed. WordPress already caches objects in memory within a single request. Redis makes that cache persist between requests and be shared across servers.

    So the benefit lands on requests that actually execute PHP, which on a well-cached brochure site is almost none of them, and on a store is nearly all the ones that matter.

    When it is worth it

    Table of situations. WooCommerce stores with logged-in traffic, membership and LMS sites, measured query time above 100 milliseconds, and multi-server setups are all yes. Brochure sites with anonymous traffic and a working page cache, and sites with single-digit millisecond query time, are no
    An object cache is another service that can fail, fill up, or serve stale data after a

    deploy. On a site that does not need it, that is added risk for no return.*

    SituationVerdict
    yesWooCommerce store with real logged-in trafficcarts and checkouts hit the database every time
    yesmembership or LMS sitealmost every request is authenticated
    yesquery time measured above ~100msreal time to recover
    yesmultiple web servers behind a load balancera shared cache is the only way they agree
    nobrochure site, anonymous traffic, page cache workingalmost nothing reaches PHP to benefit
    noquery time in single-digit millisecondsadding a moving part to save nothing

    Installing Redis and measuring the actual before and after

    Everything above describes what an object cache should do. I wanted the actual number, so I installed Redis against a real WordPress install on our test rig, rather than cite the mechanism and leave the size of the effect to the imagination.

    wp plugin install redis-cache --activate
    wp config set WP_REDIS_HOST lab-redis-1
    wp redis enable
    wp redis status   # Status: Connected, Drop-in: Valid, Ping: PONG

    With the drop-in connected and confirmed, I requested the homepage with SAVEQUERIES on and read the query count off a response header on each request:

    RequestObject cacheQueries
    1st (cold, cache just enabled)on138
    2ndon14
    3rdon14
    4thon14
    baseline, cache disabledoff142

    Queries dropped from 142 to 14 once Redis had something in it to serve, a reduction of roughly 90%. That is close to the largest effect this technique can plausibly produce, and it is real, measured on an actual request rather than asserted from how the mechanism is supposed to work.

    The honest second half of the result is the wall-clock time for the same requests, and it is the more important number for this post’s argument:

    RequestObject cacheTotal time
    1st (cold PHP, no cache)off0.740s
    2ndoff0.196s
    3rdoff0.141s
    1st (warm cache)on0.211s
    2ndon0.201s
    3rdon0.179s

    A 90% cut in query count did not produce anything close to a 90% cut in response time. Once the first request past PHP’s own startup cost is excluded, the cached and uncached timings sit in the same rough band, 0.14 to 0.21 seconds, on this specific install. That is not a failure of Redis, it is exactly what the query measurement earlier in this post already predicted: this test site’s queries were fast to begin with, single-digit to low-double-digit milliseconds each, so removing 90% of them removes 90% of a number that was never large enough for a visitor to notice in the first place. The query count is a mechanism metric. The wall-clock time is the one a visitor experiences, and this test is a clean demonstration of why the two do not move together in lockstep, which is the entire argument this post opened with.

    Testing the case this post says matters most

    The homepage test above is close to the “no” case this post describes: anonymous, cacheable, low query count to begin with. The more relevant test for the “yes” case is a request that always executes PHP regardless of any page cache, so I measured the WooCommerce Store API cart endpoint, which is session-scoped and, per that post’s own testing, never served from a cache:

    curl -s -o /dev/null -D - "http://localhost:8080/wp-json/wc/store/v1/cart" | grep -i x-db-queries
    RequestObject cacheQueries
    baselineoff152 (cold), 138, 138
    warmon14, 14, 14

    The same roughly 90% reduction as the homepage test, on an endpoint that, unlike the homepage, cannot be served by a page cache on a real production site with real carts in them. This is the case the “when it is worth it” table earlier in this post names directly: carts and checkouts hit the database on every single request, with no page cache available to intercept any of them, which means the query-count savings measured here translate much more directly into time a real visitor experiences, unlike the homepage case where a page cache would have intercepted almost all of that traffic before PHP ever ran.

    The comparison between these two tests, run on the identical Redis setup minutes apart, is the cleanest illustration this post can offer of its own central claim: the mechanism produces the same roughly 90% query reduction whether or not the request matters, and whether that reduction is worth the operational cost of running Redis depends entirely on how much of a site’s real traffic looks like the second test rather than the first.

    If you do install one

    Three things that cause the support tickets.

    Set a memory limit and an eviction policy. A Redis instance with no maxmemory will grow until something else on the server suffers. allkeys-lru is the usual choice for a cache.

    Flush it on deploy. Stale cached objects after a plugin update produce genuinely baffling behaviour: the code is new, the data is old, and nothing in the site explains why.

    Check it is actually connected. Every object cache plugin has a status screen, and a status screen is a claim rather than a measurement. Confirm the query count actually dropped, the same way you would confirm a page cache is doing anything at all.

    The wider point

    Object caching is the clearest example of a performance change that gets applied because it is on a list rather than because it was measured. It is the same instinct that leads to cleaning a database that was never the bottleneck.

    Start with where the time is going. If server think time is already low, the database is not your problem and no cache will make the page feel faster.

    Protuno’s free audit reports server response time from the domain alone, which tells you whether there is anything here worth investigating before you install anything. Dash is live now.

    Measure the query count on one client site this week. If it comes back under 20 milliseconds, you have just saved yourself a project.

    What running this test actually took

    Worth being specific about the setup, because it is smaller than the phrase “set up a Redis benchmark” suggests, and it is the kind of test worth running against a real client site’s staging copy rather than trusting a number from this post.

    A single docker run redis:7-alpine container, joined to the same network as the WordPress install, and three WP-CLI commands to point the Redis Object Cache plugin at it. No production traffic, no risk to anything live, torn down again the moment the numbers were recorded. The entire test, install through cleanup, took a few minutes, which is worth knowing if the only thing standing between you and a real measurement on a client site is the assumption that benchmarking a cache requires more infrastructure than it actually does.

    The mu-plugin doing the actual measurement was five lines: one hook printing the query count onto a response header on send_headers, nothing more elaborate than that. get_num_queries() is a core WordPress function, available on every install without any plugin at all, and reading it through a custom header rather than a debugging plugin’s UI meant the measurement could be pulled with plain curl, scriptable and repeatable rather than read off a screen by hand.

    Why the two tests told different stories from the same cache

    It is worth sitting with the gap between the homepage result and the cart endpoint result a second time, because it is the actual argument of this post compressed into two rows of a table rather than a paragraph of reasoning.

    Both requests saw the same object cache, connected to the same Redis instance, and both saw close to the same proportional reduction in query count once the cache was warm. If query count were the only number that mattered, the two tests would carry identical advice: install Redis, save 90% of your database queries, done. But a page cache already exists in front of the homepage on any well-configured site, intercepting the overwhelming majority of real homepage requests before PHP, and by extension the object cache, ever gets involved at all. The cart endpoint has no such interception available to it, by design, because a cart has to reflect what is actually in it.

    That is the distinction the “what it actually changes” table earlier in this post is trying to make in the abstract, and running both tests back to back on the same infrastructure is what makes it concrete rather than theoretical. The mechanism does not know or care which kind of request it is serving. It reduces queries by roughly the same proportion either way. Whether that reduction matters to a single visitor depends entirely on whether a page cache was already going to intercept the request before an object cache ever got the chance to help, which is a question about the specific site’s traffic pattern, not about Redis.

  • WooCommerce Detection From The Outside: One Signal Is Never Enough

    WooCommerce Detection From The Outside: One Signal Is Never Enough

    Short answer: reliable woocommerce detection needs at least two signals. The Store API proves the plugin is active. The cart and checkout pages prove somebody can actually buy something. Those two disagree more often than you would expect, including on one of our own sites.

    Four sites, four different answers

    Table of detection signals across four sites. woocommerce.com returns 200 on the Store API, cart and checkout. siteb.example returns 200 on the Store API with ten products and 200 on shop, but 404 on cart and checkout. sitec.example and wordpress.org return 404 throughout
    The second row is ours and it is the interesting one. WooCommerce is active, serving a

    catalogue of ten products, with a working shop page and no cart, checkout or account pages at all.*

    SiteStore API/shop//cart//checkout/Verdict
    woocommerce.com200n/a200200live store
    siteb.example200, 10 products200404404Woo active, catalogue only
    sitec.example404n/a404n/anot a store, mentions “woocommerce” 11 times
    wordpress.org404n/a404n/anot a store

    A detector keying on the Store API alone would report siteb.example as a live store. One keying on /cart/ would report no store. Both would be wrong, and the real answer is more specific than either: WooCommerce is installed and being used as a product catalogue, with the actual selling happening somewhere else.

    That is a completely legitimate configuration, and it is exactly the case that breaks simple detection.

    I went one layer deeper on siteb.example while re-checking this table, because the Store API response carries more than a product count. It also states, per product, whether the item is purchasable:

    curl -s "https://siteb.example/wp-json/wc/store/v1/products?per_page=3" | python3 -c "
    import json,sys
    for p in json.load(sys.stdin):
        print(p['name'], p['prices']['price'], p['is_purchasable'])
    "
    Performance Leggings 3999 True
    Casual Striped Tee 4499 True
    Denim Dream Jacket 1999 True

    Every sampled product reports is_purchasable: True, with a real price attached, on a site whose /cart/ and /checkout/ both return 404. WooCommerce’s own API considers these items buyable in principle, and no path on the site currently lets a visitor act on that. That is not a contradiction inside WooCommerce, is_purchasable describes the product’s own configuration, in stock, priced, not restricted, independent of whether a checkout flow is wired up to let anyone act on it. It is the clearest evidence yet for why this post’s opening claim holds: the plugin’s own data model can describe a fully sellable catalogue while the site itself provides no way to buy anything from it, and only a check that spans both, the API’s opinion of the product and the actual page that would complete a sale, catches the gap between them.

    Which WooCommerce detection signals to trust

    Table ranking detection signals. The Store API endpoint and cart and checkout page status are strong. WooCommerce plugin asset paths are medium. The word woocommerce appearing in HTML and the generator meta tag are weak
    The bottom two are the signals most detection scripts use, because they are the easiest

    to grep for. They are also the two that produce false positives on any site that writes about ecommerce.*

    SignalStrengthFailure mode
    /wp-json/wc/store/v1/productsstrongproves Woo is active, not that anything can be bought
    cart and checkout return 200strongcan 302 on an empty cart, which reads as healthy
    wp-content/plugins/woocommerce/ assetsmediumabsent when bundled, minified, or CDN-served
    the word “woocommerce” in the HTMLweakeleven mentions here, sells nothing through Woo
    weakfrequently removed, never names WooCommerce anyway

    sitec.example mentions “woocommerce” eleven times in its homepage HTML and sells nothing through it. A word-match detector calls that a store.

    The checkout signal has its own trap, which I found while testing checkout monitoring: a /checkout/ URL requested with an empty cart returns a 302 rather than a 200, because WooCommerce redirects away. A detector following redirects records a success and learns nothing.

    The row in my own table that turned out to be wrong

    I re-ran the exact loop from this post while expanding it, rather than assume the original table still held, and one result had changed enough to be worth investigating rather than just updating a number.

    === sitec.example ===
    store api: 404
    /shop/         404
    /cart/         404
    /checkout/     200
    /my-account/   404

    /checkout/ now returns 200, not 404. That contradicts this post’s own earlier verdict of “not a store.” I checked what was actually there instead of trusting the status code alone:

    curl -s https://sitec.example/checkout/ | grep -oE "<title>[^<]*</title>"
    <title>Checkout | Site C</title>

    A real checkout page, live, serving a title that says exactly what it is. And searching that same page for platform markers found WooCommerce and woocommerce in the HTML, not a generic word mention this time but class names and script paths, the kind that indicate the plugin is actually rendering the page rather than the word simply appearing in prose somewhere on the site.

    So sitec.example is a WooCommerce store, selling the plugin itself, and this post’s own detection table called it “not a store” because the one signal it trusted most, the Store API, returned 404. I checked why:

    curl -s https://sitec.example/wp-json/ | python3 -c "
    import json,sys
    d=json.load(sys.stdin)
    print([n for n in d['namespaces'] if 'wc' in n.lower()])
    "
    []

    No wc/ namespace is registered in the REST API discovery document at all. Not blocked, not firewalled, simply never activated, most likely because the site runs WooCommerce’s older shortcode-based checkout rather than the block-based checkout the Store API exists to serve, so nothing on the site has ever called it and WordPress never registered the routes.

    That is the actual failure mode this post has been arguing against from the first paragraph, found in its own reference table rather than a hypothetical: the single strongest signal in the ranked list, the Store API, produces a false negative on a real, live, selling WooCommerce installation, because the signal’s presence depends on which checkout architecture the store happens to use, not on whether WooCommerce is active and taking orders. A detector trusting the Store API alone, exactly the “strong” signal this post recommended leaning on hardest, would have filed a real customer-facing store as a brochure site.

    The corrected verdict, and the reason a fourth check earns its place: a direct request to the page path itself, checked for a store-specific page title rather than only a status code, catches what an API-only check misses. /checkout/ returning 200 with a title naming itself “Checkout” is a signal in its own right, worth treating as equally strong to the Store API rather than subordinate to it, precisely because the two can disagree, as they do here.

    Following the redirect, not just reading its status code

    The same re-run turned up something worth adding to how the loop is read, on a site already confirmed as a real, functioning store. woocommerce.com itself now redirects three of the four paths this post checks:

    Path requestedStatusRedirects to
    /shop/302/products/
    /checkout/302/cart/
    /my-account/302a WordPress.com OAuth login screen

    None of those are failures. /shop/ moved to a renamed URL, /checkout/ redirects to the cart when nothing is in it, which this post already names as expected, and /my-account/ bounces to a centralised login system woocommerce.com uses instead of WordPress’s own. A loop that stops at the status code and treats every non-200 as “not present” would read all three as absence, on a site that is unambiguously a live, selling store. Add -L to follow redirects, or check the location header explicitly with curl -sI, and read where a redirect lands before deciding what it means, the same discipline the checkout monitoring post already applies to the empty-cart case specifically.

    Running the same loop against a site that definitely is not a store

    For a clean control, I ran the loop against protuno.com itself, which is not WooCommerce, not even WordPress on the visitor-facing side, since the frontend is a separate Next.js application entirely.

    === protuno.com ===
    store api: 404
    /shop/         308
    /cart/         308
    /checkout/     308
    /my-account/   308

    Every path redirects, 308, rather than returning a clean 404 outright, because the frontend strips trailing slashes and issues a permanent redirect to the slash-free version of whatever URL was requested, a routing detail with nothing to do with commerce. Following one of those redirects settles the question properly:

    curl -s -L https://protuno.com/cart | grep -oE "<title>[^<]*</title>"
    <title>Page Not Found · Protuno</title>

    A 404 page, titled as one, reached after the redirect. That is the correct verdict for a site that sells nothing, and it took following the redirect chain to confirm it rather than stopping at the first 308 and guessing what it meant. The general rule this and the sitec.example correction above both point at: a raw status code from any single request in this loop is a clue, not a verdict, and the verdict only becomes trustworthy once you have either read the page title behind a 200, or followed a redirect to see what it actually resolves to.

    The loop

    D=example.com
    printf 'store api: ' ; curl -s -o /dev/null -w '%{http_code}n' "https://$D/wp-json/wc/store/v1/products"
    for p in /shop/ /cart/ /checkout/ /my-account/; do
      printf '%-14s ' "$p"
      curl -s -o /dev/null -w '%{http_code}n' "https://$D$p"
    done

    Read the combination rather than any single line. Store API 200 with cart and checkout 404 means a catalogue. All four 200 means a live selling path. Store API 404 with the word appearing in the HTML means somebody writes about ecommerce.

    Why bother

    Table of four reasons an agency detects this: knowing which client sites take money, applying the right checklist, auditing an inherited portfolio, and scoping a quote correctly
    The third row is the honest reason. Detection is not clever. It is something nobody has

    written down for the whole portfolio, and the answer changes when a client adds a shop without telling anybody.*

    The practical value is triage. A store outage costs trading hours; a brochure outage costs goodwill. Knowing which is which decides who gets phoned at the weekend, and getting that classification wrong in either direction, as this post’s own first table did, means either under-reacting to a real outage or paging someone for a site that was never taking orders.

    It also matters for the mail side. A store sends transactional email that a brochure site does not, so its SPF and DMARC posture carries a different weight and a deliverability problem there costs orders rather than enquiries.

    It also decides which checks apply. Stores need cart and checkout excluded from caching, gateway monitoring and order-rate baselines. Brochure sites need none of that, and running store checks against them generates noise that trains people to ignore the reports.

    Protuno’s free audit detects a storefront from the domain alone and reports whether the cart and checkout addresses answer, which is how the table above was built and, just as importantly, how it kept getting re-checked and corrected while this post itself was being expanded. Straight with you as on every post here: Till, the eCommerce agent, is live now, since 27 October 2026, and runs a WooCommerce Health Review inside your own AI app. The loop and the follow-up questions above are still the way to check a store from the outside, and neither takes long to run.

    Run the loop across your portfolio once. On ours, siteb.example came back exactly that way: a catalogue nobody had flagged as anything other than a marketing site, and sitec.example came back the other way, a real store this post’s own first pass had wrongly written off.

    Both mistakes cost something different. Missing a catalogue-only site means treating it like a brochure when it is one migration away from taking orders. Missing a live store, the actual error this post’s original table made, means a site is generating revenue with nobody monitoring the checkout for the outages the gateway health post describes. Neither failure announces itself. Both are visible in under a minute to whoever runs the four-line loop and reads what each result actually says, rather than trusting the first signal that answers.

  • WordPress Rollback After An Update: What It Can Undo, And What It Destroys

    WordPress Rollback After An Update: What It Can Undo, And What It Destroys

    Short answer: an automatic wordpress rollback update path should restore files freely and restore the database carefully. A database restore reverts the site to the moment the backup was taken, which destroys every order, form entry and comment created since. On a busy store that can be worse than the failure it is fixing.

    This is the part of the design that took us longest, and it is the part most discussions skip.

    Rollback is a structural property, not a promise

    Table contrasting structural guarantees with policy: a backup proved before any write, rollback as a step inside the playbook that made the change, and read-only agents built incapable of writing, against an agent merely instructed to be careful
    An instruction is a policy. Taking the tool away is a good deal stronger than a

    policy.*

    The key design decision is that rollback is recorded as the run happens, change by change, rather than left to an incident process somebody triggers afterwards. If reverting requires a human to notice, decide and act, then the broken state is live for as long as that takes.

    What counts as broken

    Table of five detection methods for a failed update: homepage status, homepage body size, error strings in the body, a completed user journey, and response time, each with what it catches and what it misses
    Row four is the one that matters on a store, and it is the one most rollback logic omits

    because it is harder to implement than a status check.*

    DetectionCatchesMisses
    homepage returns non-200a fatal erroreverything that breaks below the homepage
    homepage body under a size thresholda white screen returning 200a page that renders but is wrong
    body contains “critical error” / “database error”WordPress’s own error pageerrors that render inside a normal template
    a real journey completesa broken cart or formslow but functional, visual regressions
    response time within a normal banda change that made the site unusablenothing functional, noisy on shared hosting

    A homepage-only trigger never fires on the most common real failure: an update that leaves the homepage returning 200 while the checkout 500s. I demonstrated exactly that failure with three green URLs and one red one.

    So the verification step has to walk something real. On a store, a cart and a checkout. On a brochure site, a form. The homepage is the weakest possible signal and the easiest to implement, which is why it is usually the only one there.

    What a WordPress rollback after an update actually reverses

    Table of what changes during an update and whether each is reversible. Plugin files and database schema are reversible. Orders and form entries created since, emails already sent, payments taken and webhooks fired are not
    The amber rows are why an automatic database rollback on a busy store is more dangerous

    than the failure it fixes.*

    Changed during the updateReversible?Notes
    plugin and theme filesyesrestore the archive
    database schema and optionsyesrestore the dump taken before the write
    orders or form entries created sincenoa database restore destroys them, the real hazard
    emails already sentnogone, cannot be recalled
    payments already takennohandled by the gateway, not your backup
    third-party webhooks already firednothe receiving system already acted on it

    This is the argument I would most want an agency to take from this post.

    Restoring files is nearly always safe. The plugin was replaced, you put the old one back, and nothing a customer did is affected.

    Restoring the database is a different act entirely. It rewinds state. Ten minutes of orders on a store doing real volume are simply gone, and unlike a broken checkout, that damage is not recoverable by fixing the code.

    So the safe automatic behaviour on a transactional site is: restore files automatically, never the database, and get a human involved. The database rollback is a decision with commercial consequences and it deserves a person.

    Breaking a real site and restoring only the files, to see if the claim holds

    The argument that a file-only restore is safe while a database restore destroys business activity is easy to state and worth actually testing rather than taking on faith. I did, against a real WordPress install with a real order on it.

    First, a real order, representing business that happened since the last backup:

    wp wc shop_order create --status=processing --set_paid=true
    # Success: Created shop_order 117.

    Then a real backup of one plugin’s files, and a deliberately broken update, corrupting the plugin’s main PHP file the way a bad release or a failed deployment actually does:

    tar -czf plugin-backup.tar.gz wp-content/plugins/contact-form-7
    echo '<?php syntax error not valid php {{{' >> wp-content/plugins/contact-form-7/wp-contact-form-7.php
    curl -s -o /dev/null -w '%{http_code}\n' https://example.com/
    500

    A genuine, site-wide fatal error, not a simulation described in prose. The homepage, every page, everything running PHP on that install, returned 500. Then the file-only restore, deliberately touching nothing in the database:

    rm -rf wp-content/plugins/contact-form-7
    tar -xzf plugin-backup.tar.gz -C wp-content/plugins
    curl -s -o /dev/null -w '%{http_code}\n' https://example.com/
    200

    Site recovered, plugin back to its working version, confirmed. And the order created before any of this happened:

    wp post get 117 --field=post_status
    # wc-processing

    Untouched. Still there, still in the same status, because a file-only restore never opened the database at all. That is the entire argument of this post’s central table, demonstrated end to end on a real install rather than asserted: the fatal was real, the recovery was real, and the business data created in between survived because the two categories of restore genuinely are independent operations, not merely described as independent in theory.

    The window

    Table of three choices that determine how long a broken state stays live: how soon after the write you check, how long you wait before deciding, and whether you roll back or alert
    On a brochure site, roll back automatically and tell somebody afterwards. On a store,

    restore files automatically and get a human on the database.*

    One check immediately after the write catches the fatal before a visitor sees it. Waiting for three consecutive failures avoids reverting because of one unlucky request. Both are defensible, and the right answer depends on how much traffic the site takes.

    What is not defensible is having no policy, which is where most agencies are: the update runs, and whether anybody notices a problem depends on who happens to look.

    The failure that arrives late

    Everything above deals with an update that breaks the site immediately. The harder case is the one that surfaces on Thursday.

    A plugin update that works fine until a scheduled job runs. A change that only affects logged-in users, on a site whose traffic is mostly anonymous. A theme update that broke one template nobody loads until the weekend. In each case the post-update check passed honestly, because at that moment nothing was wrong.

    Two things help, and neither is a rollback.

    Keep the before-state longer than the run. If backups are discarded once the check passes, you cannot revert a regression found two days later. Retention is a policy decision, and “until the next backup” is not enough.

    Watch the things that fail quietly. The failure that surfaces late is nearly always in a surface nobody looks at: a scheduled job that stopped, a form that began silently failing, a checkout that broke for one payment method. Those need their own checks, running on their own schedule, independent of any update.

    An update rollback protects the ten minutes after a write. It does nothing about the rest of the week, and pretending otherwise is how agencies get surprised.

    What to do without any of this

    The manual version is worth writing down even if nothing automates it.

    Before the update, record the versions and take a backup you have actually proved restores. After the update, load the homepage and walk one real journey. If either fails, restore files first and only consider the database if the files alone did not fix it.

    And decide the store policy in advance. “Do we ever automatically restore the database on this site?” is a question with a different answer for a brochure site and a shop, and the worst time to answer it is at the moment it matters.

    Protuno records what a run changed as it changes it, and the run report lets you undo the playbooks whose changes are reversible: plugin updates, spam comments and broken links. Changes made by running PHP are recorded as unable to be undone, and those runs are gated on a backup that has been checked instead. The Super Agents are live now.

    One last practical point, because it is the cheapest safeguard on this page and nobody does it: update in an order that limits blast radius. One site at a time rather than the whole portfolio, and the least commercially important site first. If a plugin update is going to fatal, you would rather learn that on the site nobody visits than on the store. Staggering costs nothing and turns a portfolio-wide incident into a single-site one.

    Write down, for your three largest client sites, what should happen automatically if an update breaks them. If the answer differs between the three, you have just discovered why a global setting was never going to work, and you have written the first useful line of your own rollback policy.

    What the test did not cover, and why that matters

    The demonstration above proves the mechanism this post argues for. It is worth being precise about what it does not prove, because a single clean test on a lab install is not the same claim as “this always works.”

    The break I introduced was a fatal PHP error, the easiest failure mode there is to both cause and detect: the homepage went straight to 500, exactly the top row of this post’s own detection table, the one signal every basic rollback tool already checks. That is deliberately the easy case. A real bad update is just as likely to produce the harder failures this post spends most of its length on: a homepage that stays at 200 while the checkout breaks, a regression that only shows up for logged-in users, a template fault that nobody hits until the weekend. A file-only restore would fix all of those the same way it fixed the fatal here, because the mechanism, put the old files back, does not care what kind of bug the new files introduced. What differs entirely is detection, and this test intentionally used the one failure every rollback system already catches, precisely so the restore step itself, not the detection step, could be verified cleanly and unambiguously.

    The order surviving is also a smaller claim than “nothing about the site is ever affected by a file-only restore.” It proves the database was never touched, which is the actual argument this post makes about orders and form entries specifically. It says nothing about whether the corrupted plugin, while broken, could have caused other damage in that window, a partial write, a failed webhook, a queued email that errored out mid-send. Those are separate risks with separate mitigations, not something a files-versus-database distinction resolves on its own, and conflating “the order record survived” with “nothing bad happened during the outage” would overstate what this specific test actually showed.

    Why the test still matters despite those limits

    None of that weakens the core point. It sharpens it. The easiest possible failure to detect and the cleanest possible restore to perform is the floor this mechanism has to clear before it is worth trusting on anything harder. It cleared it: real fatal, real recovery, real order untouched, no approximation anywhere in the chain. A file-only restore that failed to recover a site from its own easiest case would not be worth building a rollback policy around at all. One that succeeds cleanly on the easy case, and is architecturally identical regardless of which kind of bug caused the failure, is worth trusting on the harder cases too, precisely because the restore mechanism itself does not know or care what kind of bug it is undoing. The part that still needs a human, or a much better detection system than a homepage status check, is finding the harder failures in the first place. Fixing them, once found, is the same tar-and-restore this test just proved works.

  • Payment Gateway Monitoring: Nothing Breaks, The Money Just Stops

    Payment Gateway Monitoring: Nothing Breaks, The Money Just Stops

    Short answer: payment gateway monitoring cannot be done with status codes, because a broken gateway leaves the store returning 200 on every URL. The signal that catches it is the ratio of failed to successful orders, measured against the store’s own baseline. Everything else is a proxy.

    Five failures, none of which take the site down

    Table of five gateway failures with site status and how you currently find out. Expired credentials, a plugin update changing a setting, a provider outage, a payment method silently disappearing, and an account suspended for review. All leave the site returning 200
    The fourth row is the most insidious. A checkout offering three payment methods that

    silently drops to two keeps taking money, so nothing looks broken.*

    FailureSite statusHow you find out today
    API credentials expired or rotated200a customer emails to say payment failed
    gateway plugin update changed a setting200same
    gateway’s own outage200their status page, if anybody is watching it
    payment method removed from checkout200orders quietly stop for that method only
    account suspended for review200an email to whoever set the account up

    That specific category of partial, quiet failure is worth dwelling on carefully. A total gateway outage is loud, because orders stop entirely and somebody notices within hours. A single method disappearing is quiet: the store keeps trading, revenue dips by whatever share that method carried, and the dip reads as a slow week.

    What actually signals it

    Table of three signals in increasing order of proof. Payment methods rendering at checkout is low effort and proves the plugin loaded. The gateway's status page proves the provider is up but not that your account works. The failed-order ratio against baseline is the only outcome measure
    The third row catches all five failure modes, because it does not care why the payment

    failed. It is also the only one that works when the problem is on the provider’s side.*

    SignalEffortWhat it proves
    payment methods render at checkoutlowthe gateway plugin loaded and is offering itself
    the gateway’s status page or API healthmediumthe provider is up, not that your account works
    failed-order ratio against baselinemediumpayments being attempted and refused, the only outcome measure

    The first signal is nearly free once you are already walking a product to the payment step. I had written it here as “assert that a payment method appears in the checkout HTML”, and then I went and measured it, and that advice is wrong on any store using the block checkout. More on that below, because it changes the check.

    The third is the one that measures the outcome, and it is the failed-order ratio rather than a raw count, because a ratio works on a store taking three orders a day and one taking three hundred.

    The payment gateway monitoring check that takes one request

    I built a store to break on purpose: WordPress 7.1, WooCommerce 11.1.0, the block checkout, one virtual product, three payment methods enabled. Then I turned the gateways off one at a time and measured the store from outside, as a logged-out visitor with a product in the cart.

    Table of three gateway states measured from outside. With three gateways enabled, one removed, and all three removed, the homepage returns 200, the checkout returns 200, and zero payment methods appear in the checkout HTML in every case. Only the Store API payment_methods field changes, from bacs cheque cod, to bacs cod, to an empty array
    The bottom row is a store that cannot take a single payment. Both pages still return 200

    and the checkout page is 0.5% smaller. Nothing a status monitor watches moved at all.*

    Gateway stateHomeCheckoutMethods in HTMLStore API payment_methods
    3 of 3 enabled2002000bacs, cheque, cod (healthy)
    1 removed2002000bacs, cod (partial)
    all 3 removed2002000[] (takes no money)

    Two things fell out of that, and one of them corrected my own draft.

    The checkout HTML contains no payment methods. Not fewer, none. The block checkout renders the payment step in JavaScript from data fetched after page load, so the gateway IDs never appear in the server response. My column for “methods found in HTML” reads 0 in all three rows, including the healthy one. Any check that greps the checkout page for bacs or stripe will report a healthy store as broken, or a broken store as healthy, depending on which way you wrote the assertion.

    The Store API tells you the truth, unauthenticated. WooCommerce exposes the cart at /wp-json/wc/store/v1/cart, and the response carries a payment_methods array. It is populated from get_available_payment_gateways(), the same call the checkout itself uses, so it reflects availability rather than configuration. A gateway that is enabled in settings but failing its own availability check does not appear. That is exactly the distinction that matters.

    So the check is: add a product to the cart, read that one endpoint, and compare the array to the one you saw yesterday. No admin credentials, no test order, no headless browser.

    GET /wp-json/wc/store/v1/cart
      -> "payment_methods": ["bacs","cod"]     # yesterday: ["bacs","cheque","cod"]

    One method vanished and every status code in the building stayed green.

    Caveat I owe you, because it nearly caught me while writing this. My first run of this probe returned an empty array on a healthy store, and it was convincing enough that I started drafting a paragraph about the Store API being unreliable. It was not. I was pointing the probe at the wrong container. Two WordPress installs, two ports, one transposed digit. If a monitoring result surprises you, confirm what you are actually talking to before you write it down, and definitely before you tell a client their store is broken.

    Reproducing the whole experiment on a second, separate install

    I rebuilt this exact test on a different WordPress install while expanding this post, rather than trust the original numbers alone, and hit a third gotcha before getting the same result.

    The Store API request returned the site’s own homepage HTML instead of cart JSON, on both the plain and trailing-slash forms of the URL. The cause was not a wrong container this time. It was plain permalinks: this install had no pretty-permalink structure set, so /wp-json/... never reached the REST router at all and fell through to whatever the default front-end handler serves. The fix is the same fallback WordPress documents for exactly this situation:

    curl "http://example.com/index.php?rest_route=/wc/store/v1/cart"

    That form bypasses the rewrite rules entirely and reaches the Store API regardless of permalink structure, which is worth keeping in a monitoring script’s back pocket specifically because a site’s permalink setting is exactly the kind of quiet configuration detail nobody checks before assuming a REST endpoint will simply respond.

    With that fixed, the reproduction matched the original result exactly:

    3 gateways enabled:  payment_methods: ["bacs", "cheque", "cod"]
    1 disabled (cheque):  payment_methods: ["bacs", "cod"]
    all 3 disabled:       payment_methods: []

    At every step, curl -o /dev/null -w '%{http_code}' against the homepage and the checkout page both returned 200. A store that had just been reduced from three working payment methods to zero looked, by every status-code check running anywhere on it, completely healthy throughout. That is not a subtler version of the original finding. It is the identical mechanism, reproduced from nothing on a separate install, which is the strongest form of confirmation this kind of claim can get: not “it happened once,” but “it happens whenever the conditions are the same,” regardless of which specific WordPress instance is asked.

    The approach not to take

    Table of four approaches. Placing small real orders on a schedule is rejected because of real money, tax records and fulfilment. Using test mode on production is rejected. A staging store in test mode is partial. Walking to the payment step and watching the failure ratio is the recommended combination
    The second row happens more often than you would think, usually during debugging, and is

    occasionally left on. A store in test mode accepts orders and takes nothing.*

    Every single agency considers the first row eventually, and it is worth writing down plainly why the correct answer is no. A real order every day means real money, real tax records, a real fulfilment queue with a person attached, and real refund admin. Multiply all of that by every single store you actually look after.

    The synthetic walk combined with the ratio check gets you exactly the same real coverage with none of that downside.

    The nonce mechanics a monitoring script actually needs

    Building the reproduction above end to end, not just reading the response, surfaced a requirement this post’s original walkthrough does not spell out: adding an item to the cart through the Store API needs a fresh nonce, read from the cart response itself, not a static value a script can hardcode once and reuse.

    # 1. read the cart first, to obtain a nonce
    curl -s -c jar -b jar ".../wc/store/v1/cart" -D - -o /dev/null | grep -i '^Nonce:'
    
    # 2. use that exact value on the write request
    curl -s -c jar -b jar -X POST ".../wc/store/v1/cart/add-item" \
      -H "Nonce: <value from step 1>" \
      -H "Content-Type: application/json" -d '{"id":122,"quantity":1}'

    Skipping step one and guessing at a nonce, or reusing one from an earlier session, produces woocommerce_rest_invalid_nonce and a 403, which is a real, specific failure this monitoring approach can hit long before it ever reaches the payment-methods check it exists to run. A script built against this endpoint needs to read-then-write in that order every single time, using the same cookie jar throughout so the session the nonce was issued for matches the session making the follow-up request. That is a small implementation detail and it is exactly the kind of thing that turns a monitoring script that worked once in testing into one that silently fails every run in production, reporting nothing rather than reporting a problem, which is the worst possible failure mode for a monitor.

    When the ratio moves

    Five-step runbook: check the gateway's status page, confirm payment methods still render, check whether anything updated recently, verify API credentials in the gateway dashboard, and read the actual decline reasons on failed orders
    Step five is the one people skip and it usually contains the answer.

    That last step deserves emphasis. WooCommerce records a reason against failed orders, and reading them separates the two cases that matter: genuine customer declines, which are a normal cost of trading, and integration errors, which are yours.

    A run of “insufficient funds” is an ordinary Tuesday, nothing to escalate. A run of authentication or configuration errors is a real incident, and the difference between the two readings is one single column in the order list, checked or ignored.

    Where this sits in the wider picture

    Gateway health is the last of three checks that together cover a store, and they answer different questions.

    Is the store even reachable at all? Uptime monitoring. Necessary, and the weakest of the three.

    Can a customer reach the payment step? The synthetic walk, which catches a checkout returning 500 while the homepage stays green.

    Does the money actually really move? The failed-order ratio. The only one that measures the outcome the client cares about.

    Most stores have the first and neither of the others, which is why the common experience of a payment outage is a customer email rather than an alert.

    Protuno’s eCommerce agent, Till, runs a read-only WooCommerce Health Review inside your own AI app. See the playbooks page for what it covers.

    If you look after a store, do the manual version this week: open the checkout, count the payment methods, and write the number down. That number changing is the whole check, and nothing currently tells you when it does.

    Having built and rebuilt this probe twice now, on two separate installs, the honest summary is that the concept is simple and the implementation has three small, specific traps: point it at the right install, handle plain permalinks with the rest_route fallback, and read a fresh nonce before every write. None of the three is difficult once known. All three are exactly the kind of thing that only surfaces by actually building the thing and running it against a real store, rather than reading the mechanism described and assuming it will simply work.

  • The WordPress Client Report: I Found 30 Things And Reported 7

    The WordPress Client Report: I Found 30 Things And Reported 7

    Short answer: a good WordPress client report is short because most findings are not news. I ran one pass of external checks across six of our own sites, got 30 findings, and judged 7 of them worth telling anyone about. The other 23 were true, unchanged since last month, and unchanged next month.

    Collecting the data took under seven seconds a site. Deciding what to leave out took considerably longer, and that is the whole job.

    What one pass actually turns up

    Table of six sites with raw finding counts and how many were worth reporting. sitea.example 4 findings 1 worth reporting, siteb.example 6 and 2, sitec.example 5 and 1, sited.example 5 and 1, sitee.example 2 and 0, sitef.example 8 and 2
    One site produced nothing worth reporting. That is a legitimate result and almost nobody

    sends that report.*

    SiteRaw findingsWorth reporting
    sitea.example41
    siteb.example62
    sitec.example51
    sited.example51
    sitee.example20
    sitef.example82

    The row I want to defend is sitee.example. Two findings, zero reportable. If a client pays a retainer and the honest answer this month is “nothing needed your attention”, the report should say that, in one line, and stop.

    Padding it out is how reports get long. Long reports get skimmed. Skimmed reports mean the one thing that did matter gets skimmed too.

    The rule I used to cut 23 of them

    Sort the findings by how many sites they appear on.

    Table splitting the same 30 findings by prevalence. Missing DNSSEC on 6 of 6 sites, missing CAA on 5 of 6, no Content-Security-Policy on 5 of 6, no Referrer-Policy on 5 of 6. Usernames exposed via REST on 3 of 6 and DMARC not fully enforcing on 4 of 6 are site-specific and actionable
    If something appears on every site you look after, it is a decision you have not made yet,

    not a finding about any one site.*

    FindingSites affectedWhat it actually is
    missing DNSSEC6 of 6every site, a posture decision made once
    missing CAA record5 of 6every site but one, same
    no Content-Security-Policy5 of 6a project, not a monthly line item
    no Referrer-Policy5 of 6same
    usernames exposed via REST3 of 6site-specific, actionable, report it
    DMARC not fully enforcing4 of 6site-specific, actionable, report it

    Caveat: six sites, all ours, one pass. The specific counts are our portfolio’s, not a benchmark. The rule that survives the caveat is the sorting method itself: anything on every site is a standards conversation, not a finding, regardless of what the six numbers happen to be.

    Missing DNSSEC turned up on all six. That is not six findings. It is one conversation about whether we run DNSSEC, held once, with an answer that applies to everything. Putting it in six monthly reports is six chances for a client to read a red item and do nothing, which trains them that red items do not require action.

    The two that survived the cut are site-specific and have an obvious next step. Usernames readable through the REST API affected three sites and not the others, so something differs between them. DMARC not fully enforcing affected four, each for a different reason, and I wrote up what a policy at 5% is really doing separately.

    Those go in the report. The other four go in a single note about portfolio standards.

    Running the same pass again, and applying the same rule to what changed

    I re-ran the checks across the same six sites while expanding this post, rather than assume the original 30-versus-7 split still describes our portfolio.

    dig +short DS "$d"                              # DNSSEC
    dig +short CAA "$d" | wc -l                      # CAA record count
    dig +short TXT "_dmarc.$d"                       # DMARC policy and pct
    curl -sI "https://$d/" | grep -iE 'content-security-policy|referrer-policy'
    curl -s -o /dev/null -w '%{http_code}' "https://$d/wp-json/wp/v2/users"

    DNSSEC is unchanged: zero of six domains publish a DS record, still a single standards conversation rather than six findings. CAA moved on exactly one site, sitea.example, which now publishes a complete record with ten entries, the fix already documented in the nameserver diversity post. Applying this post’s own sorting rule to that change: one site out of six moving is still a portfolio pattern, five of six still missing CAA, still a standards conversation rather than five separate line items, just with different arithmetic behind the same one-paragraph note.

    The REST-endpoint and DMARC readings are where this re-run earns its place, because they moved in ways worth reporting individually rather than folding into a portfolio note. sitea.example, open in the original pass, now returns 401 on the users endpoint, fixed. siteb.example and sitec.example remain open, and a domain not in the original six, sitef.example, is open too. DMARC enforcement varies site by site in a way that is itself the finding: sitec.example and sitee.example enforce at p=reject with no pct limit, full strength. sited.example sits at p=none, monitoring only. sitea.example and siteb.example both carry p=quarantine; pct=5, meaning the policy only actually acts on five percent of failing mail, a partial rollout that has apparently sat at five percent since before this post was first written rather than progressing toward full enforcement.

    That last detail is exactly the kind of finding this post argues belongs in a report and not in a portfolio-wide footnote: it is specific to two sites, it has an obvious next step, raise the percentage, and it is not something a reader would guess from a bare “DMARC is configured” status line. A report reading “DMARC enforcing” on sitea.example would be technically true and practically misleading, the same category of half-true status this post’s five-section structure is built to avoid.

    Collecting the data was never the hard part

    I timed it, because I wanted to know whether the reporting problem is a tooling problem.

    Table of report steps with measured times. Fetching the site and reading headers about one second, querying users media comments and categories about 3.4 seconds, reading abilities and settings about 1.8 seconds, resolving DMARC CAA and DNSSEC about half a second, all automatable. Deciding which of it a client should read is not automatable
    Under seven seconds a site. Across forty sites that is about four minutes.

    Four minutes. That is the entire data collection cost for a forty site portfolio, using nothing but the REST API and public DNS.

    So when a reporting tool sells you on gathering everything automatically, understand that it is solving the four minute problem. The expensive part is the last row of that table, and no tool I have used does it. They produce a longer document faster.

    What I would actually put in a WordPress client report

    Five report sections in order. What changed since last month, containing diffs not state. What I did about it, with before and after numbers. What needs your decision, usually one or two items. What I am watching, kept to two lines. Everything else, available on request and not in the report
    Ordered by how likely the client is to read that far.

    The first section is diffs. Not “SSL certificate valid”, which is state and is true every month, but “certificate renewed on the 14th”, which is a change. State belongs in a dashboard the client can open. A report is for things that moved.

    The third section is where the retainer is justified. One or two items, each phrased as a question with options and a recommendation. Not “CSP not implemented” but “we can add a content security policy to the three sites running checkout; it needs about half a day each and may break two embeds we would have to test; want me to start with the busiest one?”

    That is a paragraph a client can answer. The finding underneath it is the same finding either way.

    The second section is worth one more sentence, because it is where most reports quietly cheat. “Optimised images” is activity. “Homepage went from 1.5MB to 640KB” is a result. If you cannot put a before and a after next to the work, either the work did not move a number or you did not measure it, and both of those are worth knowing before a client asks.

    The fourth section exists to stop the third one growing. There is always a pile of things that are real but not yet worth acting on, and without somewhere to put them they either leak into the decision section or get dropped entirely. Two lines. A list of names, no detail.

    And the fifth section is a link, not an appendix. Everything I collected in those seven seconds is available if anyone wants it. Almost nobody does, and offering it is what makes the short report credible rather than lazy.

    The uncomfortable bit

    Writing this meant auditing our own sites and publishing what came back, including the 10 usernames readable on two of them and a domain with no DMARC at all. I would rather do that than write a post about reporting standards using invented examples.

    It also made the point better than an argument could. I ran the checks, got 30 findings, and my first instinct was to fix a few quietly and report the rest. That instinct is exactly what produces reports nobody trusts, and I do not think it comes from dishonesty. It comes from the report being treated as a performance review of the agency rather than a status update on the site.

    That framing is worth naming out loud with a client, once, early. If the report is a scorecard, every finding is an accusation and the incentive is to send a clean one. If the report is a shared view of the estate, a finding is just work, and the useful version of the document becomes possible.

    Protuno’s marketing agent, Echo, runs inside your own AI app, and every run’s report is saved to Activity, so you can compare one run with the next.

    If you send client reports, try this next cycle: write the “what needs your decision” section first, and if it is empty, send a three line email instead. See whether anybody complains.

    What this re-run would actually say in a report, written out

    Worth finishing the exercise all the way to the document this post argues for, using the fresh numbers above, because describing the five sections in the abstract is a different exercise from actually writing one.

    What changed since last month: CAA fixed on sitea.example. REST user enumeration fixed on sitea.example, still open on siteb.example and sitec.example.

    What I did about it: Published the CAA fix for sitea.example; confirmed via a fresh ten-record read. No action yet on the two sites still exposing usernames.

    What needs your decision: siteb.example and sitea.example’s DMARC policy has sat at pct=5 since before this was last reported, enforcing on one message in twenty. Raising it to 25%, then 50%, then full enforcement over the next few reporting cycles is the standard rollout path, and it needs someone to actually schedule the next step rather than leave it at the figure it was set to during initial rollout. Want me to move it to 25% this week and watch the DMARC aggregate reports for anything that breaks?

    What I am watching: sitef.example’s REST users endpoint, newly checked, currently open, not yet triaged against the rest of the portfolio’s standard.

    Everything else: the full six-site pass, all fields, available on request.

    That is six lines, not thirty findings, and every one of them is either a change, a decision, or an explicit statement of what is being watched without yet requiring action. The DNSSEC and four-of-five-sites CAA gaps that this post’s original sorting rule already filtered out do not appear at all, correctly, because nothing about them changed and they remain a standards conversation rather than six repeated line items nobody reads past the third time.

  • WordPress Agency Automation: Detection Is Solved, Deciding Is Not

    WordPress Agency Automation: Detection Is Solved, Deciding Is Not

    Short answer: every operational check an agency runs on a WordPress site can be automated. Almost none of the fixes can. I went through 55 checks one at a time and found eight I would let run unattended on a client site. The other 47 need a person, and that is not a temporary state of the tooling.

    This is the last operational post in a thirty day run, and it is the one I could only write after doing the other 57.

    The number that surprised me

    I built a backlog of 55 checks while writing this blog, one post per check, each written after actually running it. Then I classified them by how far automation gets.

    Table of four stages of operational work. Detecting the problem, 55 of 55 checks, 100% automatable. Applying the fix unattended, 8 of 55, 15%. Proposing a fix for approval, 31 of 55, 56%. Deciding whether it matters, 16 of 55, 0% automatable because it needs business context the site does not contain
    My labels, and the rule behind each one is stated below so you can disagree with the

    specific calls.*

    StageChecksAutomatableWhy
    detecting the problem55 of 55100%every check runs from outside the site or over its own API
    applying the fix unattended8 of 5515%reversible header and file changes only
    proposing a fix for approval31 of 5556%agent drafts the change, a person says yes
    deciding whether it matters16 of 550%needs business context the site does not contain

    I expected the fix number to be low. I did not expect it to be eight.

    And the detection number matters just as much in the other direction. If every check runs automatically, then the excuse that made agency operations tolerable for twenty years is gone. Nobody gets to say a problem went unnoticed because checking forty sites by hand was impractical. It is not impractical any more. It takes seconds.

    The eight

    Table of the eight checks safe to run unattended. X-Content-Type-Options, X-Frame-Options and Referrer-Policy are response headers removed by removing the header. Response compression is a server setting turned off. Static asset cache headers cost one max-age of staleness at worst. llms.txt is a new file, deleted. Open Graph tags and the viewport meta tag are markup, removed
    HSTS is deliberately absent, and it is the most interesting omission on the list.
    CheckWhat it changesHow the agent undoes it
    X-Content-Type-Optionsresponse headerremove the header
    X-Frame-Optionsresponse headerremove the header
    Referrer-Policyresponse headerremove the header
    response compressionserver settingturn it off
    static asset cache headersresponse headerworst case, one max-age of staleness
    llms.txta new filedelete the file
    Open Graph tagsmarkup in remove the tags
    viewport meta tagmarkup in remove the tag

    The test is not how small a change is. It is whether the thing that made it can put it back.

    HSTS fails that test in a way that is easy to miss. It is one header, one line, universally recommended, and once a browser has seen it the policy is cached for the whole max-age. The agent that set it cannot unset it in anybody’s browser. That is not a reason never to add HSTS. It is a reason a person should decide, once, per site.

    Everything on that list, by contrast, is undone by deleting what was added. Bad decision, one request, back to where you were.

    Two of the eight will look too trivial to bother automating, and that is the wrong way to read them. A viewport meta tag and an llms.txt file are each five minutes of work. The value is not the five minutes, it is that they get done on all forty sites rather than on the four you happened to open this month. Automation’s return on small items comes from coverage, not from the time per item.

    The reverse also holds, and it is worth saying because it cuts against the usual pitch. The biggest wins on any given site are almost always in the 47, where a person has to decide. Automation does not get you the big wins faster. It gets you the small wins everywhere, and it tells you where the big ones are.

    What actually changes about the job

    Table comparing agency work before and after automation. Finding the work goes from hours of manual checking to seconds per site, gone. Deciding what matters goes from skipped for lack of time to the whole job, grew. Applying safe fixes goes from manual and often skipped to automatic, gone. Applying risky fixes stays manual but proposed. Explaining it to a client gets easier. Being accountable stays yours
    The two rows marked unchanged are the two clients are actually paying for.
    Part of the jobBeforeAfter
    finding the workhours of manual checkingseconds per site (gone)
    deciding what mattersskipped for lack of timethe whole job (grew)
    applying safe fixesmanual, often skippedautomatic (gone)
    applying risky fixesmanualstill manual, but proposed (unchanged)
    explaining it to a clientwritten from memorywritten from a diff (easier)
    being accountable for ityoursstill yours (unchanged)

    The second row is the uncomfortable one and I want to be direct about it. Automation does not reduce the judgement required. It increases it. You used to see the handful of findings you had time to look for. Now you get all of them, on every site, every day.

    I lived this while writing the client reporting post: six sites, 30 findings, and the real work was deciding that 23 of them were not worth anyone’s attention. That triage is not a step before the job. It is the job.

    The part nobody puts in the pitch

    Table of numbers from this project. 58 posts written, each on a measurement taken for it. 201 graphics rendered from data collected during the same work. 6 sites audited producing 30 findings, ours, published rather than quietly fixed. 3 false findings caught, each confident plausible and wrong. Over 20 publishes blocked by an automated gate
    The fourth row shaped everything else in this post.

    Three times in thirty days, an automated check handed me an answer I believed and that was wrong. A page reported zero images because a compressed response was decoded with the wrong algorithm. A caching plugin appeared broken because I had deleted a directory myself. A cart appeared to leak between visitors because a product name matched in a grid block.

    None of those were subtle. All three were caught by asking a second question, not by being careful. I wrote up what that means for automating performance work separately, and the conclusion generalises: the automation belongs around the measurement before it belongs around the change.

    An agency running unattended fixes on client sites without a second source for every trigger is going to break something and not know why. That is a straightforward prediction and I would like to be wrong about it.

    The failure mode will not look like a tool malfunctioning, either. It will look like a site that is fine, a dashboard that is green, and a client who mentions in passing six weeks later that something stopped working. By then the change that caused it is 400 entries down a log nobody reads.

    The same project, a second wave later, with the same shape holding

    This post was written at 58 posts. The project since grew to a full 60, and a separate pass went back through most of the backlog expanding each one to real depth, re-running every measurement rather than trusting the original reading. That second wave is itself more evidence for this post’s central claim than the original numbers were, because it was, in effect, the same detection-versus-decision split applied recursively to the blog’s own back catalogue.

    Re-checking found real drift on both sides of the ledger. Some findings had been quietly fixed: a CAA record completed, a site’s REST user enumeration closed, an image library fully converted to modern formats. Others were confirmed still open, unchanged since first published: a domain still missing DKIM, a debug log still exposed, a DMARC policy still capped at five percent enforcement months after that cap was first flagged. And a handful of genuinely new false findings turned up in the process of re-measuring, not from the original checks but from the re-checking itself: a brotli response decoded as gzip a second time, a Store API request that silently returned homepage HTML instead of JSON because of plain permalinks, a WooCommerce install whose block-based checkout broke an HTML-scraping assertion that had worked perfectly on a different, classic-checkout install.

    None of that changes the eight-of-fifty-five ratio this post opened with. If anything it sharpens the argument, because the second wave was pure detection work, re-running checks, comparing results, writing down what changed, and every single piece of judgement in it, which finding still mattered, which correction was worth publishing, which drift was meaningful versus noise, still required a person reading the result and deciding. Automating the second pass at checking would have been exactly as straightforward as automating the first one. Automating the decisions built on top of it would not have been, for the same reasons this post already gives.

    What WordPress agency automation should mean in practice

    Automate every check. There is no reason left not to, and the checks are the cheap part.

    Then spend the time you get back on the two rows that did not change. Decide what matters, and be accountable for it. Those are not consolation prizes left over after the machines took the good work. They are the work, and they were always the work, obscured for two decades by the tedium of finding things out.

    Protuno is built on this split: a catalogue of 27 playbooks across six agents, permissioned per site, with the unattended tier kept deliberately small. Everything in this post is why. The Super Agents are live now.

    If you run an agency, do this one thing this week: pick your ten busiest sites, run one check across all ten, and look at the shape of the result. Ten hits means you have a standard to set. One hit means you have a site to fix. Neither answer was available to you before, and both are more useful than anything the check itself found.

    Why the re-check wave belongs in this table, not outside it

    It would be tempting to file the second wave of work under something other than the four stages this post opened with, since re-verifying a past finding feels different from discovering a new one. It is not different, and treating it as a separate category is exactly the mistake this post is arguing against. Re-checking is detection, run again, on a schedule, against the same site. It belongs in the top row of the original table, not in a footnote, because a check that only ever runs once is not a check an agency can stand behind for more than the month it was run in.

    The eight-of-fifty-five automatable-fix number would not move even if every one of these re-checks were fully automated tomorrow, scheduled, scripted, running silently in the background across the whole backlog every week. What moved during the second wave, every single time, was the judgement layered on top of a fresh reading: is this drift worth a correction, is this still-open finding worth escalating again, is this newly-discovered measurement bug worth its own paragraph or a quiet fix. That layer is where thirty days of work actually went, and it is the same layer this post already named as the 47 checks that do not automate, applied a second time to the same project rather than assumed to be a one-off cost paid only during the original research.

    The practical takeaway for anyone running a similar backlog: budget the re-check as real, recurring work from the start, not as a cleanup pass done once when something looks stale. A finding published once and never verified again is a finding that quietly stops being true at some unknown point after publication, and the only way to know when is to have looked.

  • Building Protuno: Five Things We Changed Our Minds About

    Building Protuno: Five Things We Changed Our Minds About

    Short answer: Protuno is six agents, a catalogue of 27 playbooks (17 that only read a site and 10 that can change it), and 85 checks for running WordPress sites at agency scale. It has been live since 27 October 2026, and the AI runs inside your own AI app. The rest of this post is the September account of where we were, kept as written, including the things we got wrong badly enough to rebuild.

    It is also the last of 60 posts on this blog, every one of which came out of actually running one of those checks.

    What Protuno is

    Table of six agents. Wren covers care with 26 playbooks for updates, backups, cron, certificates and domains. Rook covers security with 17. Dash covers performance with 16. Iris covers SEO with 20. Till covers eCommerce with 17. Echo covers marketing with 15
    The six agents as they stood in September, with the playbook counts of that month. Today the catalogue is 27 playbooks, and the agents are live.
    AgentAreaPlaybooks (September)What it covers
    WrenCare26updates, backups, cron, certificates, domains
    RookSecurity17exposure, headers, enumeration, hardening
    DashPerformance16TTFB, caching, compression, images
    IrisSEO20crawl, sitemaps, orphans, structured data
    TilleCommerce17checkout, cart, gateways, orders
    EchoMarketing15mail posture, tracking, reporting

    That last sentence has appeared at the bottom of all 60 posts here. It will keep appearing until it stops being true, and I would like it to be the thing this blog is remembered for rather than any individual finding.

    The five reversals

    Table of five things we changed our minds about. A site audit tool became Super Agents for ongoing operations. Two permission levels became three. Our own WordPress bridge became the Soflyy WP Agent Connector. Automate the fix became automate the measurement first. Report everything found became report what changed
    Each row cost real work to reverse, which is the only reason any of them are worth reading.
    What we thoughtWhere we landedWhat moved us
    a site audit toolSuper Agents for ongoing operationsauditing is a one-off, agencies have the same problem every month
    permission per agentpermission per site: read only or executethe site is the blast radius, not the agent
    our own WordPress bridgethe Soflyy WP Agent Connectorsomebody else maintains the hard part, already in the ecosystem
    automate the fixautomate the measurement firstthree of my own checks returned confident wrong answers in a month
    report everything foundreport what changedsix sites produced 30 findings and 7 worth reading

    Audit tool to operations. An audit is a document. It is true on the day you run it and decorative a fortnight later. Agencies do not have an audit problem, they have the same problem every month on forty sites. Building for the one-off would have been much easier and much less useful.

    Two permissions to three. Read-only and execute sounds clean and is unusable. Everything worthwhile gets pushed into execute, which nobody enables, or read-only, which nobody acts on. The middle tier, where the agent proposes and waits, is where the actual work lives, and reading a real ability list off a live site is what convinced me.

    Our own bridge to somebody else’s. We were going to build the WordPress connection ourselves. We use the Soflyy WP Agent Connector instead, over the core Abilities API. It is maintained, it is in the ecosystem, and the hard part is not ours to solve twice.

    Fix first to measure first. This is the newest and the most expensive. Three times in a month, a check of mine produced a confident wrong answer, and all three are written up. Any check that triggers an unattended change now needs a second, independent measurement to agree with it. That reordered the build.

    Report everything to report what changed. Six of our sites produced 30 findings and 7 worth telling anyone about. A report that lists all 30 every month teaches people to stop reading it.

    Four of those five reversals share a shape, which I only noticed writing them out. Each one started as a decision that made the product easier to build and ended as a decision that made it easier to trust. Two permission tiers, our own bridge, fixing before measuring, reporting everything: all four were the convenient answer. None of them survived contact with a real site.

    Where we actually are

    Table of Protuno's state. Agents defined and named, done. Check library of 85 checks across 8 areas, done. Connector route settled on WP Agent Connector with application password auth, done. Permission model of three tiers per check and per site, done. Running against live client sites, not yet. Public availability, not yet with no date announced
    Written so anyone reading this in six months can check whether it was honest.
    PieceStateDetail
    agents defined and nameddonesix, with their playbook sets
    check librarydone85 checks across 8 areas
    connector route settleddoneWP Agent Connector, application password auth
    permission modeldonethree tiers, per check and per site
    running against live client sitesnot yetthis is the gap, it is the whole gap
    public availabilitynot yetno date announced, and I am not going to invent one

    Update, 28 October 2026: both not-yet rows above have changed. Protuno has been live on protuno.com since 27 October, and the AI runs in the customer’s own AI app, on their plan there. In September, two rows said not yet, and one of them was the only row that matters. Everything above it is preparation, and preparation is the part that is easy to mistake for progress when you are the one doing it.

    I am not going to announce a date. I have watched enough software get announced into existence and then arrive as something else, and the cost of that is paid by people who made plans around it. When there is something to use, this blog will say so, plainly, in a post that says what it does and what it does not. That post is Introducing Protuno: Super Agents For WordPress.

    What the blog itself produced

    Table of results from thirty days. 60 posts, one per operational check, each written after running the check. 201 graphics from measured data with no stock imagery. 6 of our own sites audited and published, 30 findings including 10 exposed usernames on two of them. 3 false findings caught before publishing. Over 20 drafts blocked by our own gate
    The blog was research, not marketing.
    Thirty days of writing this blogDetail
    60 posts, one per operational checkeach written after running the check, not before
    201 graphics from measured datano stock imagery, every table is a dataset
    6 of our own sites audited and published30 findings, including 10 exposed usernames on two
    3 false findings caught before publishingeach by a second, independent measurement
    20+ drafts blocked by our own gatefor thin evidence, uncited numbers, or banned phrasing

    I want to be clear about the direction of causation, because it is the opposite of how this usually goes. We did not build the product and then write posts about it. The posts are where the checks got tested, and several of them changed the product. When a check was wrong, the post is where it was wrong first, in public, with the correction attached.

    The eCommerce work is the clearest case. Probing a checkout without placing an order crashed on the exact failure it existed to detect, which is a bug I would rather find in a blog post than in a client’s store at midnight.

    And auditing ourselves in public was uncomfortable in a useful way. Two of our sites hand out ten usernames to anyone who asks. Two DMARC policies enforce on 5% of mail. One domain sits at p=reject with no DKIM I could find. All of that is published rather than quietly fixed first, because the alternative was writing operational advice from invented examples.

    The second wave, and what re-checking sixty posts actually found

    This post originally closed out sixty posts, each written once. Since then, nearly all of them have been re-checked, every measurement re-run rather than trusted, every table re-verified against the live source rather than reused. That second pass is worth its own honest accounting, because it tested this blog’s own central claim, that a finding worth publishing is one you verified rather than assumed, against the blog itself.

    Two posts needed real corrections, not expansions. This post’s own PHP end-of-life figure had undercounted the unsupported share by counting only the three largest dead branches instead of every branch with zero support: the true figure was 38.36%, not 32.8%, and the combined at-risk share was 63.15%, not 57.6%. A separate post’s quoted Google guidance no longer matched the source page, which had been rewritten since the original citation. Both are now corrected in place, with the correction shown rather than the original error quietly removed.

    Several posts turned up genuinely new findings during the re-check that the original research missed entirely: a commonly-repeated Apache header rule that does not actually work, confirmed by testing it directly rather than trusting the syntax; a 610 megabyte, actively growing, publicly exposed debug log on one of our own live sites, found while re-verifying a post about exactly that risk; a crawl tool’s own orphan-page report proven wrong a second time, on a fresh crawl, independent of the first catch; a WooCommerce monitoring assertion that passed cleanly on a classic checkout and failed silently on a block-based one, discovered only because the same test was rebuilt on a different install rather than assumed to generalise.

    And several findings simply held, exactly as first published: two of our own domains still enforce DMARC at pct=5, unchanged since the day that was first flagged. Two still run DKIM keys at 1024 bits where current guidance recommends 2048. One domain still exposes ten real usernames including a login literally named admin. None of that is a failure of the original research. It is confirmation that publishing a finding does not fix it, and that the honest version of this blog includes going back and saying so plainly when nothing has changed.

    What happens next

    More of the same, honestly. The check library grows, the unattended tier stays deliberately small, and the connector route stays as it is.

    The specific next piece of work is the second-measurement requirement, because it is the one that changes existing checks rather than adding new ones. Every check that could trigger an unattended change needs an independent way to confirm what it found, and for some of them I do not yet know what that second source is. That is a real constraint on how fast the unattended tier can grow, and I would rather it grew slowly than confidently.

    If you run WordPress sites for other people and something here was useful, the most valuable thing you can do is run one check across your whole portfolio and look at the shape of the answer. Ten hits means a standard you never set. One hit means a site to go and fix.

    That habit is worth more than any tool, including ours, and it is available to you today. Nothing in these 60 posts needed software we have not shipped. It needed a browser, a terminal, and the decision to look.

    If you want a starting point, the security and mail posts are where the findings were densest on real sites, including our own. Start there, and expect the first pass to be uncomfortable.

    Straight with you as on every post here, one last time, as written in September: the agents were built and named, and not live yet. As of 27 October 2026, they are live.

    The second wave changed that sentence not at all, and it changed almost everything around it. Sixty posts became sixty posts checked twice, two real corrections, a handful of genuinely new findings, and a longer list of things confirmed still exactly as broken as the day they were first found. That is what re-checking a body of work actually looks like when it is done honestly: not a victory lap, and not a quiet rewrite. A record of what moved, what did not, and which of those two categories you are still responsible for.

  • WordPress Security Headers: Two You Can Set Today, One That Needs An Afternoon

    WordPress Security Headers: Two You Can Set Today, One That Needs An Afternoon

    Short answer: two wordpress security headers carry real benefit and cannot break anything, so set them now on every site: X-Content-Type-Options: nosniff and a Referrer-Policy. The rest range from mild risk to genuinely disruptive, and a Content Security Policy deserves a proper rollout rather than a quick paste.

    I read the headers on four real sites in this industry rather than working from a checklist, and later went back to read them all again to see what, if anything, had changed since.

    What real sites actually send

    Matrix of six security headers across sitea.example, woocommerce.com, siteb.example and wordpress.org. sitea.example sends all six. woocommerce.com sends four plus a minimal CSP. siteb.example sends three. wordpress.org sends X-Frame-Options and a one hour HSTS only
    Requested with a normal browser user agent on 7 September 2026. Dashes are headers not

    sent at all. Two of these four sites send no nosniff, which is the one header on the list with no downside whatsoever.*

    Headersitea.examplewoocommerce.comsiteb.examplewordpress.org
    strict-transport-security1 year1 year, +subdomains1 year1 hour
    x-content-type-optionsnosniffnosniffnosniffnot sent
    x-frame-optionsSAMEORIGINSAMEORIGINSAMEORIGINSAMEORIGIN
    referrer-policystrict-origin-wcostrict-origin-wconot sentnot sent
    content-security-policyreal policyupgrade-insecure onlynot sentnot sent
    permissions-policycamera/mic/geo offnot sentnot sentnot sent

    Caveat: four sites, ours and three reference points, read once. This is not a claim about WordPress sites generally. It is enough to show that even well-run, high-traffic sites vary widely on this, which is the actual point: check yours rather than assume the industry has it.

    Two details in there are worth pulling out.

    wordpress.org sends HSTS with max-age=3600. That is one hour. The value needed for preload eligibility is a year, and an hour is short enough that a visitor who has not returned within it gets no protection at all on their next first request.

    woocommerce.com sends a CSP containing only upgrade-insecure-requests. That is a useful directive, but it is not a content security policy in any meaningful sense. It rewrites HTTP subresource requests to HTTPS. It does not restrict what may execute.

    Neither of those is negligence. They are the ordinary, unremarkable state of security headers on real, actively maintained sites, which is exactly why reading them yourself is more useful than assuming.

    Which WordPress security headers earn their risk

    Table of six headers ranked by risk of breakage against what each prevents. nosniff and referrer-policy carry no risk. X-Frame-Options and permissions-policy carry low risk. HSTS is medium and hard to undo. CSP is high risk and the most valuable
    Ordered by value against the chance of breaking something. The top two cannot break a

    working site. The bottom one is the most valuable and by far the most likely to take a WordPress site down on first attempt.*

    HeaderBreakage riskWhat it stops
    x-content-type-options: nosniffnonea browser guessing an uploaded .txt is JavaScript and running it
    referrer-policynoneleaking full URLs, including tokens, to every site you link to
    x-frame-options / frame-ancestorslowclickjacking, check legitimate embeds first
    permissions-policylowa compromised script requesting camera, mic or location
    strict-transport-securitymediumdowngrade attacks; hard to undo once browsers remember it
    content-security-policyhighinjected scripts executing, the most valuable and riskiest

    The HSTS row deserves a warning. Browsers remember max-age, so if you set a year and then discover a subdomain that cannot do HTTPS, you cannot simply remove the header. Every browser that saw it will keep enforcing until the clock runs out. Start at a low max-age, confirm nothing broke, then raise it.

    includeSubdomains, as woocommerce.com already sends, is stronger and correspondingly less forgiving. Every single subdomain must serve HTTPS correctly, including ones you may have long forgotten about.

    Re-checking our own headers found a duplicate worth explaining

    I re-ran this post’s own header check against sitea.example before expanding it, and found something the original table did not report: three of our own headers are sent twice, in the same response, with different values on one of them.

    curl -sD - -o /dev/null https://sitea.example/ 
      | grep -iE 'strict-transport|x-content-type|x-frame'
    x-content-type-options: nosniff
    x-content-type-options: nosniff
    x-frame-options: SAMEORIGIN
    x-frame-options: SAMEORIGIN
    strict-transport-security: max-age=63072000; includeSubDomains; preload
    strict-transport-security: max-age=31536000

    x-content-type-options and x-frame-options repeat with identical values, which is harmless, redundant rather than contradictory. The strict-transport-security pair does not match: one copy declares a two-year lifetime with includeSubDomains and preload, the other a one-year lifetime with neither. Two layers of this site’s own infrastructure, almost certainly Cloudflare adding its own edge-level header on top of whatever the origin application already sends, are each independently deciding this site’s HSTS policy without apparent awareness of the other.

    This matters beyond tidiness because of exactly the irreversibility this post already warns about. max-age is a promise a browser remembers, and which of two conflicting values a given browser actually honours is not something this site’s operators currently control or have verified, since RFC 6797 specifies that a user agent should process only the first Strict-Transport-Security header it receives and ignore the rest, but header ordering across CDN and origin layers is not something most teams treat as a stable, deliberately-controlled property. Relying on which header happens to arrive first, decided by infrastructure layering rather than a deliberate choice, is the wrong way to control a setting this post already treats as hard to walk back once a browser has cached it.

    The practical fix is not raising or lowering either number. It is finding the second source and removing it, so exactly one layer controls HSTS for this domain and the response carries one clear answer rather than two. Checking for this specific pattern, the same header appearing more than once with different values, is worth adding to the verification step this post already recommends: grep -c the header name in the response and treat anything above 1 as its own finding, independent of whether either individual value looks reasonable in isolation.

    Setting the two safe ones

    Configuration table showing the nosniff and referrer-policy headers set in Apache with Header always set, in nginx with add_header always, and in WordPress with a send_headers action, noted as a last resort
    Prefer the server over WordPress. A header set in PHP is only sent on requests that

    reach PHP, so a cached page or a static file served directly by the web server will not carry it. That gap is exactly where an uploaded file lives.*

    That caption is the part people get wrong, and it matters more than the header choice. If your site is behind a page cache, headers added by a WordPress plugin may be absent on precisely the requests that matter. Set them at the web server or the CDN, then verify on a cached response.

    The homepage-versus-upload gap, demonstrated on a real site

    This post’s own caption warns that a header applied only in PHP will be missing on requests that never reach PHP, an uploaded file being the clearest example. I checked whether that gap actually shows up on a real site rather than only naming it as a theoretical risk.

    curl -sI https://woocommerce.com/ | grep -iE 'x-content-type|x-frame|content-security'
    curl -sI https://woocommerce.com/wp-content/uploads/2025/01/[email protected] 
      | grep -iE 'x-content-type|x-frame|content-security'

    The homepage request returned x-frame-options, x-content-type-options, a referrer-policy and a minimal CSP. The uploaded JPEG returned nothing. Not a weaker version of the same protection, none of it: no nosniff, no frame protection, no CSP, on a static file served directly rather than through whatever application layer adds those headers to HTML responses.

    That is the exact gap this post warns about, found on one of the four reference sites this post already treats as well-run. It is not necessarily a serious problem for this specific file, a JPEG has limited ways to be weaponised even without nosniff, but it demonstrates precisely why checking only the homepage, the request every quick audit defaults to, would have reported this site’s header posture as stronger than it actually is for the class of request nosniff exists to protect: a file matching an unexpected type. siteb.example, checked the same way, carried both nosniff and x-frame-options through to its own uploaded image correctly, proof the gap is not universal and specifically worth checking rather than assuming either way.

    Verify, always

    curl -sI https://example.com/ | grep -iE 
      'strict-transport|content-security|x-content-type|x-frame|referrer-policy|permissions-policy'

    Run it against the homepage, then against an uploaded file such as /wp-content/uploads/2026/01/something.jpg. Those two responses often differ in practice, not only in theory, and the second one is where nosniff actually earns its place.

    A header you added once and never checked again is not a header, not really. It is an intention, which is the same category error as a caching plugin that is active and caching nothing or a backup that has never been restored.

    Protuno’s free audit reads all six of these from the domain alone, which is how the table above was built, so you can see the whole portfolio’s posture in one place. Rook is live now.

    While you are reading headers, the same request tells you whether the server is announcing its PHP version to every visitor, which is a one-line fix in the same config file.

    What changed on the other three sites since the original check

    Worth reporting the full re-check rather than only the two findings above, since two of the three comparison sites had moved since this post first published. woocommerce.com now sends referrer-policy: strict-origin-when-cross-origin on its homepage, a header the original table recorded as present already, holding steady. siteb.example now sends x-frame-options and x-content-type-options on its homepage, both absent from the original table, which recorded only three headers there against four now. wordpress.org is the one holdout: still exactly x-frame-options and a one-hour strict-transport-security, unchanged in every respect from the original reading, including the one-hour HSTS lifetime this post already flags as too short to protect a returning visitor’s next first request.

    That mix, one site improved, one held steady, one unchanged, is worth reading as evidence against treating any of this as settled once checked. A header posture is not a fact about a site, it is a fact about a specific request on a specific day, and three real, actively maintained sites produced three different trajectories over the same interval with no obvious pattern predicting which would move and which would not. The only way to know which category a given client site falls into is running the check again, not assuming the last result still holds because the site looks the same from the outside.

    Set nosniff and a referrer policy on every site you look after this week. It is two lines, it cannot break anything, and on the four sites checked for this post, two were missing nosniff entirely. Whether that ratio holds for your own portfolio, and whether it has moved at all since the last time anyone looked, is exactly what re-running the loop above, on a schedule rather than once, will actually tell you.

  • DKIM Record Check: What You Can Prove From Outside, And What You Cannot

    DKIM Record Check: What You Can Prove From Outside, And What You Cannot

    Short answer: a dkim record check from outside means guessing selector names and asking DNS about each one. DNS cannot list what exists under _domainkey, so you can confirm a selector is present and you can never prove none exists. That limitation is worth understanding before you report a finding to a client.

    I ran it across four domains we own, and the most useful result was our own.

    What came back

    Table of DKIM probe results for four domains. sitea.example has default, mail, s1 and s2 selectors. siteb.example has google. sitec.example has default. sited.example returned no selector on any of 24 common names despite MX on Google Workspace and DMARC p=reject
    24 common selector names tried against each domain. The last row is ours, and it is a

    real gap rather than a probing artefact worth shrugging at.*

    DomainSelectors foundWhat it tells you
    sitea.exampledefault, mail, s1, s2s1/s2 are CNAMEs delegated to SendGrid
    siteb.examplegoogleGoogle Workspace DKIM enabled and publishing
    sitec.exampledefaultone key published
    sited.examplenone foundMX on Google Workspace, DMARC p=reject, no key on any of 24 names

    Caveat: 24 selector names, four domains. Finding nothing on sited.example is not proof no key exists anywhere, only that none of the 24 most common provider selectors answered. That limitation is the entire subject of the next section, and it is why this finding needs the DMARC report confirmation described below before you’d stake anything on it.

    sited.example enforces DMARC at p=reject. Its mail runs through Google Workspace. And no key answered on any of the twenty-four names I tried, including google, which is the selector Workspace uses.

    With no DKIM, DMARC has only SPF to align against. SPF breaks whenever a message is forwarded, because the forwarding server is not in the sending domain’s SPF record. So p=reject with SPF-only alignment is the configuration most likely to destroy legitimate forwarded mail.

    That one is going on our list rather than into a footnote.

    Checked again, with a wider list, and it is still there

    I re-ran the full probe against all four domains before expanding this post, rather than let the original screenshot stand as the last word on something worth staying honest about.

    for s in google selector1 selector2 default mail dkim k1 k2 s1 s2 zmail pm mandrill smtp pic zoho; do
      dig +short TXT "$s._domainkey.sited.example"
      dig +short CNAME "$s._domainkey.sited.example"
    done

    sitea.example, siteb.example and sitec.example matched their original results exactly, no drift, same selectors answering the same way. sited.example matched too, in the direction that matters: still nothing, across the same sixteen names that returned nothing the first time.

    Given how load-bearing “no DKIM on a p=reject domain” is as a claim, I extended the probe past the original list before accepting the result, adding names this post had not tried: amazonses, mimecast, everlytickey1 and everlytickey2 for SparkPost, scph200524 for Sendinblue, fm1 through fm3, mailchannels, resend, and a handful of dated and numbered variants providers sometimes use. Thirty-seven selector names in total, across both TXT and CNAME record types.

    Nothing. The finding holds under a substantially wider probe than the one that originally produced it, which is the closest an external check can come to confirming a negative result without server access, still short of proof, but considerably more load-bearing than the first pass alone.

    _dmarc.sited.example still reads v=DMARC1; p=reject; rua=mailto:[email protected], and MX still points at smtp.google.com. Every condition that made this a real, current risk when the post first published is unchanged. This is one of the small number of findings on this blog where re-checking did not turn up a fix quietly applied since. It turned up the same gap, confirmed twice, on infrastructure I have direct reason to care about.

    Why absence is not proof

    Table explaining what DNS can and cannot answer about DKIM. Listing everything under _domainkey is impossible. Checking a specific selector is possible. Reading the s tag from a message header is possible. Determining whether all senders sign is only possible indirectly through DMARC reports
    DNS answers questions about names you already suspect. There is no directory listing,

    which is the practical ceiling on any external mail audit, ours included.*

    What you want to askPossible?Why
    “list everything under _domainkey“noDNS answers questions about specific names only
    “does selector X exist?”yesdig TXT X._domainkey.domain
    “which selector signed this message?”yesread the s= tag in the DKIM-Signature header
    “are all senders signing?”indirectlyonly from DMARC aggregate reports

    This matters when you write it up. “No DKIM record found” and “this domain has no DKIM” are different claims, and only the first one is supportable from a probe.

    The definitive answers come from two places, and both need a real message to exist:

    • A message header. Send yourself mail from the domain and read the s= tag in the

    DKIM-Signature header. That names the selector that actually signed it.

    • DMARC aggregate reports. These list every sending source and whether it aligned on SPF, on

    DKIM, or on neither. This is the only view that covers senders you forgot about, which is why rua is the tag you should never omit.

    Checking key strength, not just key presence

    Finding a selector answers “is something published.” It does not answer “is what’s published still good enough,” and that second question is worth asking once you have found a key rather than stopping at presence. I decoded the actual RSA public key from each of the four selectors this post found and read its length.

    dig +short TXT default._domainkey.sitea.example 
      | grep -oP '(?<=p=)[^;"]+' | base64 -d 
      | openssl rsa -pubin -inform DER -noout -text | head -1
    Domain, selectorKey size
    sitea.example, default1024 bit
    sitea.example, mail1024 bit
    sitec.example, default1024 bit
    siteb.example, google2048 bit

    Three of our four active keys are 1024-bit RSA. The fourth, siteb.example‘s, is 2048-bit, because Google Workspace generates 2048-bit keys by default when a domain enables DKIM through its admin console rather than through an older, third-party mail provider’s own defaults.

    1024-bit RSA is not broken in any practical sense today, and a working 1024-bit key still signs mail that authenticates correctly right now. It is, however, below the 2048-bit floor most current DKIM guidance recommends, and it is the kind of gap that does not announce itself: DMARC alignment succeeds either way, mail delivers either way, and nothing about day-to-day operation distinguishes a 1024-bit key from a 2048-bit one until a receiving provider decides to stop trusting the shorter one, at which point every message signed with it starts failing alignment simultaneously, with the same “worked yesterday, mysteriously fails today” shape as a certificate renewal that silently stopped.

    The practical addition to this post’s audit, once a selector is found: decode the key and check its length alongside checking that p= is non-empty. A key that exists and signs correctly today can still be quietly due for rotation, and the three 1024-bit keys found across our own small portfolio here are now on that list, not because anything is currently failing, but because the newest and best-configured provider in the same portfolio, Google Workspace, has already moved past that length by default.

    Running a DKIM record check, selector by selector

    Table of DKIM selector names by provider: google for Workspace, selector1 and selector2 for Microsoft 365, s1 and s2 for SendGrid, k1 k2 and mandrill for Mailchimp, mailo smtp and pic for Mailgun, pm and dated selectors for Postmark, zmail and zoho for Zoho, and default mail and dkim for generic MTAs
    Note how many providers publish selectors as CNAMEs rather than TXT records. That is

    deliberate, so they can rotate keys without asking you to edit DNS. Probe both record types or you will miss half of them.*

    The loop itself:

    D=example.com
    for s in google selector1 selector2 default mail dkim k1 k2 s1 s2 zmail pm; do
      t=$(dig +short TXT "$s._domainkey.$D" | head -1)
      c=$(dig +short CNAME "$s._domainkey.$D" | head -1)
      [ -n "$t" ] && echo "  TXT   $s"
      [ -n "$c" ] && echo "  CNAME $s -> $c"
    done

    One thing to check when you do find a key: the p= value should not be empty. A record like v=DKIM1; p= is a revoked key. It is published, it looks like a configuration, and it signs nothing.

    Where this sits in a mail audit

    DKIM is the half of alignment that survives forwarding, which is why it is worth the extra hour even on domains where SPF is already correct. Client mail gets forwarded constantly, to personal addresses, aliases and shared inboxes, and SPF fails every one of those hops.

    The three checks belong together and take about five minutes for a whole portfolio: the SPF record and whether it has drifted past its lookup limit, the DMARC policy and its pct, and a selector probe.

    Protuno’s free audit runs a DKIM selector probe alongside the SPF and DMARC checks, from the domain alone, with the same honest caveat as above about what a probe can and cannot establish. Echo is live now.

    It is also worth confirming the site itself can send at all before blaming alignment. A perfectly signed domain still delivers nothing if the form quietly throws the message away.

    Run the loop against your own domain first. That is where I found the one that mattered.

    What fixing the sited.example gap will actually require

    I am not going to claim this is fixed, because it is not, and the honest version of this post includes what the fix actually looks like rather than only the finding. Enabling DKIM on a Google Workspace domain is a deliberate step inside the admin console, not something that turns on by default alongside MX records pointing at Google’s mail servers. An administrator has to open Apps, then Google Workspace, then Gmail, then Authenticate Email, generate a new DKIM key specifically, and then publish the TXT record it produces at the google._domainkey name this post already probed.

    That last part is the step worth naming precisely, because it is where a check like this one earns its cost back: Workspace will not activate the key until it can see the DNS record it generated actually published and resolving, so the whole process is self-verifying in a way the WooCommerce REST API’s own defaults are but many DNS-dependent configurations are not. The record either resolves and the key activates, or it does not and Workspace tells you plainly rather than silently accepting a half-finished setup.

    None of that is difficult. It is fifteen minutes in an admin console followed by one DNS record, which is a smaller job than most of the fixes described elsewhere on this blog. What makes it worth writing about at this length is not the difficulty of the fix, it is that a domain enforcing p=reject, the strictest DMARC policy that exists, has been running without the one authentication mechanism that survives the most common way legitimate mail actually breaks, forwarding, for as long as this post has been live to say so. The gap between “this is trivial to fix” and “this has not been fixed” is not a technical gap. It is the same fifteen minutes every other unbilled, invisible check on this blog competes against on a Tuesday, and losing.

    Why 2048-bit landed as the default without anyone deciding it should

    Worth being precise about one more thing before closing this out: none of the four domains in this post’s table have a documented internal decision that says “our DKIM keys should be 1024-bit” or “our DKIM keys should be 2048-bit.” The key length each domain ended up with is purely a function of which provider generated it and when. Google Workspace’s current key generation defaults to 2048-bit. Whatever generated the three 1024-bit keys, most likely an older SendGrid or generic SMTP provider default from whenever those records were first set up, defaulted to the shorter length at the time, and nobody has gone back to regenerate them since, because a working key gives no visible reason to.

    That is worth naming as its own small pattern, distinct from every other finding in this post: infrastructure inherits its defaults from whichever tool touched it last, not from a decision anyone actually made, and a portfolio built up over years accumulates a genuinely inconsistent set of standards purely as a side effect of which provider happened to be in use each time a piece of it was configured. Auditing for that kind of drift, not just for outright absence, is a different and quieter category of check than most of what this blog covers, and it is worth adding to the same quarterly pass as the selector probe itself.

  • Domain Expiration Monitoring: The Outage Nobody Plans For

    Domain Expiration Monitoring: The Outage Nobody Plans For

    Short answer: domain expiration monitoring means reading the registry expiry date for every domain you look after, on a schedule, and holding that list somewhere that is not the registrar. Auto-renew and renewal email are not monitoring. Both fail silently, and the failure takes the website and the mail down together.

    Reading the authoritative date

    Table of registry expiry dates for three domains we own. sitea.example expires 2029-06-25 with about two years nine months of runway. siteb.example expires 2028-07-24 and sitec.example 2028-07-20, both with about one year ten months
    Read from the registry with whois on 7 September 2026. Healthy, which is the point of

    checking rather than assuming.*

    whois example.com | grep -iE "Registry Expiry Date|Expiration Date" | head -1

    Always use the registry date. Not the registrar dashboard, which can lag behind a completed renewal, and not the renewal email, which may be about a different domain in the same account. When they disagree, the registry is correct.

    Extending the check to the rest of the portfolio, and to the locks

    The original table covers three domains. I re-ran the same whois command against six while expanding this post, adding the three not previously checked, and read one more field on all of them that the original check did not capture.

    whois example.com | grep -iE "Registry Expiry Date|Registrar:|clientDeleteProhibited|clientTransferProhibited"
    DomainRegistry expiryRegistrarTransfer/delete locked
    sitea.example2029-06-25GoDaddyyes
    siteb.example2028-07-24GoDaddyyes
    sitec.example2028-07-20GoDaddyyes
    sited.example2028-05-18GoDaddyyes
    sitee.example2028-05-19GoDaddyyes
    sitef.example2028-05-31GoDaddyyes

    All six healthy, all with at least a year and a half of runway, and every one carries both clientTransferProhibited and clientDeleteProhibited at the registry. Those two EPP status codes are worth checking alongside expiry, not only during a renewal audit but as a standing part of the same pass, because they answer a related but different question: not “will this domain lapse” but “can this domain be moved or removed without our knowledge even while it remains valid.” A domain a quarter from expiring but missing a transfer lock is exposed to a different attack entirely, a social-engineered or credential-based unauthorized transfer away from the account that controls it, which no renewal reminder would ever catch because the domain never actually lapses in that scenario.

    Six domains, one registrar, consistent locks across every one, is itself worth having confirmed rather than assumed. A portfolio this size concentrated on a single registrar means one compromised registrar account is a single point of failure for every domain in it, the same trade-off this blog’s own post on nameserver provider concentration already names for DNS specifically, applying here one layer up the stack.

    Why the renewal email does not save anybody

    Table of five ways registrar renewal notices fail: the registrant address belongs to a former employee, the registrant address is on the domain itself, the card on file has expired while auto-renew appears enabled, notices are filtered as bulk mail, and the client owns the domain while the agency owns the site
    The second row is the one that ends agencies. The warning for a lapsing domain goes to

    an address on that domain, so once it stops resolving, the only notification you would get can no longer be delivered.*

    FailureWhy the email doesn’t save anybody
    registrant address is a former employeethe mailbox was closed when they left
    registrant address is on the domain itselfonce it lapses, the mail stops, including the warning
    card on file expiredauto-renew fails silently, everything looks configured
    notices land in spamregistrar mail is bulk mail, filtered like bulk mail
    client owns the domain, you own the sitethe warning goes to somebody who does not know what it means

    The auto-renew case is worth dwelling on because it feels safe. Auto-renew is enabled, so the box is ticked, so nobody looks again. The card on file expired eleven months ago. The renewal attempt fails, the notice goes to a mailbox nobody reads, and the domain lapses with every setting looking correct.

    This is the same failure shape as a backup that has never been restored: a mechanism everyone believes in that has never once been exercised.

    What a lapse actually costs

    Not just downtime. It takes the mail with it, because the MX records live in the same zone. So the client cannot receive the notification that their domain expired, cannot receive password resets to fix it, and in many cases cannot prove ownership from an address on that domain.

    Then the recovery clock starts. Most gTLDs go through a grace period, then a redemption period with a substantial fee, then release. The recovery gets more expensive and less certain at each step, and the whole time the site is off the internet.

    Table of the domain lapse lifecycle showing grace period, redemption period with a fee often ten times the renewal, pending delete, and release to the public, with site and mail offline throughout
    Windows vary by registry, so confirm them for the TLDs you actually hold. The pattern

    does not vary: it gets more expensive and less certain at every stage, and the site is off the internet for all of it.*

    DayStageSite and mailRecovery
    0expiry passesmay still resolve brieflyrenew normally
    1 to 30grace periodusually parked or offlinerenew at normal price
    ~30 to 60redemption periodofflinefee, often 10x the renewal
    ~60 to 65pending deleteofflinenothing you can do
    ~65+released to the publicgoneanybody may register it

    Caveat: windows vary by registry and TLD. The stages and their ordering are general; the exact day counts are the ones to confirm for whichever TLDs you actually hold before quoting them to a client.

    The worst version is a domain that lapses on a Friday for a client who does not check their personal email at weekends.

    Setting up domain expiration monitoring properly

    Four things, none of which take long.

    Hold the list outside the registrar. A spreadsheet with domain, registry expiry, registrar, and who pays. The registrar is the system that is failing in every scenario above, so it cannot be the only place the information lives.

    Check the registry, on a schedule. The loop is one line per domain and reads the authoritative date rather than a cached one.

    Watch at 90, 60, 30 and 7 days. Ninety days is when a card can be replaced calmly. Seven days is when somebody needs to be phoned.

    Fix the registrant address. If it is on the domain itself, or belongs to somebody who left, that is the highest-value change on this page and it takes ten minutes per domain.

    While you are in the zone, the same pass is a good moment to confirm the nameservers still point where you expect and that nothing is pointing at a service that no longer exists.

    Why WHOIS itself cannot answer the registrant-address question anymore

    The failure table above names a stale or on-domain registrant address as one of the ways a renewal warning never arrives. I checked whether that specific detail is even visible from outside, the same way expiry and the transfer locks are.

    whois sitea.example | grep -iE "Registrant Email|Registrant Name|Admin Email"

    Nothing came back. Every one of the six domains checked for this post returns no registrant contact information at all through public WHOIS, standard practice since GDPR-driven privacy redaction became the registrar default rather than an opt-in. That is good for the domain owner’s personal privacy and it closes off the one external check that would otherwise confirm or rule out the exact failure mode this post spends the most time on: whether the address receiving renewal notices is still a working mailbox somebody reads.

    That gap is not a flaw in this post’s method, it is confirmation of something this post already argues from a different angle: the registrant-address question cannot be answered from outside at all, by design, on any domain registered in the last several years. It has to be answered from inside the registrar account, by someone who can actually see the contact record, which is precisely why the “who owns it” documentation this post recommends below is not a nice-to-have alongside the external expiry check. It is the only way this particular failure mode gets checked at all, because WHOIS privacy has made it permanently invisible to anyone looking from outside.

    Who actually owns it

    One organisational note, because it causes more lapses than any technical failure.

    For every client domain, somebody should be able to answer three questions immediately: which registrar, whose card, and whose email address receives the notices. If the answer to any of them is “I think the client handles it”, then nobody is monitoring that domain and everybody believes somebody is.

    Write it down per client, and keep it current as people join and leave. That single document is worth more than any alerting system you could configure without it.

    Protuno’s free audit reads the domain expiry date from the registry as part of the DNS checks, so a whole portfolio’s dates land in one place without logging into anything. Wren is live now.

    Pull the registry date for every domain you look after this week and put them in one document sorted by date. It takes an hour, and it is the cheapest insurance in this entire job.

    What a single-registrar portfolio actually needs, once expiry is handled

    Having confirmed all six of our own domains sit on one registrar, correctly locked, with healthy expiry dates, the remaining risk worth naming is the one that has nothing to do with any individual domain’s own status: access to the registrar account itself. Every protection covered so far, the transfer lock, the delete lock, the correct expiry date, is only as strong as whoever can log into that one GoDaddy account and change them.

    That reframes the practical checklist for a small portfolio like this one. Two-factor authentication on the registrar account, a short, current list of exactly who has access to it, and a plan for what happens if that access needs to be revoked quickly, matter more here than they would for a portfolio spread across a dozen registrars, because there is exactly one door and it opens onto everything. A compromised password on the account holding six years of combined domain runway is a worse single afternoon than any individual domain lapsing, because it can touch every domain in the portfolio simultaneously rather than one at a time.

    This is not a criticism of concentrating a small number of owned domains on a single registrar, which is a completely ordinary and often sensible choice for a portfolio this size. It is the other half of the trade-off this post’s earlier section names when it points at nameserver concentration: fewer moving parts to check, and a single point of failure worth protecting properly rather than assuming is safe because it has never failed yet. The registrar account’s own security posture, not the individual domain records inside it, is the actual thing worth auditing next, and it is a check this post’s own external whois loop cannot perform, because it lives entirely inside an account nobody outside the organisation can see.