Caissa Codex (https://chesscodex.org) is a free encyclopedia of chess openings: 3,690 named lines, each with its own page, plus pages for every ECO code and a few rankings, about 4,000 URLs in total. It runs on a Raspberry Pi 5 with 1 GB of RAM in my home. Here is what makes that work.
Boring stack, on purpose
PHP 8.4 and SQLite behind nginx and PHP-FPM. No framework, no build step, no Node on the server. The front controller is one file with a route table, the views are plain PHP templates, and a small PHP script minifies the CSS and JavaScript. A page is a few prepared SQLite queries and a template, which a Pi handles easily.
Let the edge do the work
Anonymous pages send Cache-Control: public, s-maxage=600, and Cloudflare keeps them for 10 minutes. Most requests never reach the Pi, and PageSpeed scores stay around 100.
No open ports
The Pi is reachable only through a Cloudflare Tunnel: the router has no port forwarding, and SSH goes through Cloudflare Access with keys only.
Living with a rate-limited API
The statistics come from the Lichess opening explorer, which in practice allows about 20 requests a minute. So the site never fans out requests: the numbers are cached in SQLite and refreshed in the background one request at a time, and bulk data (results by rating band for the 1,500 most-played lines) is collected by slow command-line jobs.
Stockfish on the Pi
Every line's final position was evaluated by Stockfish 19 at depth 30 on the same Pi, and the results sit in a table next to the statistics. That makes comparisons like this possible: Stockfish gives White +2.03 after the Stafford Gambit, yet Black scores 50.8% in 4.6 million Lichess games.
Security basics
A strict Content Security Policy without inline scripts (all JavaScript lives in files), escaped output everywhere, and prepared statements for every query.
Top comments (2)
We run the same tunnel, no-open-ports shape on a bigger box, and the never-fan-out rule is the part people skip — one request at a time against a rate-limited upstream beats the burst-and-429 cycle every time. What does a cold page cost you on 1 GB — is the SQLite cache small enough to stay page-cached, or do the first hits after a restart hit disk?
Thanks. On 1 GB it turned out to be a non-issue. The whole SQLite file is 9.5 MB, and with about 650 MB available the kernel keeps all of it in page cache: this morning, after 12 hours of uptime, all 592 pages were resident, with default cache_size and no mmap.
I timed it on the box with curl straight to nginx, so Cloudflare wasn't in the way. A warm opening page takes about 7 ms to first byte, or 14 ms if PHP-FPM has just reaped its idle worker (ondemand, 30 s timeout). Then I evicted the DB from page cache with dd iflag=nocache. The next page took 107 ms, and a page of a different type right after it was back to 7 ms.
So yes, the first hits after a restart read from the SD card, but it costs about 100 ms once. Most visitors don't reach the box anyway, since anonymous pages sit in Cloudflare's edge cache for 10 minutes. I haven't measured a cold opcache after a full restart; that would add PHP compile time on top.