Keeping WordPress Secure

I have been running WordPress in one form or another since May 2003. Back then I was running a web hosting company. Since then I have hosted it for thousands of customers, I have stood on stage in front of a few thousand people and broken in to it live through a plugin to make a point, and today it serves this very blog from a HP DL380 G9 in my garage. In my last WordPress post I covered how I host Wordpres, but in this blog post I will cover the other half, which is how do you keep WordPress secure?


And disclosure up front. I have been hacked in the past. I have dealt with

  1. Comment spam.
  2. Vulnerable plugins
  3. Brute Force Attacks to Wp-admin
  4. I have had to deal with vulnerable plugins and also brute force attacks via wp-admin. This post is not a replacement for backups but I will cover off all 4 attack vectors

WordPress powers around 43% of all websites on the internet. That popularity is a double edged sword because it also makes it the most attacked CMS on the planet. Cutting to the chase, it is very rarely WordPress core that gets you. Core is patched fast and patched well. It is the plugins. Every plugin you install is somebody else’s PHP running with full access to your database, and there is a very long tail of abandoned and slow to patch plugins out there. My site has 13 active plugins plus WooCommerce. That is 14 code bases I did not write sitting on the internet 24 hours a day.

If you tail the access log of any WordPress site that has been on the internet for more than a day you will see what I see. A constant stream of requests to /wp-login.php and /xmlrpc.php from IP addresses you have never heard of, trying usernames and passwords. It isn’t personal, it is automated and it never stops. Security & hygiene is everything to me, I said as much in the hosting post when I explained why I moved off my Synology, so in this post I am going to walk you through the three layers I use to keep this blog secure. None of it is a product, none of it costs money, and all of it runs on the same Unraid host as the blog itself.

What I will cover in this post

  • Knowing where I stand : Scanning with WPScan in Docker
  • Putting WPScan on a schedule so the box emails me when something is wrong
  • Reducing the attack surface : Locking down wp-admin to my LAN with Nginx Proxy Manager
  • Getting in when I am not at home : WireGuard and a small Chrome trick
  • Patching : Auto updating plugins and updating the stack with Docker Compose
  • Summary

Layer 1 – Knowing Where I Stand With WPScan

You cannot fix what you do not know about. The first layer is simply knowing what is exposed and what is out of date, and for WordPress the tool for this is WPScan.

WPScan is a WordPress specific security scanner. It fingerprints your WordPress version, your theme and every plugin it can detect, then cross references all of that against a maintained vulnerability database. It will also enumerate your users, which is a useful and slightly uncomfortable thing to watch working against your own site. The vulnerability database is the value here, WPScan the company maintain it and it is very good.

WPScan is a Ruby application. I do not want Ruby, a version manager and a pile of gems installed on my Unraid host just to run a scanner once a week, and if you have read my hosting post you know how I feel about keeping binaries away from my data. So it runs in Docker. The container is pulled, it runs, it is removed and the host stays clean. Same pattern I use for the AWS CLI in my backup script.

You will want an API token. Without one WPScan still runs but it cannot pull vulnerability data and the result is close to useless. Sign up at wpscan.com and grab the token from your profile. The free tier gives you 25 API requests per day, which is plenty for a weekly scan of one site. Treat this token like a password because it is one. Do not commit it to Git, do not paste it in to a forum post and do not leave it in a screenshot. If it ends up somewhere public, regenerate it from your profile and move on. You will see mine redacted throughout this post for exactly that reason.

Running WPScan By Hand

Here is the scan. One line and nothing to install.

docker run -it --rm \
  wpscanteam/wpscan \
  --url https://automation.baldacchino.net \
  --api-token [REDACTED] \
  --enumerate u,vp,vt

Docker pulls the image on the first run and caches it. The --enumerate u,vp,vt flag is the interesting part

  • u enumerates users. A username is half of a brute force attempt.
  • vp enumerates vulnerable plugins.
  • vt enumerates vulnerable themes.

There is also ap and at (all plugins, all themes) if you want a full inventory rather than just what is known to be vulnerable, but be aware it chews through more of your 25 daily API requests.

Reading The Output

This is a real scan against this blog from May this year and I am not going to dress it up. This is what came back.

