A Headless CMS With No Build Step: PHP + Cockpit, Server-Rendered
You can run a headless CMS without a JS build step. Here's how we pair Cockpit v2 with server-rendered PHP, FTP deploys, and zero npm pipeline.
You can run a headless CMS without a JavaScript build step. Pair a content API like Cockpit v2 with server-rendered PHP, and the editing experience stays decoupled and modern while the front end ships as plain HTML — no bundler, no node_modules on the server, no rebuild on every content change. This is the stack that runs the site you're reading, and below is exactly how it's wired, where it wins, and where it would be the wrong call.
What "headless CMS without a build step" actually means
A headless CMS stores content and exposes it over an API, with no opinion about how you render it. Cockpit CMS v2 is a self-hosted PHP headless CMS: you define collections (think "journal", "portfolio", "team") as JSON models, edit entries in its admin UI, and read them back over a REST API. The "head" — the thing that turns content into pages — is entirely yours.
"No build step" is the part people get wrong. It does not mean no tooling at all. It means no compilation between your source code and what the server runs. There is no bundler transforming .jsx or .ts into deployable artifacts, no framework that demands npm run build before a single page exists, no dist/ directory that is the real source of truth. Our PHP files are the deployable. The browser gets server-rendered HTML on the first byte.
Concretely, the front end here is PHP 8.3 on OVH shared hosting, behind Cloudflare. A request hits router.php, which dispatches to a page like journal-post.php, which calls Cockpit's API, gets back JSON, and echoes HTML. That's the whole pipeline. There is no React, no hydration, no client-side router.
The short version
- Editing: Cockpit v2 admin (self-hosted PHP, SQLite database).
- Rendering: server-side PHP templates that fetch JSON from Cockpit's REST API per request, with a short edge/disk cache.
- Build step: none for content. The only "build" is minifying CSS and JS — and even that is optional and runs locally, not on the server.
- Deploy:
./deploy.sh— minify, FTP mirror to OVH, purge Cloudflare. No CI runner required. - i18n: per-field translations stored in Cockpit (
title,title_pt,title_es), served via URL prefixes (/pt/,/es/).
Why a PHP headless CMS instead of an SSG
The default 2020s answer to "headless CMS front end" is a static site generator — Next.js, Astro, Gatsby — pulling from the API at build time and emitting static files. That's a genuinely good architecture. We reach for it on plenty of projects. But it carries a tax that nobody mentions in the tutorial.
An SSG couples publishing to a build. Edit a typo in the CMS, and the page doesn't change until a build runs and redeploys. To hide that latency you wire up webhooks, incremental static regeneration, a build queue, a hosting platform that runs the builds, and a cache-invalidation story for when ISR gets stale. None of it is hard in isolation. All of it is moving parts that can break at 11pm on a Friday, and most of it exists to paper over the fact that you turned a content edit into a software deployment.
Server-rendered PHP collapses that. Content is data, fetched at request time. An editor saves in Cockpit, the change is live on the next request (or the next cache expiry — seconds, not a build). There is no webhook, no build minute, no regeneration job. The "deploy" only happens when code changes, which on a marketing site or studio journal is dramatically less often than content changes.
This is the core thesis: for content-driven sites where editors publish more often than developers deploy, decoupling content from the build is worth more than the raw performance of pre-rendered static files — especially once an edge CDN is doing the heavy lifting on cache anyway.
The deploy story: FTP, minify, purge
Because there's no build artifact, the deploy is almost insultingly simple. The whole thing is one script:
./deploy.sh # minify → FTP mirror → Cloudflare purge
Three steps:
- Minify. We regenerate
assets/css/main.min.csswith rcssmin andassets/js/*.min.jswith terser — but only if the source is newer than the output. This is the only "compilation" in the system, it's optional, and it runs on the developer's machine. The server never sees a toolchain. - FTP mirror.
lftpmirrors the working directory to/www/on OVH, additive only (no--delete), excludingstorage/, thecockpit/install, secrets,.git/, andnode_modules. The PHP files you wrote are the PHP files that run. There is no transpilation in between to debug. - Cloudflare purge. A
purge_everythingAPI call busts the edge cache so the new code is live globally in seconds.
We run ./deploy.sh --dry-run first whenever the exclude list changed, so lftp prints what it would upload before anything moves. No CI/CD platform, no build minutes, no YAML pipeline. This script is the CI/CD. A junior could read it top to bottom in two minutes and understand the entire path from laptop to production — which is itself a reliability feature.
SQLite as the CMS database: the honest tradeoffs
Cockpit v2 defaults to SQLite, a single file on disk, and we keep it that way. This surprises people, so let's be precise about when it's right and when it isn't.
Where SQLite wins for a headless CMS:
- No database server to operate. No connection pools, no separate process to monitor, no credentials to rotate, no managed-Postgres bill. The database is a file you can back up by copying it.
- It's fast for reads. A studio journal or marketing site is overwhelmingly read traffic against a dataset measured in megabytes. SQLite reads from local disk are faster than a network round-trip to a database server.
- Backups are trivial. Snapshot the file. Restore by replacing it. Version it if you want.
Where SQLite is the wrong tool — be honest about this:
- Write concurrency. SQLite serializes writes with a database-level lock. For a handful of editors this is a non-issue. For an app with many users writing simultaneously, it's a wall — reach for Postgres or MySQL (Cockpit supports them).
- Horizontal scaling. A file on one server's disk does not scale to a fleet of app servers sharing one database. If you need multiple write nodes, SQLite is over.
- It lives on the same host. On shared hosting, your CMS availability is tied to that host. We mitigate with Cloudflare in front and disciplined backups, but it's a real coupling.
The rule we use: if the content is edited by a small team and read by the world, SQLite is not a compromise — it's the correct, boring, durable choice. If the database is the application (user-generated content, high write fan-out, multi-tenant scale), it isn't.
i18n per field, not per build
This site is trilingual — English, Portuguese (pt-PT), and Spanish — and the i18n model is where the no-build approach really pays off. Translation lives per field, inside the CMS. Cockpit stores localized variants alongside the base field: a journal entry has title, title_pt, title_es and the same for excerpt and body. An editor translates a post in the same admin screen, no code involved.
The front end resolves locale from the URL prefix. /pt/journal/... serves Portuguese, /es/... serves Spanish, bare paths serve English. The PHP template picks the right field at render time — roughly:
$lang = current_locale(); // 'en' | 'pt' | 'es'
$title = $entry['title_' . $lang] ?? $entry['title'];
The payoff: adding a translation never triggers a rebuild. Save the field in Cockpit, the localized page is live on the next request. With an SSG, three languages can mean three times the build surface and a matrix of locale routes regenerated on every change. Here it's one extra field lookup at request time. (We learned the hard way to not auto-redirect visitors to /pt/ or /es/ based on navigator.language — it hijacks the URL people actually asked for. Locale is a choice, not a guess.)
When this stack beats Next.js / Astro — and when it doesn't
Choose server-rendered PHP + headless CMS when:
- Content changes far more often than code, and editors shouldn't wait on a build to publish.
- The site is content-and-marketing shaped: pages, posts, portfolio, structured collections — not a stateful web app.
- You want a deploy a single person fully understands, with no platform lock-in and no build-minute bill.
- You're on inexpensive shared hosting and an edge CDN, and you'd rather not operate a database server or a build runner.
- Long-term maintainability matters more than chasing the framework of the year. PHP from 2024 will run in 2034.
Reach for Next.js or Astro instead when:
- You're building an application — auth, dashboards, real-time, lots of client state. Server-rendered PHP with no JS framework is the wrong tool for rich interactivity.
- The team already lives in React/TypeScript and the component model, type-safety, and ecosystem are net productivity wins for that team.
- You genuinely need pre-rendered static HTML at the absolute edge with zero origin dependency — a docs site, a launch page expecting a traffic spike where you cannot tolerate any origin in the path.
- You want the DX of a typed content layer, image optimization pipelines, and MDX baked in, and you're happy to own the build/deploy machinery that comes with them.
There's no universal winner. The mistake is treating "headless CMS" as a synonym for "JavaScript SSG." Headless just means the content is decoupled from the rendering. PHP rendering that content per request is every bit as "headless" as a Next.js build consuming the same API — it simply moves the work from build time to request time, and on a content site behind a CDN that's frequently the better trade.
The limits, stated plainly
We don't pretend this is free of edges:
- No type safety across the API boundary. A renamed Cockpit field won't error at compile time — it'll render empty. We catch this with null-coalescing fallbacks and a smoke check after deploy, not a type checker.
- Per-request rendering needs a cache. Hitting the CMS API on every uncached request would be wasteful; Cloudflare and a short origin cache do the work an SSG does at build time. You're trading a build cache for an edge cache.
- SQLite ties CMS write availability to one host. Fine for a small editorial team; not fine for a write-heavy multi-user product.
- You own the rendering. No framework gives you routing, i18n, or image handling for free — we wrote those (a few hundred lines of deliberate PHP). That's a feature if you value transparency, a cost if you wanted batteries included.
We build the JavaScript-framework version too — it's the right answer for plenty of client work. But for a content-driven site maintained by a small team, a headless CMS with no build step is faster to ship, cheaper to run, and dramatically calmer to operate. The best build step is frequently the one you didn't add.