The week I decided to remove every security plugin from my WordPress, it took me a while to register what I had just done. It wasn't an act of trust in WordPress. It was exactly the opposite: an acknowledgement that you can't ask PHP to defend itself against attacks that target PHP. The full source code of the experiment is available on GitHub. It is not a production-ready template. It's a public archive of experiments — and that's written in black and white in the README.
WordPress powers more than 40% of the web. That number is both its strength and its curse: when you're the most obvious target, you also attract the most malicious attention. The traditional community answer, for the last fifteen years, has been to stack security plugins. Wordfence. Sucuri. iThemes Security. All In One WP Security. We add a PHP layer to watch PHP.
I was doing the same as everyone else. And then one day I stepped back and looked at what I was actually asking these plugins to do. The answer made me uncomfortable: I was asking them to intercept malicious HTTP requests from inside the very application being targeted. It's like installing a guard inside the vault rather than at the entrance of the bank.
So I opened an empty repo, took a WordPress Bedrock stack, pushed it onto Upsun (formerly Platform.sh), and set myself a rule: no security plugins. Everything had to happen upstream of PHP, in infrastructure configuration, as Infrastructure as Code.
The paradox of security plugins
Before describing what I did, I want to verbalise why the "PHP plugin" approach is structurally flawed. There are four compounding problems.
First: a plugin is code that increases your attack surface. It's counter-intuitive, but it's mechanical. Every security plugin is a few thousand extra lines of PHP that run on every request. Those lines can themselves contain flaws. CVEs on security plugins themselves aren't a rarity — they show up every year, and each one is a cruel paradox: you installed a plugin to protect yourself, and that plugin became the entry point.
Second: they intervene too late. This is the point I find most important. When a request targeting /wp-content/uploads/shell.php lands on your server, here's what happens before your security plugin can do anything: the load balancer accepts it, Nginx routes it, PHP-FPM spawns a worker, WordPress boots (we're talking a few tens of megabytes in memory), the plugins load, and finally your security plugin wakes up and says "nope, not this one". By then the attacker has already burned CPU and RAM, and had the chance to exploit any flaw in the load path.
Third: the configuration lives in the database. Plugin settings are stored in wp_options. Not version-controlled. Not auditable through git blame. Not reproducible in another environment. If you lose the database, you lose your security posture. If a colleague flips a setting in the dashboard, nobody knows. It's the exact opposite of what DevOps has been trying to teach us for ten years.
Fourth: they create a false sense of security. A green padlock in the WordPress dashboard does not mean your site is secure. It means a plugin has verified what it knows how to verify. What it doesn't verify, you'll never know, because you haven't read its 50,000 lines of code.
Defense in Depth: push controls as low as possible
The approach I'm about to describe is an application of the Defense in Depth principle: you don't put all your eggs in a single security layer, you stack several, and above all you place each control at the lowest possible level of the stack.
On a WordPress hosted on Upsun, the stack looks like this:
Incoming HTTP request
└─► Upsun router (TLS, cache, redirects) ← Layer 1
└─► Nginx (locations, rules, headers) ← Layer 2
└─► PHP-FPM (OPcache, disable_functions) ← Layer 3
└─► WordPress (business logic)
The idea is that by the time a request actually reaches WordPress, it has already been filtered by three upstream layers. WordPress no longer handles perimeter security — it only handles what it was built for: producing pages from a database.
In my experiment, everything fits into three files tracked in Git:
.upsun/config.yaml— the router and Nginx configurationweb/app/mu-plugins/upsun-security.php— a mu-plugin that hardens what must remain in PHPweb/app/mu-plugins/health-check.php— a monitoring endpoint that doesn't load WordPress
Three files. Not fifty plugins in a database. Let's see what's inside.
What happens at the Nginx layer
The first line of defense is the web.locations."/" block in Upsun's config.yaml. It describes the rules Nginx applies before PHP is even invoked — and therefore before WordPress is loaded in memory. A request matching a forbidden rule is rejected in a handful of microseconds, without ever booting a PHP-FPM worker.
Here are the most important rules, pulled straight from the repo:
rules:
# Sensitive files: never serve .env, .git, composer.*
'\.(env|git|composer\.json|composer\.lock|auth\.json)$':
allow: false
# Config, backup, docs (Defense in Depth)
'\.(yml|yaml|dist|sql|log|sh|bak|ini|swp|md|markdown)$':
allow: false
# Path traversal attempts
'^//+':
allow: false
# PHP in /uploads: the classic webshell vector
'^/app/uploads/.*\.(php[0-9]?|phtml|inc)$':
allow: false
# Direct PHP in /plugins and /themes: prevents bypass
'^/app/(plugins|themes)/.*\.php$':
allow: false
# WordPress version disclosure
'^(?:/wp)?/(?:readme|license|changelog|-config|-sample)\.(?:html|txt|php)':
allow: false
# wp-login.php and wp-admin at root: 404 for everyone
'^/wp/wp-login\.php': { allow: false }
'^/wp-login\.php': { allow: false }
'^/wp-admin': { allow: false }
# Install/upgrade scripts (unused after deployment)
'^/wp/wp-admin/(install|upgrade)\.php':
allow: false
# XML-RPC: brute-force vector and DDoS amplifier
'^/wp/xmlrpc\.php': { allow: false }
'^/xmlrpc\.php': { allow: false }
# User enumeration via the REST API
'^/wp-json/wp/v2/users':
allow: false
Pick any of these rules and compare it to what a typical security plugin does. Wordfence can block user enumeration via the REST API — but it does so after WordPress has loaded the REST plugin, instantiated the controller, and started executing the request. Nginx does it in a few microseconds. For a high-traffic site receiving hundreds of automated scans per hour, that's the difference between a server that sweats and a server that sleeps.
One detail worth underlining: blocking wp-login.php at the Nginx layer. In my experiment, I use a "secret admin path" driven by an environment variable: the WordPress login URL becomes https://mysite.com/my-very-random-secret-2026, reserved for me, and the public wp-login.php URL returns a 404 for everyone. Bots that spend their day testing passwords against /wp-login.php no longer find anything to attack.
Modern security headers, at the right layer
HTTP security headers are another typical example of something we traditionally hand over to a plugin when it should live at the server layer. In config.yaml, it's a few lines:
headers:
X-Frame-Options: "SAMEORIGIN"
X-Content-Type-Options: "nosniff"
Referrer-Policy: "strict-origin-when-cross-origin"
Permissions-Policy: "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=(), interest-cohort=()"
A quick note on what's not there: the infamous X-XSS-Protection. I deliberately left it out. This header has been deprecated since 2020 and can introduce vulnerabilities in some browsers — Chrome disabled it by default, Firefox never implemented it. Most WordPress security guides still recommend it. That's exactly the kind of tech debt plugins struggle to shed.
For Permissions-Policy, I disable by default everything a WordPress site never needs: camera, mic, geolocation, payment, USB. And interest-cohort=(), which opts out of FLoC — Google's tracking system that we don't want coming back.
Content Security Policy: four modes driven by an environment variable
CSP is the most powerful security header — and the most annoying one to roll out. A strict CSP breaks pretty much everything that lives inline in WordPress: plugin scripts, injected styles, event handlers, and so on.
My approach in the repo is to drive CSP through a UPSUN_CSP_MODE environment variable that accepts four values:
// In upsun-security.php
// UPSUN_CSP_MODE:
// 'off' : no CSP (not recommended)
// 'permissive' : CSP with unsafe-inline (WordPress compat)
// 'report-only' : strict CSP in observation mode (recommended starting point)
// 'strict' : strict blocking CSP (end goal)
The point is that you can progress gradually toward a strict CSP without breaking production. You start in permissive so everything still works, move to report-only in staging to collect violations without blocking, fix them one by one, and the day you switch to strict in production, you've already verified there are no false positives. On every request, the mu-plugin generates a unique nonce (base64_encode(random_bytes(16))) that is automatically injected into every <script> tag WordPress emits via the script_loader_tag filter. Result: legitimate inline code passes through, attacker-injected code is blocked.
What I find elegant about this approach is that it's version-controlled. When I flip CSP from report-only to strict, that's a commit. Another developer can review the PR. If it breaks, we roll back in one command. Compare that to a plugin storing its CSP setting in wp_options.
A Nano-WAF written in PHP inside a mu-plugin
This is the most entertaining part of the experiment. Every Nginx rule we saw above deals with URL patterns. But there's still a category of attacks invisible in the URL: the ones that exploit HTTP headers. TRACE method for cross-site tracing, scanner User-Agents fingerprinting your site, application fingerprinting through weird headers.
For that, I let a very small piece of PHP code intervene — but before WordPress is loaded, through a mu-plugin that fires on muplugins_loaded (or earlier). I've nicknamed it a Nano-WAF: three hundred lines doing basic perimeter filtering.
First filter: dangerous HTTP methods.
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$blocked_methods = ['TRACE', 'TRACK', 'DEBUG', 'CONNECT'];
if (in_array(strtoupper($method), $blocked_methods)) {
status_header(405);
header('Allow: GET, POST, HEAD, PUT, DELETE, PATCH, OPTIONS');
exit;
}
These four methods have no legitimate reason to be accepted by a WordPress site. They exist for debugging and HTTP proxying. Blocking them is free and closes off an entire attack vector.
Second filter: a User-Agent allowlist/blocklist that splits into three categories:
- Systematic bypass for
/health(monitoring needs frictionless access). - Allowlist for legitimate services — and here I genuinely took the time to catalogue them: search engine bots (Googlebot, Bingbot, DuckDuckBot, Qwant, Ecosia), payment webhooks (Stripe, PayPal, Mollie), monitoring tools (Pingdom, UptimeRobot, Datadog), AI crawlers (ClaudeBot, GPTBot, PerplexityBot, Google-Extended, ChatGPT-User — I want my content indexed by LLMs), and more.
- Blocklist of known attack scanners:
sqlmap,nikto,nmap,masscan,nuclei,wpscan,joomscan,dirbuster,gobuster,burpsuite,metasploit, and the rest of the collection. When one of these user-agents is detected, the server responds with 404 — not 403. It's deliberate: a 403 tells the attacker we saw them coming. A 404 makes them think the resource doesn't exist. This is called stealth mode, and it significantly reduces log noise.
The design is fail-open: an unknown User-Agent passes. I really don't want to block a new legitimate service I didn't anticipate in the allowlist. The only thing that fails closed is a positive scanner detection.
Third filter: path filtering in stealth 404 mode. Paths like /wp-admin at root, /wp-login.php at root, xmlrpc.php at root, /wp/readme.html, /wp/license.txt, etc., are already blocked by Nginx — but the mu-plugin double-checks and also returns a 404 with an X-Robots-Tag: noindex, nofollow header. Belt and braces.
OPcache: taking advantage of an immutable filesystem
Upsun, like Platform.sh and most modern PaaS offerings, builds your application into an immutable image. Once deployed, the application filesystem is read-only. The only way to modify code is to ship a new deployment.
That constraint, which might look annoying at first, is actually a gift for both security and performance. For security: an attacker who manages to upload a PHP file somewhere cannot execute it, because the zone where they can write (/uploads, mounted as a volume) is blocked by the Nginx rules we saw earlier. And for performance, it changes everything we can do with OPcache.
OPcache is PHP's bytecode cache. By default, it periodically checks whether PHP files have changed on disk (opcache.validate_timestamps=1). On an immutable PaaS, that's pointless: the files cannot change. So you can configure:
php:
opcache.enable: 1
opcache.memory_consumption: 128
opcache.interned_strings_buffer: 16
opcache.max_accelerated_files: 20000
opcache.validate_timestamps: 0
opcache.revalidate_freq: 0
The key parameter is validate_timestamps: 0. PHP never re-checks files. Everything is served from worker memory. The disk is only touched on the very first load of a file. I don't have a benchmark with numbers to show you in this article (that'll be a dedicated follow-up), but the qualitative effect is tangible: pages load faster, and CPU load is visibly lower.
A health-check that doesn't load WordPress
The last piece of the puzzle, and probably the most under-rated. For external monitoring, you need an endpoint that says "all good" or "something's wrong". The temptation is to use the home page: a monitor doing GET / every minute.
That's a terrible idea. On every check, you boot the entire WordPress stack: plugins, themes, hooks, database queries. For a monitor checking every 30 seconds, that's dozens of full WordPress loads per minute just to say "it's alive".
My approach in the repo: a health-check.php mu-plugin that answers on /health, checks PHP, MariaDB and Redis, returns a JSON, and doesn't load WordPress. Technically it defines WP_USE_THEMES = false and WP_INSTALLING = true before including wp-config.php, which prevents most plugins from hooking in. On Upsun I have something even better: in config.yaml I've defined a dedicated /health location that points straight to the PHP file, completely bypassing the WordPress router:
"/health":
root: "web/app/mu-plugins"
passthru: "/health-check.php"
allow: true
scripts: true
headers:
Access-Control-Allow-Origin: "*"
Result: the check doesn't traverse WordPress at all. It's a standalone endpoint living in the same PHP-FPM process, but with zero dependency on WordPress. It can answer even when WordPress is broken — which is exactly what you want from a health check. For external monitoring, it's more reliable. For zero-downtime deployments, it's indispensable.
Supply chain security: auditing what you install
One last piece that goes beyond "perimeter security" but rounds out the picture. In the Upsun build hook, I run two commands:
composer install --no-dev --optimize-autoloader --prefer-dist
composer audit --locked
The second command reads composer.lock and compares every dependency against the public vulnerability database. If a CVE exists on an installed package, the build flags it. That kind of control simply doesn't exist in a classic WordPress workflow — because there's no composer.lock, no build pipeline, no versioned workflow to hook into.
And in post-deploy, another check:
wp core verify-checksums
This WP-CLI command asks WordPress.org for the official core checksums and compares them against the installed files. If any core file has been modified (backdoor, tampering), it gets detected. Free, idempotent, executed on every deployment.
What it actually changes
I don't have a benchmarks table to sell you — it would be dishonest without running a rigorous side-by-side comparison against an equivalent plugin-based install. What I can share are the qualitative observations I've taken away from this experiment:
- The audit surface is radically smaller. Three files in Git, instead of fifty plugins and a
wp_optionstable. A security auditor can read the full security configuration in fifteen minutes. - The configuration is reproducible. I can spin up a new staging environment in one command (
upsun environment:branch) and get exactly the same security posture. On a classic WordPress, that kind of staging/prod parity requires database exports, settings migration, and a lot of guesswork. - Security changes go through PR review. When I want to move CSP from
report-onlytostrict, that's a commit, a PR, a review.git blametells me who changed what and when. On classic WordPress, the same change is a checkbox in a dashboard, invisible to everyone. - The WAF doesn't exist outside the PHP process. It's a nano-WAF, not a real managed WAF. It doesn't replace Cloudflare or Akamai. It doesn't protect against volumetric DDoS. It's there to close off obvious vectors, not to deal with sophisticated traffic.
The limits — and what plugins still do better
I don't want to leave the impression that you can replace everything with Infrastructure as Code. There are several things security plugins still do far better than an infra configuration:
- Scanning malware on existing files. A Wordfence that scans
/wp-content/uploadslooking for known signatures is still useful on an established site that may have already been compromised. - Two-factor authentication. That's applicative logic, it naturally belongs in WordPress, and plugins like Two Factor (maintained by the core team) handle it very well.
- Advanced application firewalling. WordPress-specific business rules (like detecting injection attempts inside Elementor form fields) are better handled by a plugin that understands the application context.
- Compliance reporting. For GDPR, PCI-DSS and the like, plugins produce the reports auditors expect in the format they expect.
The approach I'm advocating isn't "zero security plugins forever". It's "infrastructure handles perimeter security, and plugins only deal with what must live in the application domain". In practice, that shrinks your security plugin footprint to zero or one (Two Factor), instead of five or ten.
Conclusion: why this experiment changed me
What I took away from this session isn't so much a recipe to copy-paste as a shift in mental posture. As long as we keep asking PHP to protect itself, we accept that security is a problem solved by business code. As soon as we push controls into infrastructure configuration, we change worlds: security becomes an infrastructure problem — version-controlled, reviewed, auditable, reproducible. It's no longer a layer you add. It's a property you declare.
It connects to something I've been seeing for years across all my B2B and B2G projects: value no longer comes from the code you write, it comes from the operational posture you give yourself. A well-configured site running an up-to-date WordPress core with three well-thought-out infrastructure files is more defensible than a site with fifty security plugins and a dashboard full of green lights.
The source code of this experiment is available on GitHub: rdelfosse/upsun-wp-exploration. It is neither a production-ready template nor a tutorial to follow blindly. It's a concept car: a showcase of what you can stack when you push Infrastructure as Code logic to its maximum on a WordPress install. Use it as inspiration, adapt it to your context, challenge it.
This article is the first in a series on modernising WordPress stacks. Next episode planned: CI/CD and zero-downtime deployments — or how to stop doing your updates over FTP.

