# Securing WordPress without plugins: the Infrastructure as Code approach

> What if we stopped asking PHP to protect itself? Experiment report on a WordPress Bedrock deployed on Upsun, where security lives in three versioned files instead of fifty plugins.

- **Date**: 2026-04-15
- **Category**: Innovation
- **Author**: Romain Delfosse
- **URL**: https://www.romaindelfosse.fr/en/blog/securiser-wordpress-sans-plugins-infrastructure-as-code/

---

*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](https://github.com/rdelfosse/upsun-wp-exploration). 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:

1. `.upsun/config.yaml` — the router and Nginx configuration
2. `web/app/mu-plugins/upsun-security.php` — a mu-plugin that hardens what must remain in PHP
3. `web/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:

```yaml
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:

```yaml
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:

```php
// 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.**

```php
$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:

1. **Systematic bypass** for `/health` (monitoring needs frictionless access).
2. **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.
3. **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:

```yaml
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:

```yaml
"/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:

```bash
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:

```bash
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_options` table. 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-only` to `strict`, that's a commit, a PR, a review. `git blame` tells 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/uploads` looking 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](https://wordpress.org/plugins/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](https://github.com/rdelfosse/upsun-wp-exploration?utm_source=romaindelfosse&utm_medium=blog&utm_campaign=wordpress-iac). 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.*
