We rebuilt our own site as static HTML, and the attack surface went with it
Hardening a CMS is an argument you are always slightly losing. Every plugin is somebody else’s code executing on your server with your database credentials. Every update is a race. Every uploads directory is a place a file might be written and then executed. You can do all of it properly — least privilege, per-site process isolation, PHP blocked under uploads, checksums run on a schedule — and you are still maintaining a position rather than holding one.
We had been doing exactly that across an estate of sites, and doing it competently after a painful education. Then we rebuilt our own site and the position stopped needing to be maintained, because there was nothing left to defend.
What a static build actually removes
Not “reduces”. Removes. The site is a directory of HTML, CSS, images and two XML files, served by nginx. There is:
- no database, so there is no SQL injection, no credential in a config file, and no dump to exfiltrate
- no PHP execution anywhere in the document root, so an uploaded file is a file, not a program
- no admin login, so there is no brute force, no session fixation and no password to leak
- no plugin surface, so a vulnerability disclosed in a popular extension is somebody else’s incident
- no write path at all from the web, so nothing on disk can be modified by a request
That last one is the quiet one. Most CMS compromises persist because the attacker can write. Remove the ability to write and a compromise has nowhere to live: the next deploy overwrites the tree wholesale, from a build that came from version control.
The rule we set on the vhosts
Every PHP request to these sites returns a real 404, deliberately, and will keep doing so.
That is not paranoia about a language. It is about what the previous era left behind. A site that has been compromised accumulates injected URLs — thousands of them, indexed, linked from elsewhere, with inbound traffic. If those paths start returning 200 again because a well-meaning rewrite rule caught them, the spam comes back to life in the index and the domain’s reputation goes with it.
So unknown paths must keep returning a genuine 404, and PHP must not execute even by accident. Those two rules are what keep several thousand previously-injected addresses deindexed.
A lesson about protections that are decorative
A detail from the estate work that transfers directly. These vhosts run on nginx, and nginx
does not read .htaccess at all. A great deal of WordPress security advice, and several popular
security plugins, protect a site by writing rules into .htaccess. On Apache that works. On nginx
those files are inert text sitting in the directory they claim to be protecting.
It is worth saying plainly because the failure is invisible: the plugin reports that protection is enabled, the file exists, and nothing is enforced. That is how a directory of site backups — the sort of thing that contains a full database dump — can sit publicly readable for a long time while a dashboard says everything is fine.
The general form of the lesson: verify that a protection is enforced by the thing actually serving the request, not that it has been configured somewhere.
What we gave up, honestly
Three things, and they are real.
Non-technical editing. There is no admin interface. Content lives in Markdown with typed frontmatter, in version control, and publishing means a commit and a build. For a team that writes its own material this is an improvement — review, history and rollback come free. For an organisation where marketing needs to publish without engineering, it is a genuine cost and should be priced as one.
Anything that needs a server. The contact form is the obvious case. It is now a small endpoint of our own, running as its own user under systemd with a restricted syscall and address-family profile, rather than a plugin inside the CMS. That is better, but it is a thing we now own and maintain instead of a checkbox.
Instant changes. A typo fix is a build and a deploy rather than an edit and a save. The build is about ten seconds and the deploy is a directory swap, so in practice this costs a minute — but it is a minute, not zero.
What it bought operationally
Deployment became a swap. The new build is unpacked beside the live directory, checked in place,
and then two mv commands exchange them. Rollback is the same two commands in the other order,
and the previous tree is still sitting there.
Backups became trivial, because the site is the build output and the build output is a function of the repository. The thing that needs backing up is the enquiry database, which is a much smaller and better-defined problem than “a CMS, its uploads, its database and whatever state has accumulated in it since 2019”.
And the security work moved from continuous to structural. There is no longer a patching cadence for the website, because there is nothing in the document root that executes.
When this is the wrong answer
If the site is genuinely a content operation — many authors, frequent publishing, editorial workflow, scheduled posts, non-technical staff — a CMS is the right tool and the answer is to run it properly: per-site process isolation, file integrity checking against the official distribution rather than signature scanning, and updates treated as an operational duty rather than a notification.
Static generation suits a site that is mostly read and occasionally written, by people who are comfortable with a pull request. That describes a company site and most product marketing. It does not describe a newsroom.