_______________________________________________________________
         __          _______   _____
         \ \        / /  __ \ / ____|
          \ \  /\  / /| |__) | (___   ___  __ _ _ __ ®
           \ \/  \/ / |  ___/ \___ \ / __|/ _` | '_ \
            \  /\  /  | |     ____) | (__| (_| | | | |
             \/  \/   |_|    |_____/ \___|\__,_|_| |_|

                  WordPress Security Scanner
                         Version 4.0.0
_______________________________________________________________

[+] URL: https://automation.baldacchino.net/ [220.233.43.71]
[+] Started: Sat May 23 10:47:54 2026
[+] Command Line: wpscan --url https://automation.baldacchino.net --api-token [REDACTED] --enumerate u,vp,vt

Interesting Finding(s):

[+] Headers
 |  - server: openresty
 |  - x-served-by: automation.baldacchino.net

[+] robots.txt found: https://automation.baldacchino.net/robots.txt
 |  - /wp-admin/
 |  - /wp-admin/admin-ajax.php

[+] WordPress readme found: https://automation.baldacchino.net/readme.html

[+] The external WP-Cron seems to be enabled: https://automation.baldacchino.net/wp-cron.php
 | Confidence: 60%

[+] WordPress version 6.9.4 identified (Outdated, released on 2026-03-11).

[+] WordPress theme in use: generatepress
 | [!] The version is out of date, the latest version is 3.6.1
 | Version: 3.4.0

[+] Enumerating Vulnerable Plugins (via Passive and Aggressive Methods)

[+] woocommerce
 | [!] The version is out of date, the latest version is 10.7.0
 | [!] 8 vulnerabilities identified:
 |   - WooCommerce < 9.1.4  - Stored XSS                  (CVE-2024-39666)
 |   - WooCommerce < 9.2    - Contributor+ Stored XSS
 |   - WooCommerce < 9.4.3  - Reflected XSS               (CVE-2025-5062)
 |   - WooCommerce < 9.4.3  - Unauthenticated Order Creation
 |   - WooCommerce < 9.7.1  - Shop Manager+ Stored XSS    (CVE-2025-26762)
 |   - WooCommerce < 9.9.4  - Shop Manager+ SQLi
 |   - WooCommerce < 10.0   - Shop Manager PII Leak (Multisite)
 |   - WooCommerce < 10.0.3 - Shop Manager+ Stored XSS    (CVE-2025-49042)
 | Version: 9.1.1 (100% confidence)

[+] wp-ulike
 | [!] The version is out of date, the latest version is 5.0.3
 | [!] 9 vulnerabilities identified:
 |   - WP ULike < 4.7.1  - Admin+ Stored XSS              (CVE-2024-6094)
 |   - WP ULike < 4.7.4  - Admin+ Stored XSS              (CVE-2024-7878)
 |   - WP ULike < 4.7.5  - CSRF to Statistic Deletion     (CVE-2024-9649)
 |   - WP ULike < 4.7.5  - Admin+ Stored XSS via Widgets  (CVE-2024-7879)
 |   - WP ULike < 4.7.7  - Admin+ Stored XSS              (CVE-2025-22738)
 |   - WP ULike < 4.7.6  - Admin+ Stored XSS              (CVE-2024-12770)
 |   - WP ULike < 4.7.10 - Unauth Content Spoofing        (CVE-2025-32259)
 |   - WP ULike < 5.0.0  - Subscriber+ Arbitrary Log Del  (CVE-2026-0909)
 |   - WP ULike < 5.0.2  - Contributor+ Stored XSS        (CVE-2026-2358)
 | Version: 4.7.0 (90% confidence)

[i] 2 plugin(s) Identified.

[+] Enumerating Users
[+] baldacchino_admin
[+] Shane Baldacchino
[i] 2 user(s) Identified.

[+] Finished: Sat May 23 10:50:27 2026

That is not a clean result. Core was behind. GeneratePress, my theme, was behind. WooCommerce was sitting on 9.1.1 with eight known vulnerabilities against it and WP ULike was on 4.7.0 with nine. None of these are theoretical, every single one has a fix published, which means the only thing standing between me and them was me applying updates. I was updating by hand, I got busy, and this is what busy looks like. I will come back to this in the patching section because this scan is what pushed me to stop doing updates manually.

It also enumerated my admin username and my display name in about 3 seconds. That is user enumeration working exactly as designed and it is worth sitting with for a moment. Every bot on the internet can do the same thing, and once they have the username all that is left is the password. This is a big part of why in Layer 2 I take the login page away from the internet altogether.

The scanner does not fix anything. It tells you where to point.

Putting WPScan On A Schedule

A one off scan feels good. Then six weeks later three new CVEs land against a plugin you forgot you even had installed. I will not remember to re-run this and neither will you, so I do not rely on remembering. The DL380 runs it on a schedule and I only hear about it when there is something to hear about. I cant stress this enough, reliability of the signal matters more than the scan itself. If it emails me every week I will start ignoring it. If it only emails me when something is wrong, I read it.

This script wraps the same Docker command but asks WPScan for JSON instead of the banner output. Parsing the human readable text with grep is fragile, it breaks the day the format changes. JSON does not. I parse it with jq, count the vulnerabilities and outdated components and only send mail if there is something to send. Email goes out through Gmail’s SMTP using nothing but curl, so there is no Postfix or msmtp to install and babysit on the host.

Save this on the Unraid host as /mnt/user/appdata/wpscan/wpscan-watch.sh and edit the config block at the top.

#!/usr/bin/env bash
#
# wpscan-watch.sh
# Runs WPScan against a target in Docker, parses the JSON result, and only
# emails (via Gmail SMTP) when there is something worth waking up for:
# a vulnerability, an outdated component, or a failed scan.

set -euo pipefail

# ---------------------------------------------------------------------------
# Config - edit these
# ---------------------------------------------------------------------------
TARGET_URL="https://automation.baldacchino.net"
WPSCAN_API_TOKEN="REGENERATE_THIS_AND_PASTE_IT_HERE"   # wpscan.com profile
GMAIL_USER="you@gmail.com"
GMAIL_APP_PASSWORD="your_16_char_app_password"         # NOT your login password
ALERT_TO="you@gmail.com"
REPORT_DIR="/mnt/user/appdata/wpscan"                  # Unraid appdata share
RETENTION_DAYS=30                                      # prune old reports
# ---------------------------------------------------------------------------

mkdir -p "$REPORT_DIR"
STAMP="$(date +%Y%m%d-%H%M%S)"
REPORT="${REPORT_DIR}/wpscan-${STAMP}.json"

# --- email helper: Gmail over implicit TLS, no extra packages, just curl -----
send_alert() {
    local subject="$1"
    local body="$2"
    curl --silent --show-error --ssl-reqd \
        --url "smtps://smtp.gmail.com:465" \
        --user "${GMAIL_USER}:${GMAIL_APP_PASSWORD}" \
        --mail-from "${GMAIL_USER}" \
        --mail-rcpt "${ALERT_TO}" \
        --upload-file - <<EOF
From: WPScan Watch <${GMAIL_USER}>
To: ${ALERT_TO}
Subject: ${subject}
Content-Type: text/plain; charset=utf-8

${body}
EOF
}

# --- run the scan ------------------------------------------------------------
# No -it (cron has no TTY). --rm so we do not leave dead containers behind.
# WPScan exits non-zero when it finds vulnerabilities, so we guard with || true
# and make the alert decision off the JSON, not the exit code.
docker run --rm \
    wpscanteam/wpscan \
    --url "$TARGET_URL" \
    --api-token "$WPSCAN_API_TOKEN" \
    --enumerate u,vp,vt \
    --format json \
    --no-banner \
    --random-user-agent \
    > "$REPORT" 2>/dev/null || true

# --- sanity check: did we get valid JSON? ------------------------------------
if ! jq empty "$REPORT" >/dev/null 2>&1; then
    send_alert "[WPScan] FAILED on ${TARGET_URL}" \
"WPScan did not return valid JSON. The scan errored, the container failed, or
the target was unreachable. Have a look at ${REPORT} on the host."
    exit 1
fi

# --- count vulnerabilities across core, plugins and themes -------------------
TOTAL_VULNS=$(jq '[.. | objects | select(has("vulnerabilities")) | .vulnerabilities | length] | add // 0' "$REPORT")

# --- count out-of-date components (outdated != vulnerable, but worth knowing) -
# WPScan does not emit a simple boolean, so compare detected version to the
# latest_version it pulled from its DB. Different = behind.
OUTDATED=$(jq '
    [ (.plugins // {} | to_entries[].value),
      (.themes  // {} | to_entries[].value),
      (.main_theme // empty) ]
    | map(select(.latest_version != null
                 and .version.number != null
                 and .latest_version != .version.number))
    | length
' "$REPORT")

# --- nothing to report? go back to sleep -------------------------------------
if [ "$TOTAL_VULNS" -eq 0 ] && [ "$OUTDATED" -eq 0 ]; then
    # prune old reports and exit quietly
    find "$REPORT_DIR" -name 'wpscan-*.json' -mtime +"$RETENTION_DAYS" -delete
    exit 0
fi

# --- build a human-readable summary ------------------------------------------
SUMMARY=$(jq -r '
    [
      ( .version
        | select((.vulnerabilities // []) | length > 0)
        | "WordPress core " + (.number // "?")
          + (.vulnerabilities | map("\n  - " + .title) | join("")) ),
      ( (.plugins // {}) | to_entries[]
        | select((.value.vulnerabilities // []) | length > 0)
        | "Plugin: \(.key) " + (.value.version.number // "?")
          + (.value.vulnerabilities | map("\n  - " + .title) | join("")) ),
      ( (.main_theme // empty)
        | select((.vulnerabilities // []) | length > 0)
        | "Theme: \(.slug) " + (.version.number // "?")
          + (.vulnerabilities | map("\n  - " + .title) | join("")) ),
      ( (.themes // {}) | to_entries[]
        | select((.value.vulnerabilities // []) | length > 0)
        | "Theme: \(.key) " + (.value.version.number // "?")
          + (.value.vulnerabilities | map("\n  - " + .title) | join("")) )
    ]
    | join("\n\n")
' "$REPORT")

BODY="Target : ${TARGET_URL}
Scanned: $(date)
Report : ${REPORT}

${TOTAL_VULNS} vulnerability(ies) and ${OUTDATED} outdated component(s) found.

${SUMMARY}

Full JSON report is on the host at the path above."

send_alert "[WPScan] ${TOTAL_VULNS} vuln(s) on ${TARGET_URL}" "$BODY"

# prune old reports
find "$REPORT_DIR" -name 'wpscan-*.json' -mtime +"$RETENTION_DAYS" -delete
exit 0

A few decisions in there worth calling out

  • JSON, not text. Every decision is made off the parsed JSON and not the exit code. WPScan exits non zero when it finds a vulnerability, which would trip set -e and kill the script before it ever sent the email, so the scan itself is guarded with || true.
  • Silence is the default. No vulnerabilities and nothing out of date means the script prunes old reports and exits without a sound. I only get mail when there is a reason to get mail.
  • No MTA. Plenty of guides have you install and configure Postfix just to send one email. I do not want a mail server on my host. curl speaks SMTP over implicit TLS to Gmail on port 465 and that is the entire mail stack.
  • Reports are kept. Each run drops a timestamped JSON file in appdata so I have a history to look back on, and anything older than 30 days is cleaned up. Because it lives in appdata it also goes to S3 every night with everything else.

You need jq and curl on the host. curl is there out of the box on Unraid, jq I have via the NerdTools plugin if your build does not ship it.

The Gmail App Password

You cannot use your normal Gmail password here and you should not want to. With 2FA enabled go to your Google Account, then Security, then App passwords, and generate a 16 character password specifically for this script. Drop it in to GMAIL_APP_PASSWORD. If it is ever compromised you revoke that one app password and nothing else in your Google account is affected. It is the same approach I used for the SMTP side of my Azure cost reports.

Scheduling It With User Scripts

On Unraid I use the User Scripts plugin, which is a friendly front end over cron that lets me see the last run and its output from the WebGUI. Create a new script, point it at the file above, set the schedule to Custom and give it a cron expression. If you are not on Unraid a plain crontab entry does exactly the same job. Weekly, early Sunday morning.

chmod +x /mnt/user/appdata/wpscan/wpscan-watch.sh

# User Scripts > Custom schedule, or crontab -e
0 6 * * 0 /mnt/user/appdata/wpscan/wpscan-watch.sh

That is it for Layer 1. Every Sunday at 6am the DL380 scans this blog and the only time I hear about it is when there is something to patch. Now to the part that actually stops the bots.

Layer 2 – Locking Down wp-admin With Nginx Proxy Manager

WPScan found my username in a few seconds and every bot on the internet can do the same. The password is the only thing left and whilst mine is long, random and sits behind 2FA, I would much rather the login page simply was not there for anyone who is not me. If you cannot reach wp-login.php you cannot brute force it.

A quick recap of my stack from the hosting post because it matters here. Nothing talks to WordPress directly. Traffic comes in via OPNsense which forwards 443 to 10.0.0.200:8443 and 80 to 10.0.0.200:8081. Nginx Proxy Manager (NPM) terminates TLS with a Let’s Encrypt certificate and proxies through to the wordpress container over the wp-net bridge network. NPM is the front door, which makes it the right place to enforce who gets in. This is the relevant part of my compose file.

  nginx-proxy-manager:
    image: jc21/nginx-proxy-manager:latest
    container_name: nginx_proxy_manager
    restart: unless-stopped
    ports:
      - "81:81"        # NPM admin UI
      - "8081:80"      # HTTP  - OPNsense forwards 80 here
      - "8443:443"     # HTTPS - OPNsense forwards 443 here
    volumes:
      - /mnt/user/appdata/nginx-proxy-manager/data:/data
      - /mnt/user/appdata/nginx-proxy-manager/letsencrypt:/etc/letsencrypt
    networks:
      - wp-net

NPM is a GUI over nginx, but it leaves an escape hatch. Open the proxy host for the blog, go to the Advanced tab and you get a Custom Nginx Configuration box. Whatever you put in there is injected in to the server block for that host. This is mine.

# ---------------------------------------------------------------------------
# WordPress hardening
# Nginx Proxy Manager > Proxy Host > Advanced > Custom Nginx Configuration
# ---------------------------------------------------------------------------

# 1. admin-ajax.php is called from the PUBLIC front end by plugins
#    (WooCommerce, WP ULike, forms). An exact match beats the regex
#    below, so this stays open to the world.
location = /wp-admin/admin-ajax.php {
    include conf.d/include/proxy.conf;
    proxy_pass $forward_scheme://$server:$port;
}

# 2. The back door is LAN only. Anyone else gets a 403 from nginx
#    before the request ever reaches PHP.
location ~* ^/(wp-admin|wp-login\.php) {
    allow 10.0.0.0/24;
    deny  all;
    include conf.d/include/proxy.conf;
    proxy_pass $forward_scheme://$server:$port;
}

# 3. Nobody needs XML-RPC. It is the favourite brute force endpoint
#    because one request can carry hundreds of password attempts.
location = /xmlrpc.php {
    deny all;
}

There are three things going on here.

  1. /wp-admin and /wp-login.php are LAN only. allow 10.0.0.0/24 then deny all. My LAN gets a login page, the rest of the internet gets a 403 from openresty and PHP never even wakes up. This is the whole point of the exercise.
  2. The admin-ajax.php exception. This one caught me out the first time. A lot of plugins call /wp-admin/admin-ajax.php from the public front end, WooCommerce and WP ULike both do. If you block /wp-admin wholesale you break your own site for every visitor. In nginx an exact match (location =) is more specific than a regex and wins, so it is carved out and stays open.
  3. xmlrpc.php is denied to everyone, including me. Nothing I use needs it (Jetpack does, if you rely on Jetpack think twice). It is the favourite endpoint for brute forcing because system.multicall lets an attacker try hundreds of passwords in a single HTTP request.

The $forward_scheme, $server and $port variables are set by NPM itself in the generated config, so these locations forward to exactly the same place as the rest of the site and you do not have to hard code the container name. Because this lives in NPM’s Advanced tab it survives NPM image updates, and because NPM’s data directory is in appdata it goes to S3 every night. If you want to see the final config nginx is actually running, it is on disk at /mnt/user/appdata/nginx-proxy-manager/data/nginx/proxy_host/1.conf (your number will differ).

Talk is cheap, so here is proof. The same URLs hit twice, once from the internet using normal DNS and once from inside my network straight at NPM on 8443.

# From the internet - real DNS, public IP, no VPN
$ for P in /wp-admin/ /wp-login.php /xmlrpc.php /wp-admin/admin-ajax.php; do
    printf "%-28s " "$P"; curl -s -o /dev/null -w "%{http_code}\n" "https://automation.baldacchino.net$P"
  done
/wp-admin/                   403
/wp-login.php                403
/xmlrpc.php                  403
/wp-admin/admin-ajax.php     400

# From my LAN - straight at Nginx Proxy Manager on 8443
$ for P in /wp-admin/ /wp-login.php /xmlrpc.php /wp-admin/admin-ajax.php; do
    printf "%-28s " "$P"; curl -s -o /dev/null -w "%{http_code}\n" \
      --resolve automation.baldacchino.net:8443:10.0.0.200 "https://automation.baldacchino.net:8443$P"
  done
/wp-admin/                   302
/wp-login.php                200
/xmlrpc.php                  403
/wp-admin/admin-ajax.php     400

From the outside the admin surface is a wall of 403s. From the inside /wp-admin/ 302s me to the login page and wp-login.php renders. xmlrpc.php is 403 from both sides, as intended. And admin-ajax.php returns 400 from both sides, which is WordPress itself saying you did not give me an action, meaning the request made it all the way through nginx to PHP. That is the exception working. Same server, same URLs, the only difference is where I am standing.

Getting In When I Am Not At Home

This is where it gets a bit more interesting. When I am away I connect back in to the house via Unraid’s built in WireGuard (Settings, VPN Manager). My laptop lands on 10.253.0.2 in the tunnel and 10.0.0.0/24 is routed down it, so from a networking point of view I am at home.

But my browser does not know that. automation.baldacchino.net resolves to my public IP, so when I type the URL in Chrome goes out the front door of the tunnel, arrives at NPM from the internet side, and NPM does exactly what I told it to do. 403. I built a wall and then walked in to it.

The proper fix is split DNS. A host override in OPNsense’s Unbound so that inside the house (and inside the tunnel) the name resolves to 10.0.0.200. The catch is DNS can only change the IP, not the port, and NPM listens on 8443 not 443. I could re-plumb NPM to sit on 443 but I have other things on that host and I have never gotten around to it. So instead I have a small script on my Mac that I keep with the rest of my utilities in ~/scripts/mac.

#!/bin/zsh
# Open automation.baldacchino.net in Chrome, resolved to the LAN box
# instead of the public IP. Keeps the real hostname so the Let's Encrypt
# cert validates and WordPress cookies/redirects behave normally.

HOSTNAME="automation.baldacchino.net"
TARGET="10.0.0.200:8443"          # HTTPS vhost on the LAN (NOT 8080/8081)
URL="https://${HOSTNAME}/wp-admin/"
PROFILE="${TMPDIR:-/tmp}/chrome-${HOSTNAME}"

exec "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
  --user-data-dir="$PROFILE" \
  --host-resolver-rules="MAP ${HOSTNAME} ${TARGET}" \
  --no-first-run --no-default-browser-check \
  "$URL" >/dev/null 2>&1 &

The magic is --host-resolver-rules. This is Chrome’s own resolver override and unlike /etc/hosts it lets you map a hostname to an IP and a port. A few things I learnt getting this right.

  • 8443, not 8081. Look at the NPM port mappings above, 8081:80 and 8443:443. A https:// URL has to land on the TLS listener. Point it at 8081 and Chrome tries to do a TLS handshake against a plain HTTP port and all you get back is a 301 to the public hostname, which sends you straight back out the front door to your own 403. Yes, I did this.
  • The hostname stays. Because Chrome still thinks it is talking to automation.baldacchino.net the Let’s Encrypt certificate validates with no warnings, WordPress’s cookies are set on the right domain and its redirects all work. Browsing to https://10.0.0.200:8443 directly gives you a certificate error and a login that never sticks.
  • The rule covers every port. This matters because WordPress redirects /wp-admin/ to wp-login.php on the default port 443. If the mapping only covered one port that redirect would escape to the public IP mid way through the login.
  • A separate --user-data-dir. Chrome ignores command line flags if there is already an instance running, so this forces a new one. It also means the override only applies to this one window and my normal browsing is untouched.

Run it and I get a Chrome window sitting on the WordPress login page, coming in via the tunnel as a LAN address. Unraid’s WireGuard in Remote access to LAN mode NATs the tunnel behind the server so as far as NPM is concerned I am just another device in the house. It is not elegant, but it is one command and it has worked every time I have needed it.

Layer 3 – Patching

Everything above tells me what is wrong and shrinks the blast radius. Neither of them actually fixes a vulnerable plugin. Patching does, and it is the least glamorous and most important layer.

Go back to that WPScan output. Seventeen known vulnerabilities across two plugins and every single one of them had a fix already published. The only reason they were sitting there is I was clicking Update in the WordPress dashboard when I remembered to, and I did not remember to. So I stopped doing it by hand.

Plugins & Themes Auto Update

Since 5.5 WordPress has had per plugin auto updates built in. Go to Plugins, and against each one click Enable auto-updates. Same again for your theme under Appearance, Themes. If you prefer the command line, the Bitnami image I use ships WP-CLI so you can do it from the Unraid host in two lines.

docker exec wordpress wp plugin auto-updates enable --all
docker exec wordpress wp theme  auto-updates enable --all

# and confirm
docker exec wordpress wp plugin auto-updates status --all

Yes, an auto update can break your site. I have had a plugin update take a page layout with it. But I take nightly backups to S3 with versioning and UpdraftPlus on top of that, so a bad plugin update is a restore, not a rebuild. Weigh it up honestly, you are far more likely to be hit by a known exploit against a plugin you forgot to update than by a broken update, the maths favours updating. WordPress core applies minor and security releases automatically by default and I leave that exactly as it is.

The Stack – Docker Compose

Plugins are only half of the surface. The rest is PHP, Apache, MariaDB and Nginx Proxy Manager, and every one of those is a binary inside a container image. This is where the Docker Compose approach I banged on about in the hosting post pays for itself. The binaries live in the image, my data lives in /mnt/user/appdata/wordpress, and updating one does not touch the other. From the directory with my compose file

cd /mnt/user/appdata/wordpress

# 1. take a backup first - same mariadb-dump my nightly script runs
docker exec mariadb_wp bash -c "mariadb-dump -u root -p'xxxxxxx' \
  --single-transaction --routines --triggers wordpress" \
  > /mnt/ssd_pool/appdata/wordpress/db_backup.sql

# 2. grab the latest images for every service in the stack
docker compose pull

# 3. stop and remove the containers (volumes and data are untouched)
docker compose down

# 4. bring it back up on the new images
docker compose up -d

# 5. watch it come up
docker compose logs -f --tail=50 wordpress

Step 1 is not optional. The backup script from my hosting post does a mariadb-dump with --single-transaction and syncs appdata to S3, it runs nightly on its own but I run it by hand before I pull anything. It takes about 30 seconds and it means the worst case of a bad update is a five minute restore.

Steps 2 through 4 are the update. pull fetches the new images for MariaDB, WordPress and NPM. down stops and removes the containers, and this is the bit that makes people nervous the first time, but remember nothing lives in the container. WordPress is in /mnt/user/appdata/wordpress/wp, the database is in /mnt/user/appdata/wordpress/db/data, NPM’s config and certificates are in their own appdata folders. up -d creates fresh containers from the new images and mounts the same data back in. The site is down for about 20 seconds while MariaDB starts.

If a new image does something I do not like, rolling back is changing the tag in the compose file and running the same three commands. image: mariadb:11.8 pinned is exactly why I pin MariaDB to a major version, I want PHP and Apache to move quickly and I want my database engine to move when I say so.

  mariadb:
    image: mariadb:11.8          # pinned - moves when I decide
  wordpress:
    image: bitnami/wordpress:latest   # moves every pull
  nginx-proxy-manager:
    image: jc21/nginx-proxy-manager:latest

I do this roughly monthly, or whenever the Sunday WPScan email tells me to. Then I re-run the scan by hand and make sure it comes back quiet.

Summary

None of this is exotic and none of it is a product. It is three layers that each cover for the weakness of the other two, all running on the same box in my garage.

LayerWhatWhere It RunsCadence
Know where I standWPScan in Docker, JSON parsed with jq, Gmail via curlUnraid, User ScriptsWeekly, Sunday 6am. Email only on findings
Reduce the surfaceNPM allow/deny on wp-admin, wp-login.php and xmlrpc.phpNginx Proxy Manager, Advanced tabAlways on
Remote accessWireGuard plus Chrome –host-resolver-rulesUnraid and my MacWhen I am away
Patch plugins & themesWordPress built in auto updatesWordPressAs released
Patch the stackdocker compose pull / down / up -dUnraidMonthly, or when WPScan says so
Safety netmariadb-dump + S3 versioning + UpdraftPlusUnraid to us-east-1Nightly, and before every update

The scan does not make me secure, it just tells me the truth. Locking down wp-admin does not make me secure either, it just takes the easiest target off the table. What makes me secure is acting on the scan, and keeping core, themes and plugins current without having to remember to. And before you touch a single update, take a backup. I have said it before and I will keep saying it, the most important thing is my data. Scan, back up, patch, re-scan. Then get on with your weekend knowing that if anything changes your inbox will tell you.

If you run WordPress at home and you are doing something different, or you have a better way of handling the WireGuard problem than my Chrome script, leave me a comment below. Thanks Shane Baldacchino

Leave a Comment