The runtime decision came before anything else, because it decides what is cheap.

OpenResty is nginx with LuaJIT compiled in and a set of libraries for writing request handlers in Lua. It has been around since about 2011 and is widely deployed as a gateway sitting in front of applications. Building the application in it is unusual.

The property I wanted is that there is no per request startup. Code loads once when the master process initialises, the worker inherits it after the fork, and a request arrives to a world already in memory. There is no framework boot, no autoloader, no plugin discovery. That removes the largest cost I identified last November, and it removes it structurally rather than by caching around it.

The second property is the FFI. LuaJIT can call C directly by declaring the function signature, with no binding layer to write or compile:

local ffi = require("ffi")
ffi.cdef[[
   int sqlite3_open_v2(const char *filename, sqlite3 **ppDb, int flags, const char *zVfs);
]]
local sqlite = ffi.load("sqlite3")

That is the whole binding. The call sites get JIT compiled, so they are faster than going through the Lua C API, and there is no build step.

This matters because the three things a CMS of this shape needs are all C libraries that exist everywhere. SQLite for the index, cmark for Markdown, libyaml for front matter. The Lua ecosystem being thin stops being the problem it looks like when the ecosystem you actually need is C.

Some honest costs.

SQLite is synchronous, and a query blocks the worker's entire event loop for its duration. Reads out of the page cache are tens of microseconds so this is fine in practice, but an unbounded scan is not, and the design has to make unbounded scans impossible rather than unlikely.

There is no inotify integration. Nginx has its own event loop and no Lua API for handing it a foreign file descriptor, so watching the filesystem means polling a non blocking inotify descriptor from a timer.

And LuaJIT is Lua 5.1 with extensions. setfenv exists, which is useful for sandboxing, and various things written for 5.3 do not apply.

There is one more property that is easy to miss. Nginx is a mature web server, so TLS, static files, byte ranges, keepalive, gzip and rate limiting are all already there and correct. A Node or Python CMS reimplements a portion of that in application code. Here the application handles the API and nginx does what it is good at.

I spent a week testing whether this was actually viable before committing to it: loading the four C libraries in a container, checking the SQLite build had FTS5 and the JSON functions, and confirming inotify events propagate through a Docker bind mount on Linux. They do.

Next is the harder part, which is what to borrow from whom, and the places where those borrowings turn out to contradict one another.