The build with no database
When I moved this site to Astro,
the change that felt best wasn’t in the content or the design. It was in
.upsun/config.yaml — the file that tells the platform what this site is.
It went from describing a small distributed system to describing a folder of
files. This post is that file, annotated.
What it used to say
The WordPress version declared three backing services and an application that needed all of them:
services:
appdb: { type: mariadb:11.4 }
redis: { type: redis:7.2 }
es: { type: elasticsearch:7.10 }
A database for content. Redis for the object cache, because the database couldn’t keep up on its own. Elasticsearch because search across that content needed a real index. And a deploy hook that flushed Redis and ran a sequence of WP-CLI incantations on every deploy to keep the whole thing coherent. None of it was wrong — that’s genuinely what enterprise-shaped WordPress needs. It was just an enormous amount of machinery for a personal site whose content is a few dozen Markdown files.
What it says now
Here is the entire application config, minus nothing:
applications:
app:
source: { root: "app" }
type: "nodejs:22"
build:
flavor: none
hooks:
build: |
set -e
npm ci
npm run build
web:
upstream:
socket_family: tcp
commands:
start: "node server.mjs"
locations:
"/":
root: "dist"
index: ["index.html"]
passthru: true
expires: 10m
scripts: false
"/_astro":
root: "dist/_astro"
passthru: true
expires: 1y
There is no services: block at all. That’s the whole story.
A few lines earn their place:
type: nodejs:22is mostly a build-time runtime. Node runsastro buildwhen I push; at serve time it runs just one tiny thing (more on that below), while every real page is a static file the router hands back directly.build.flavor: nonetells the platform not to guess at a build; thebuildhook owns it.npm cifor a reproducible install from the lockfile, thennpm run build, which isastro check && astro build— the type-check runs before the build, so a broken type fails the deploy instead of shipping.commands.start+passthru: truepoint at a ~15-line Node script,server.mjs, with exactly one job: return the branded 404 page with a real 404 status. nginx serves every file that exists; only genuine misses reach the script. It’s the one process this otherwise-static site can’t shed — because static hosting can’t set a status code on a file it hands back, so a naïvepassthru: /404.htmlwould serve the not-found page with a 200.- The two
expiresare the whole caching strategy. HTML gets a short ten minutes, because it can change when I republish. Everything under/_astro— the hashed CSS, JS, and images — gets a year, because those filenames are content-hashed: if the content changes, the filename changes, so the old one can be cached forever without ever going stale.
Why “no database” is the feature
Strip the services away and something clarifying happens to the operations story. There is no database to back up, migrate, or get breached. No cache to flush, because the artefact is already the final HTML. No search cluster to keep in sync, because search over a few dozen files doesn’t need one. The attack surface is a static file server.
The workflow collapses to primitives I already trust:
- Deploy is
git push. The build hook runs, and if it’s green the new files go live. - Rollback is
git revert. There’s no schema to migrate backwards, no data to reconcile — the previous commit built a previous set of files, and you just build them again. - Staging is any branch. The platform builds each one into a full environment with its own URL — no database to clone, so a preview environment is just… the same build, pointed at a branch.
The build itself takes about half a second to produce every page on the site. Every page is prebuilt, so there’s nothing to render on the fly — the only thing that ever “runs” is the little 404 fallback, and only when someone asks for a page that isn’t there.
The honest caveat
“Almost no server” doesn’t mean no constraints — it means the constraints move. The 404 handler is the honest asterisk: this platform can’t set a 404 status on a purely static file, so a genuine not-found response costs one tiny always-on process. And a contact form now needs an external endpoint, because there’s no PHP to receive a POST. Anything genuinely dynamic — per-user content, a comment system — would need a service you’d add back deliberately, or a third party. For a single-author professional site, that trade is a bargain: I gave up capabilities I wasn’t using to delete an entire category of things that can break at 3am.
The best infrastructure diff is the one that removes infrastructure. This site’s config now fits on a screen, describes no moving parts, and hasn’t needed a single out-of-hours thought since it shipped. That’s the whole pitch.