I keep choosing tools that were already old ten years ago, and I keep not regretting it. This is the essay where I actually defend that, with the receipts — because "boring tech good" is a slogan, and slogans are cheap. The bill is what's interesting.
Short version up front, because I hate articles that make you scroll for the payoff: the boring stack has paid for itself nine years running, and the places where it hurts are not the places people warn me about. The warnings are always about being behind the times. The actual costs are a front-end library nobody is patching any more, a CSS framework that stopped getting security fixes in January 2023, runtime upgrades that nothing will ever force me to do, and the fact that a good share of this system's reliability lives in people's heads instead of in a type checker. None of those are arguments against the choice. They're the invoice, and it's a cheap invoice. But I'd rather print it than pretend it's zero.
What is actually running, in exact versions
This matters, so let me be specific rather than atmospheric. The CMS behind this site is iHomePage CMS — started around 2017, rewritten several times since. Here is what is running on the machine serving you this page:
- PHP 8, behind Nginx and PHP-FPM over a Unix socket.
- SQLite as the production database. One file per site. No database server, no port, no credentials, no connection string.
- Twig 3 for templates, with a thin query layer over PDO.
- One entry point and a kernel of about a dozen files, all of it under
declare(strict_types=1). That is the whole engine: router, controller, business and data layer, static publisher, schema migrator, pagination, HTML sanitizer, module dispatch. - On the front end of this site: jQuery 3.6.0, Bootstrap 4.6.2, Popper 1.16.1, all three from a CDN, plus one hand-written stylesheet —
main.css, 716 lines, 148 selectors, exactly one media query — busted with?ver=1.14. No bundler. Nonpm install. No build output directory. When I change the CSS I edit the CSS and bump the number.
And then the part that makes the rest of it work: everything is rendered server-side with Twig and then written to disk as flat HTML. Nginx tries the file first and only falls through to PHP when there isn't one:
So the normal case for a visitor is Nginx handing over a file. PHP, SQLite and Twig are the *fallback*, not the hot path. There is a full-site build (a full-site build step) and a single publish method (SiteService::publish(url)) that every path goes through — admin save, agent write, "regenerate" button, sitemap cron, and the fallback render that writes itself to disk after the response has already been flushed to the visitor. One publish method, byte-identical output no matter who called it.
That is the stack. Nothing in it was fashionable in 2016.
The canonical argument, and where I part ways with it
The reference text here is Dan McKinley's "Choose Boring Technology," written up from a talk he gave at a Wikimedia developer conference around 2015. If you haven't read it, read it before you read me. His central device: "Let's say every company gets about three innovation tokens." Writing your website in Node spends one. MongoDB spends one. Spend them where the novelty is the product; everywhere else, pick the thing whose failure modes are already mapped. He is careful about the obvious misreading — "'Boring' should not be conflated with 'bad'" — and in the spoken version he puts the real appeal better than I could: of boring technology's known flaws, "It's bad, but you know why it's bad."
I agree with nearly all of it and I think the accounting metaphor is wrong for me in two ways.
First, tokens are a company budget. My constraint is a maintenance horizon. McKinley is writing for an engineering org, where the cost of novelty is coordination: more things to learn, more on-call surface, more ways for a new hire to be lost. I run something small on purpose. My cost of novelty is different and more brutal: anything I adopt, I will still be the person who understands it in five years. Not "the team" — me, and the few people I work with. That reframes the question. It isn't "can we afford this token," it's "am I willing to still be answering questions about this in 2031." Most of what gets pitched to me fails that question immediately, and it has nothing to do with whether the technology is good.
Second, tokens get spent down. Boring compounds. The token metaphor implies a depleting resource, which makes boring sound like thrift. It's the opposite: the boring core is what *generates* the budget for new things. I'll come back to this, because the most modern thing in my CMS exists only because the dull parts underneath it never move.
The best critique of the boring doctrine I've found is Pablo Meier's "Against 'Boring'," and his sharpest line is the one that should bother anyone who argues my side: this approach, "by definition, can't outperform the market." He's right, and it's the index-fund argument — risk-managed, floor-protecting, ceiling-capped. He also concedes the thing that decides it for me: "Most products don't need technical innovation of any kind, just plumbing." A CMS that renders content to HTML is plumbing. I'm not trying to outperform a market with my template engine.
The other critique worth taking seriously doesn't come from an essay, it comes from commenters on the Lobsters and HN threads about McKinley's piece, and it's this: boring is relative to expertise. Whether SQL or Mongo is "boring" depends entirely on your own experience; a team of Kubernetes experts finds Kubernetes boring. That is completely true, and it means this essay is partly autobiography. PHP and SQLite are boring *to me* because they are what this CMS has been written in since 2017 and I can picture how they fail. If you learned to program in 2021, React is your boring. Choosing boring is not a technology recommendation, it's an instruction to know yourself.
Why it has held up, concretely
Three things, and none of them are about PHP being good.
One table. The content model collapses to a single post table. Articles, products, columns, tags, stores, versions — all rows in post. The kernel doesn't know what any taxonomy value means; a site invents them freely. Many-to-many relations live in post_post(post_id, child_post_id, weight) and the relation's meaning is inferred from the taxonomy pair at each end rather than stored: (dir, article) is "this article is filed here," (store, product) is "this shop stocks this," (course, teacher) is a faculty list. One-to-one belonging uses post.parent_id. Adding a content type means adding a string and a template with the same name. Adding a relation type means nothing at all.
The direction of travel is the evidence here. Across those rewrites the model didn't accumulate tables, it shed one: there used to be a separate term table for categories, and it was folded into post, which is why the kernel still carries a deprecated function alias called list_post_by_term() whose name no longer refers to anything that exists. Nine years of new kinds of site, and the schema got *simpler*. That is the single highest-leverage decision in the whole project.
The database is a file. A site is a directory and a SQLite file. Backing one up is VACUUM INTO. Cloning a site to look at a bug is cp. Moving a site between machines is moving a folder. There is no "and then restore the dump into the new instance and fix the grants." There are many site databases on this one machine, and the operational story for all of them is *there is no operational story*. The SQLite team's own framing of their project is the right way to think about this: "SQLite does not compete with client/server databases. SQLite competes with fopen()." The file-ness is the feature, not a limitation being tolerated.
Rendering and serving are separate. Because every page gets written to disk, the dynamic path's performance stops being load-bearing. I can afford a template engine, a query builder and an interpreted language in the render path precisely because that path runs once per content change, not once per visitor. The sitemap builder for one of my bigger sites walks 350,000 rows in 11 seconds at 8MB peak memory — that's acceptable for a nightly cron and would be unacceptable per request, and separating the two is what lets me not care.
Also: the output is files. If every piece of software I wrote vanished tonight, Nginx would keep serving those sites until someone turned the box off. I don't know how to put a price on that, but it's not nothing.
Now the bill
Here's where an essay that only praises stops being useful. These are real, in order of how much they actually bother me.
SQLite's single writer is a correctness discipline, not a performance footnote
The usual summary — "WAL gives you concurrent reads and writes" — is wrong, and the correction from a commenter named thayne in a 2025 HN thread on SQLite concurrency is the cleanest I've seen: "The WAL doesn't allow multiple parallel writes. It just allows reads to be concurrent with a single write transaction." One writer. Always.
Living with that is not hard, but it is *knowledge*, and the knowledge is not in the API. Two things make it work. You set a busy timeout — this CMS opens every connection with PRAGMA journal_mode = WAL and PRAGMA busy_timeout = 5000 — and any transaction that might write has to start with an immediate-mode transaction, taking the write lock at the start rather than discovering halfway through that it can't upgrade. That second rule is the one people trip on. The full-site builder has an explicit batch mode for exactly this reason, and the reasoning sits in the comment above it: run a few hundred thousand addresses as individual write transactions and you grab the write lock a few hundred thousand times, which pushes visitor-path writes — redirect counters, admin saves — into busy-timeout errors. So the build batches its manifest changes into one an immediate-mode transaction per batch, pauses between batches, and renders page-by-page as before. Nothing about that is in SQLite's interface. It's in a comment, because that's the only place it can be.
The honest verdict on this belongs to a commenter called apitman on that same thread: "The necessity of this sort of tribal knowledge kills a lot of the simplicity of sqlite for me." I think that's fair. The simplicity I'm selling you is partly a simplicity I already paid for.
And if you want the cost of boring in one story, it's Tailscale's. They've used SQLite as their primary database since 2022, one database per shard. In August 2025 their S3 backups caught the first corrupted database; nineteen cases followed over about six months, causing outages into early 2026. The root cause was a race between a write transaction and a WAL reset that could make committed data disappear — a bug that had been in SQLite since 2010. They funded the SQLite maintainers to build a VFS logging layer to find it; the fix shipped in SQLite 3.53.0 in April 2026, where the release note reads, flatly, "Fix the WAL-reset database corruption bug."
Read that twice, because both halves are the point. The boring choice means you may be the one who finds the sixteen-year-old bug, and nobody is coming to find it for you. It also means that when it's found, the fix lands in a project whose stated intent is "to support SQLite through the year 2050," and Tailscale is still running SQLite. I'd take that trade. But note which version of SQLite my PHP links against: 3.40.1, from December 2022. It predates that fix. The race needs a write transaction and a checkpoint colliding across connections, and nineteen databases across Tailscale's whole fleet is a low rate. But it is not zero, and 3.40.1 is on the wrong side of the fix.
And that is the more interesting failure, because it isn't SQLite's. It's mine, and it's a kind of boringness I wasn't paying attention to. Upstream SQLite is at 3.54.1, released 2026-10-16. Debian 12 ships 3.40.1, dated 2022-12-28. That's fourteen minor versions and nearly four years, and PHP links whatever the distro gives it. Taking the distro's build is the same instinct as everything else in this essay — don't hand-roll what the packager already tests — except that it quietly hands the decision about *which* database you run to someone whose priorities are not your priorities. A data-corruption fix with no CVE attached is not obviously their problem. Whether it ever lands in a Debian 12 package is Debian's call, not mine, and I never made a decision about that; I inherited one.
So there is a line inside "choose boring" that I'd now draw differently. Boring your *dependency* means picking something whose failure modes are mapped. Boring your *packaging* means not picking at all. Those are not the same move, they just feel the same at install time, and the first one is the one worth defending. For a component where a bug means silently losing committed data, the version is part of the choice.
The upgrade nothing forces you to do
Every runtime a boring stack sits on eventually goes out of support, and the dates are published years ahead. That is the clearest instance of the thing I argued in the Claude Desktop piece — a workaround that keeps working is just deferred cost — pointed back at me.
The reason it is not an emergency is the same reason it is awkward: on a stack like this the upgrade is genuinely small. There is very little surface that a language minor version can break, because there is very little surface. No framework majors to chase, no build chain to re-derive, no transitive dependency tree where one package pins another three versions back.
Which is exactly why it drifts. Boring technology's one real vice is that it never forces your hand. A stack that breaks every eighteen months has its upgrade schedule written for it by its own fragility. A stack that never breaks has to be maintained against zero pressure, by someone who has decided to, on a date nobody set. That is a discipline problem wearing a technology win's clothes, and the honest version of this essay has to say so.
jQuery and Bootstrap 4: the real cost is not what people think
Everyone expects me to defend jQuery on aesthetics. I'd rather defend the numbers and then concede the actual problem.
The numbers, checked today: jQuery runs on 64.5% of all websites per W3Techs — about 85% of sites whose JavaScript library can be identified at all. jQuery 4.0.0 shipped on 2026-01-17 after nearly two years in beta, dropping IE 10 and older, moving the source to ES modules, cleaning up $.ajax behaviour. Adoption of jQuery 4, as of today: under 0.1% of websites. That gap is the most honest picture of boring technology in the wild that I know of. The maintained version is not the deployed version. Nobody upgrades, because nothing breaks, because that's the whole appeal.
Among developers, Stack Overflow's 2026 survey has jQuery at 19.7% used in the past year and SQLite at 42.1% — the second most-used database behind only Postgres. Interesting on its own: SQLite is not a contrarian position among developers anymore even while it's still treated as one in architecture discussions.
Here is the part I can't argue away. Bootstrap 4 reached end of life on 2023-01-01. Bootstrap's own page says it plainly: neither 3 nor 4 "receives new features, updates, or security fixes." The last release was 4.6.2. There is an XSS CVE from July 2024 affecting Bootstrap 4 that will never be patched upstream, and there's now a paid-support market — HeroDevs, TuxCare, OpenLogic — selling patches for EOL Bootstrap, which is itself the proof that the burden is real. Meanwhile Bootstrap 4 is still roughly twice as deployed as Bootstrap 5.
My exposure on this specific site is small and I want to be exact about why, because "it's fine on my site" is not a transferable argument. On koenlee.org, Bootstrap 4 supplies a grid and a modal on statically built pages; main.css says in its own header comment that nearly everything visual is a deliberate override. There is no user-generated HTML and nearly no user input. An unpatched XSS in a CSS framework needs a path by which an attacker's string reaches the DOM, and on a flat-file blog that path mostly doesn't exist. That reasoning does not survive contact with a CMS where customers type things. If you run EOL Bootstrap on an application with logged-in users, you have an unpatched dependency and no story, and you should go read your own CVE list rather than my essay.
The second cost is social, and a commenter named alnico put it in one line in the HN thread about jQuery 4: "many teams automatically rule out anything that requires jQuery." That is a real tax. Not on me — on anyone who needs other people to want to work on their code.
No type system, and being honest about which bugs that causes
PHP 8 with strict types everywhere, declared parameter and return types, readonly properties and constructor promotion is not 2009 PHP. But it is not a compiler either, and the codebase has the scars to prove where the line falls.
The clearest one is a column called post.info, which at some point held both PHP serialize() output and JSON, so reads had to sniff and handle both. It's deprecated now — everything structured goes into *_json columns, where the kernel encodes and decodes automatically based purely on the column-name suffix, which is a nice convention precisely because it needs no registry of field names. But info is still in there, and the write path still doesn't actually stop you using it, so the rule is "don't" rather than "can't." That is a type-system-shaped hole, and no amount of parameter typing in PHP closes it, because the thing that lost its type was a database column.
Where I'd push back is on the assumption that types would have caught the worst bugs in here. They wouldn't have. On 2026-09-01 a static-publishing refactor silently removed the step that writes a fallback-rendered page to disk; it was restored on 2026-09-28. Four weeks of pages being re-rendered by PHP on every request instead of being served as files, with no error anywhere — the site was *correct* the whole time, just quietly doing the expensive thing. No type checker finds that. It's a missing behaviour, not a wrong type, and the only things that catch it are tests, assertions in the build, and someone looking. Same shape when post.status was renumbered on 2026-10-03 so that published is 1 instead of 2: the risk was never the type, it was every script and every stale admin tab with 2 burned into it, which is why the publish layer now refuses to write 2 at all. That fix is a runtime guard, not a signature.
The real mitigation on offer is static analysis, and the most honest framing of what it buys you comes from a comparison of PHP static analysis against natively typed languages: runtime safety there is a "language guarantee," and for PHP it's high — "if analysis is run and complete." That parenthesis is the entire argument. Matthew Brown, who wrote Psalm, redefines the goal in a way I find more useful than the usual shouting: type safety is "the ability of available language tooling to help avoid type errors," which is a claim about tooling, not a proof about programs.
And this is the place to be blunt rather than clever: there is no PHPStan and no Psalm in this repository. Strict types on every file and not one static analyser over the top of them. That is not a defensible position, it's just the honest one, and it's the clearest thing on this entire list that the boring stack doesn't excuse. PHPStan's eleven levels exist exactly so that this isn't an all-or-nothing adoption — you start at level 0 on an existing codebase and ratchet. The argument for boring technology is that you know its failure modes; declining to run the tool that enumerates them is not boring, it's just unexamined.
Hand-written CSS, and where it would stop working
716 lines and one media query is not a scaling problem; it's a stylesheet. I'm not going to pretend I've discovered something by hand-writing CSS for a personal site. The place this breaks is known and I'd name it rather than wait to be told: global CSS has one namespace, so two pieces of UI that both want .card are in a fight, and the number of possible interferences grows faster than the number of components. Chris Coyier's summary of the hand-rolled fix — naming everything uniquely yourself — is that it's "laborious and error-prone." BEM exists as a symptom of exactly this. At thirty components written by one person, the boring answer wins easily. At three hundred written by eight people, it doesn't, and I wouldn't argue otherwise.
Hiring, which is the argument I have the least answer for
This is the strongest case against me and it has data behind it. Perforce's 2026 PHP Landscape Report, surveying 700-plus open-source users, found hiring and retaining skilled PHP developers is the top concern for PHP team managers — and the shape of the bench is the real story: over half of surveyed PHP users have 15 or more years of PHP experience, and only 15% have five years or less. The New Stack ran a piece with the title that says the whole thing: "Who will maintain the web when PHP's veterans retire?" Stack Overflow's 2026 survey puts PHP at 17.7% of developers.
I have a weak answer and a real one. The weak answer is that the supply of people who will call themselves a PHP developer is enormous. The real answer is that I chose a stack that depends on me, and that is a structural bet, not a technical one. It works because the company is small on purpose and expects to stay that way. If my plan were to hire fifteen engineers next year, the boring stack would be the wrong choice and the essay would be dishonest. The cost here isn't paid in bugs. It's paid in what you can't become.
Where I do reach for new things, and why that isn't a contradiction
The accusation I'd expect is that this is nostalgia dressed as judgement. So here's what's new in the same codebase.
The CMS has an MCP module: /mcp with a full OAuth 2.1 authorization server beside it — mandatory PKCE S256, dynamic client registration per RFC 7591, token revocation per RFC 7009, client identity by metadata document. You point Claude or Cursor at https://{your-domain}/mcp, log in with your site admin account, approve the scopes, and the agent can manage the site. No API keys anywhere. That is about as new as web infrastructure gets right now — MCP itself is barely two years old — and it sits in the same codebase as the jQuery, alongside the work we're doing on a B2B knowledge base built on LLMs and RAG. I am not a man who thinks the web peaked in 2012.
Two details about *how* that module sits in a nine-year-old CMS are the whole point of this essay.
First: the kernel knows nothing about MCP. The module is routed by the same generic rule everything else uses — /{a}/{b}/{c} maps to modules/{a}/{b}.php and a {c}_action() method, and /mcp is just padded out with index. Its OAuth discovery documents are served by the same generic .well-known file feature any site can use. Second: it did not add a table. Authorization grants live in a JSON column on the user row. If MCP turns out to be a dead end — entirely possible; it's two years old — I delete a directory and the grants fall out with it. There is no migration to unwind and no schema that remembers it.
That is my actual rule, and it's narrower than "choose boring technology": be boring in the load-bearing parts, and new at the edges, and design the edges so that deleting them is cheap. The data model, the database, the render path, the language — those move slowly or not at all. MCP, Stripe, an LLM pipeline, a new site type — those go in as modules and JSON columns. The dull core is not a constraint on the new work. It is the thing that makes trying the new work cost one directory.
Which brings back my disagreement with the innovation-token metaphor. Tokens are something you spend. A boring core is something that *pays*. An OAuth 2.1 authorization server is a serious piece of work; it is a much smaller piece of work when the attention it needs is not competing with a build toolchain, a framework major version, and a bundler that broke. Hillel Wayne's reframing is the best short version of this I've seen: choose boring technology and innovative *practices*. The boring part was never meant to be the interesting part.
My favourite evidence that the serious people already know this is htmx — the project most associated with the server-rendered revival. It shipped 4.0.0 on 2026-08-28, and as of today its npm latest tag still points at 2.0.11. They released a major version and declined to make it the default install. That is a project practising "choose boring" against its own new work, in public, and it's a better argument than any blog post about it.
The criticism I think lands
Two of them.
First, I do not get to claim I have no build step. I have a static site builder with an ownership manifest, md5 verification, batch transactions, resume-from-id, adoption of pre-existing files, and a cleanup sweep. There's an HN thread title that exists to puncture people like me: "Dear friend, you have built a Kubernetes." The honest version of my position is not "no build step." It is "a build step with no dependencies nobody chose, that can be read end to end in an afternoon, and that fails in ways its own comments explain." Which is a real difference, and a much smaller claim than the one usually made on my behalf.
Second, Pablo Meier's ceiling argument is correct and I can't refute it, only locate myself inside it. Boring can't outperform. For the plumbing underneath writing and catalogues and quotes, I don't want to outperform — I want to still be here. For the gemstone catalogue, the pricing system, the RAG product, that's where I'd spend. The point of being dull in one place is being able to afford to be interesting somewhere it shows.
What I'd actually do
If you're deciding this for yourself, in order:
- Ask McKinley's first question and mean it: how would you solve the problem at hand without adding anything new? Most of the time there's an answer and it's tedious. Tedious is the signal, not the objection.
- Separate rendering from serving before you optimise anything. Static output makes a slow language and a slow template engine irrelevant, and it's the decision that bought me the freedom to be unfashionable everywhere else.
- Default to SQLite, and learn the two rules the same day.
busy_timeoutset to something non-zero, and an immediate-mode transaction on any transaction that might write. If you can't name both rules, you don't run SQLite in production yet — you're running it until it bites. - Decide whether your write pattern is single-writer-shaped before it matters. Read-heavy content, admin-authored, bursty writes: SQLite is more than enough. Many concurrent writers, or more than one application server needing the same data: use Postgres and stop being clever.
- Don't let the distro pick your database version. A distribution's packaged SQLite can sit years behind upstream. For a component where a bug means silently losing committed data, the version number is part of the choice, and letting a packager make it is the exact spot where "boring" quietly turns into "old."
- Write down your EOL dates somewhere that nags you. Nothing in a boring stack will nag you on its own. Bootstrap 4 went dark on 2023-01-01, and every runtime under you has a date of its own. Those dates exist whether or not anyone has them written down.
- Audit the front end you inherited from yourself. EOL with no user input is a shrug. EOL with logged-in users typing things is an unpatched XSS waiting for a path. Know which one you are and don't reason from the first to the second.
- Put the new thing in at the edge, with no new table. If you can't delete it by deleting a directory, you've adopted it rather than tried it.
- Be honest that "boring" means "boring to me." My boring is PHP and SQLite because of when I learned to work. Yours is probably something else. The instruction isn't "use old things," it's "use the things whose failure modes you can already picture."
After thirty years, the thing I'd want a younger version of me to hear isn't that new technology is bad. It's that the question is never "is this good," it's "am I willing to still understand this in five years." Almost everything fails that question, and almost nothing is harmed by failing it. The handful of things that pass become the floor you stand on while you do the work that's actually yours — which, in my case, has never once been the template engine.
---