The Boring Stack Manifesto

I run Kubernetes for a living. The websites I actually ship for myself run on Flask, SQLite, and HTMX.

That isn’t a betrayal of the day job. It’s the most consistent thing about it. The reason I’m useful to companies running serious infrastructure is that I have a clear sense of what complexity costs - and I refuse to pay it when nothing requires me to.

This is the manifesto for the boring stack: when it wins, why it wins, and the case where you should put it down and pick up something heavier.

The boring stack - one box, one database, one engineer

What “boring” means here

Boring is not “old.” Boring is “I know exactly how this will behave at 3 a.m. on a Sunday, and so does everyone who comes after me.”

A boring stack has three properties:

  • You can run it on one box. One process, one database, one log file. You only earn the right to a distributed system once a single host has stopped being enough, not before.
  • Every component has been in production for at least a decade. The CVEs are known. The footguns are documented. The answers on the internet aren’t AI-generated guesses.
  • It survives staff turnover. A new engineer can read the whole codebase in an afternoon. There is no DSL to learn, no convention to memorize, no internal blog post titled “How we use Kubernetes.”

Notice what isn’t on that list. Performance. Scalability. “Modern.” These matter eventually. They almost never matter on day one, and a surprising number of products never reach the eventually.

Case study: inpedana.com

I run inpedana.com, a live dashboard for Italian rhythmic gymnastics results. Search an athlete’s name, get her full career: every competition, every score, rank distribution, per-apparatus trends, charts going back years. Real users, real traffic, public data.

Here is the entire stack:

inpedana.com architecture diagram

  • Flask + Jinja for the web app. Single Python process. Templates rendered server-side.
  • SQLite for storage, WAL mode. One file on disk. Bounded dataset (Italian gymnastics is not a planet-scale problem). Read-mostly.
  • HTMX for the only interactivity that matters: search-as-you-type, drill-down navigation without a page reload. About 14 KB on the wire.
  • Chart.js loaded from a CDN for the visualizations. No build step. No bundler.
  • gunicorn with sync workers in front of Flask.
  • nginx as the reverse proxy and TLS terminator. The same nginx I already had.
  • systemd units for the web app and for the scraper timer.

That is the whole list. There is no Docker registry. No Kubernetes cluster. No service mesh. No Redis. No message queue. No frontend build. No CI matrix. No staging environment that bears any resemblance to production, because production is a small VM.

Deploys are git pull && systemctl kill -s HUP --kill-whom=main inpedana-web. Rollbacks are git checkout && systemctl kill -s HUP. The whole system fits in one engineer’s head, which matters because there is one engineer.

What would the “modern” version look like? React frontend on Vercel, FastAPI on Cloud Run, managed Postgres, Redis for caching, GitHub Actions for CI, an OpenAPI spec, a TypeScript client. None of that is wrong. None of it solves a problem this product has.

The four pillars

1. Flask (or any small framework) over a microservice cargo cult

A single Python process can serve hundreds of requests per second on a modest VM. For most products, that’s not a ceiling you’re going to hit. If you do, you have a real problem that wants a real solution, not a preemptive one.

The “what if we need to scale” objection is overwhelmingly answered by: you don’t, and if you do, you’ll know, and the fix will be cheaper than the architecture you would have built to avoid it. This is the same argument I made about Kubernetes, one layer up the stack.

Single-file Flask apps are not a sin. They are a feature. When the file gets uncomfortable, split it. You’ll know.

2. SQLite in WAL mode, in production, on purpose

Most engineers were taught that SQLite is “embedded only” or “for tests.” That hasn’t been true for years. With WAL mode enabled, SQLite handles concurrent reads while a writer is active. For read-mostly workloads - which is what most dashboards, content sites, and B2B SaaS admin panels actually are - this is the operationally simplest database that exists.

What you give up:

  • A second machine running your database. (You also give up the ops burden of that machine.)
  • Multi-writer concurrency at high throughput. Almost nobody needs this. Almost everybody thinks they do.
  • Some specific Postgres features (advisory locks, LISTEN/NOTIFY, certain index types). Notice when this actually matters for your product, not in the abstract.

