8 MIN READ · Pedro Thomaz

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.

A Headless CMS With No Build Step: PHP + Cockpit, Server-Rendered

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

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:

  1. Minify. We regenerate assets/css/main.min.css with rcssmin and assets/js/*.min.js with 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.
  2. FTP mirror. lftp mirrors the working directory to /www/ on OVH, additive only (no --delete), excluding storage/, the cockpit/ install, secrets, .git/, and node_modules. The PHP files you wrote are the PHP files that run. There is no transpilation in between to debug.
  3. Cloudflare purge. A purge_everything API 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:

Where SQLite is the wrong tool — be honest about this:

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:

Reach for Next.js or Astro instead when:

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:

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.