What you gain:

  • Backups are cp inpedana.sqlite inpedana-backup.sqlite. Or sqlite3 .backup if you want to be polite about it.
  • Migrations run in milliseconds, not minutes. There is no connection pool to drain.
  • Tests are instant, because the database is a file you create in the temp directory.
  • Local development is identical to production, because production is a file.

When SQLite is wrong: writes from many writers, a dataset that won’t fit on one disk you’re willing to pay for, a hard requirement for a feature only Postgres or MySQL has. The honest version of “we need Postgres” is “we have a real reason.” Most of the time, you don’t yet.

3. HTMX instead of a SPA

A single-page app is the right answer when your application has substantial client-side state: a design tool, a code editor, a spreadsheet. For everything else, you have a server-rendered application with a few interactive moments. A search box that updates as you type. A tab that swaps in new content. A form that validates without a full reload.

HTMX exists for exactly that. You add hx-get to an element, the server returns an HTML fragment, the page swaps it in. No JSON contract, no state-management library, no bundle. Your “frontend” is the same Jinja templates as your “backend.”

This is not a religious argument. It’s an architectural one. If your interactive surface is “make this part of the page change when the user does X,” you don’t need a SPA. You need a tag attribute and a server route.

If the product later grows a feature that genuinely needs client-side state - say, a live side-by-side athlete comparison with synced charts - you can mount a single Vue or Svelte island on that one view without rewriting anything else. The boring stack accepts contributions; it doesn’t demand a rewrite to accept them.

4. systemd over an orchestrator

For one to a handful of services on one host, systemctl is the orchestrator. Restart policy, dependencies, environment, logs to journalctl, timers for cron-like jobs. It’s all there, it’s documented, and it’s been shipping in every major Linux distribution for over a decade.

You earn an orchestrator when you have more than one host worth orchestrating. Until then, the orchestrator is the operating system.

When boring stops being right

I am not telling you to ship a multinational fintech on a single VM. The boring stack has an honest off-ramp. You start outgrowing it when:

  • Writes from multiple writers become contended. You see SQLite’s database is locked errors that aren’t transient. Move to Postgres.
  • One box stops being enough. Real concurrent user load, not imagined. You’ll know.
  • The dataset stops fitting comfortably on disk. Or you need read replicas in another region for latency.
  • You add a real-time, client-state-heavy feature. A live editor, a collaborative whiteboard, a stateful dashboard with subscriptions. Now you need a SPA, and probably a websocket layer.
  • Your team grows past the size where one person can hold the whole system. This usually triggers component splits whether you want them or not.

The point is not that boring is forever. The point is that boring is the right place to start, and the migration path away from it - when you actually need to migrate - is short, well-documented, and only painful in the parts that are inherently painful regardless of where you started.

The opposite mistake is much worse. Teams that start on Kubernetes-shaped infrastructure for a product with three users spend a year on plumbing they never needed. By the time they figure out whether the product works, they’ve burned the runway that would have let them rebuild properly anyway.

The complexity tax

Here’s the case for boring, distilled.

Every component you add is a piece of infrastructure someone has to learn, secure, patch, monitor, back up, and debug at 2 a.m. The cost is not the box on the architecture diagram. It’s the cumulative attention of every engineer who will ever touch the system.

For a product that has not yet proven itself - which is what most products are, most of the time - that attention is the scarcest resource you have. Spending it on infrastructure that doesn’t make the product better is the most expensive way to fail.

The boring stack is not anti-modern. It’s pro-leverage. You buy the complexity you need when you need it, and not a day before. The engineers I trust most are the ones who can tell the difference, and the loudest signal that they can is that the systems they ship for themselves look nothing like the systems they ship for their employers.

Mine look like inpedana.com. One Python file’s worth of routes. One SQLite file. A handful of systemd units. An nginx config I haven’t touched in years. It serves real users, costs less per month than lunch, and I sleep through the night.

That is what I want for your product, too.

If you’re paying a complexity tax your product doesn’t need, let’s talk.