A self-hosted CMS that fits in one Worker. Push to main, it's live. No server, no Docker, no monthly bill.
Images, database, sessions, cache — everything runs inside Cloudflare's free tier. One git push deploys. Fork it, create a D1 database and a KV namespace, paste two IDs into wrangler.toml, and you're running.
One Worker · D1 (SQLite) · KV · Hono · TypeScript · ~80KB gzipped
- What it is
- Features
- Prerequisites
- Quick start (5 steps)
- Run the onboarding wizard
- Writing content
- Images
- Local development
- Project structure
- Themes
- Security
- Tech stack
- FAQ / Troubleshooting
- License
PHCloud is a CMS for one site — your blog, your portfolio, your small business. You write in a browser, it saves as HTML in D1, and renders through a static theme. Tags, RSS, sitemap, search, SEO — all built in, all inside one Worker.
No build step. No node_modules on the edge. No framework runtime. One hono dependency.
| Feature | What it does |
|---|---|
| Admin panel | Dashboard + full CRUD for posts, pages, tags, navigation, and settings |
| WYSIWYG editor | contentEditable rich-text editor with a formatting toolbar (bold, italic, H2/H3, link, image, blockquote, list). The toolbar buttons reflect the active selection. No markdown parser. |
| Scheduled posts | Publish immediately or schedule for a later datetime; unpublished posts have a private preview link |
| Image upload | Paste or drag an image → resized to ≤1200px and compressed to WebP client-side → stored in D1 → served from /img/:id, browser-cached as immutable |
| Pages | Static pages (About, Contact, Privacy…) alongside posts |
| Tags | Organize posts; browse at /tag/:slug |
| Navigation | Custom header links, editable from the admin |
| Search | Full-text search at /search?q=… |
| RSS feed | Auto-generated at /feed.xml |
| XML sitemap | Auto-generated at /sitemap.xml |
| Themes | One static theme file (src/themes/default.ts) compiled into the Worker; automatic light/dark via prefers-color-scheme plus a header toggle that remembers your choice |
| Onboarding wizard | First-run setup in the browser — creates your admin account and seeds defaults |
| Auth | PBKDF2 password hashing (Web Crypto, 100k iterations), HTTP-only cookies, KV-backed sessions |
| Caching | Posts, settings, and nav cached in KV; image bytes cached in KV for 30 days. Cache is invalidated on publish/edit/delete. |
| Edge security | CSP, HSTS, X-Frame-Options: DENY, nosniff, strict referrer policy, locked-down permissions policy |
Two free accounts, no credit card on either:
- Go to github.com/Stevechaapps/phcloudcms
- Click Fork (top-right), pick your account, keep the
mainbranch, click Create fork
You now have https://github.com/<your-username>/phcloudcms.
D1 is Cloudflare's serverless SQLite — where all content lives.
- Cloudflare Dashboard → D1 SQL database → Create database
- Name it
phcloud-db, leave location hint blank, click Create - Copy the database ID (a UUID). You need it in step 4.
KV holds image bytes, sessions, and the rendered-page cache.
- Cloudflare Dashboard → Workers KV → Create instance
- Name it
phcloud-cache, click Create - Copy the namespace ID. You need it in step 4.
Workers reads bindings from the config file, not the dashboard. Open wrangler.toml in your fork and replace the two IDs with the ones you just created:
[[d1_databases]]
binding = "DB"
database_name = "phcloud-db"
database_id = "<paste your D1 database ID here>"
[[kv_namespaces]]
binding = "CACHE"
id = "<paste your KV namespace ID here>"The repo ships with the original author's D1 and KV IDs pre-filled — those are safe to share (they're just resource identifiers, not secrets). Still, you must overwrite them with your own before deploying. Cloudflare Workers Builds wipes dashboard bindings that aren't in
wrangler.toml, so the file must hold your real IDs.
Commit the change to main on your fork.
Pick one path:
| Workers Builds (auto-deploy) | Wrangler CLI | |
|---|---|---|
| Auto-deploys on push? | Yes | No (manual) |
| Needs the CLI? | No | Yes |
| Best for | Most people | Developers |
Path A — Workers Builds (dashboard, recommended):
- Cloudflare Dashboard → Workers & Pages → Create application → Continue with Github
- Authorize Cloudflare to read your fork if asked, then select your
phcloudcmsrepo - Production branch:
main. Build command: leave blank. Deploy command:npx wrangler deploy. Root: blank. - Click Save and Deploy. Wait for Success (~30–60s).
Path B — Wrangler CLI:
git clone https://github.com/<your-username>/phcloudcms.git
cd phcloudcms
npm install
npx wrangler login # opens a browser to authorize
npx wrangler deployYour site goes live at https://<project-name>.<your-subdomain>.workers.dev.
Add your own custom domain: After setup, go to Workers & Pages → your Worker → Triggers → Custom Domains → Add Custom Domain. Cloudflare handles DNS and SSL automatically.
Want a shorter Worker URL? In Workers & Pages → Change your account subdomain (e.g.
steve), and/or rename the Worker (Settings → Service name) to something short likeph. Shortest:ph.steve.workers.dev.
- Visit your Worker URL
- The Setup Wizard appears on first run (it disappears once the admin account exists). Fill in:
- Site name
- Admin username (defaults to
admin) - Admin password (≥ 8 characters)
- Click Initialize Core Systems
This creates the D1 schema (migrate), seeds defaults, and logs you in. You land on /admin.
- New Post → enter a title, write in the WYSIWYG editor
- Toggle Publish immediately or Schedule for later (pick a datetime)
- Click Save Post
Published posts appear on the homepage immediately (publishing invalidates the cache). Drafts stay private; an unpublished post gets a shareable Preview link in the editor.
Images are stored in D1 — no external bucket, no API key.
Paste an image into the editor (Ctrl+V), or drag one onto it:
- The browser resizes it to ≤1200px wide and re-encodes it to WebP (quality 0.7) on a
<canvas> - The compressed bytes are uploaded as base64 to your Worker, stored in the
imagesD1 table - An
<img src="/img/42">is inserted at the caret /img/:idserves the bytes from KV (cached 30 days) withCache-Control: public, max-age=31536000, immutable
git clone https://github.com/<your-username>/phcloudcms.git
cd phcloudcms
npm install
npm run dev # wrangler dev on http://localhost:8787wrangler dev provisions a local D1 and KV from your wrangler.toml bindings. On first visit the onboarding wizard appears and migrate creates the schema locally — same flow as production.
Scripts:
npm run dev # wrangler dev on http://localhost:8787
npm run build # tsc --noEmit (type-check)
npm run lint # prettier --check src/
npm run lint:fix # prettier --write src/src/
index.ts Worker entrypoint — middleware + route registration (catch-all last)
cms/ framework bits: auth, d1 (migrate), sanitize, escape, render, middleware
admin/ admin UI: shell, dashboard, posts, pages, tags, nav, images, settings, editor, login
routes/ HTTP routes by domain: posts, pages, tags, nav, images, install, wipe, public, settings, auth
themes/ default.ts (single static theme, compiled into the Worker)
Routing lives in src/routes/*; the admin HTML bodies live in src/admin/*; the framework glue (auth, DB, sanitization) lives in src/cms/*. The editor's inline JavaScript is shared in src/admin/editor.ts.
There is a single static theme file at src/themes/default.ts compiled into the Worker.
export const css = `…`;- Light/dark is automatic via
@media (prefers-color-scheme: dark), plus a header toggle (☾/☀) that overrides the OS preference and is remembered inlocalStorage('phcloud-theme'). A small inline script in<head>applies the saved choice before paint so there's no flash of the wrong theme. The admin panel follows the OS scheme too (prefers-color-scheme: dark); the login page is dark in both modes. - Reskin by editing
:rootcolors insrc/themes/default.ts, then commit + push. - Swap by pointing the import in
src/cms/render.tsat a different theme file.
See THEMES.md.
Set globally on every response in src/index.ts:
Content-Security-Policy:default-src 'self'; script-src 'nonce-<random>' 'strict-dynamic'; style-src 'unsafe-inline'; script-src-attr 'unsafe-inline'(CSP nonce is injected into<script>tags via middleware;'unsafe-inline'on styles and inline event handlers since they're first-party admin code, not user content)Strict-Transport-Security:max-age=31536000; includeSubDomainsX-Frame-Options: DENY·X-Content-Type-Options: nosniffReferrer-Policy: strict-origin-when-cross-originPermissions-Policy: camera/microphone/geolocation disabled
Post and page content is run through an allowlist HTML sanitizer (a tiny tokenizer in src/cms/sanitize.ts — no DOM on the Workers runtime) on both write and read. style attributes and data: URLs are not allowed; the editor emits semantic tags via execCommand.
| Component | Technology |
|---|---|
| Platform | Cloudflare Workers |
| Framework | Hono v4.12 |
| Database | D1 (serverless SQLite) |
| Cache / sessions / images | Workers KV |
| Language | TypeScript |
| Auth | PBKDF2 via Web Crypto |
| Entry point | src/index.ts |
One runtime dependency: hono (the router). No markdown parser, no DOMPurify, no client framework — content is authored in a contentEditable editor and sanitized by the built-in allowlist tokenizer.
Your D1 binding is missing or wrong. This happens if you deployed before putting real IDs in wrangler.toml — Workers Builds wipes dashboard bindings that aren't in the config file.
Fix: put your D1 database ID and KV namespace ID into wrangler.toml (step 4), commit to main, and let it redeploy.
Check that wrangler.toml has valid D1 and KV IDs and that both resources still exist in the dashboard. Deleting either will break the Worker.
The published-posts cache lives in KV for ~10 minutes, but publishing/editing/deleting invalidates it immediately (cms:posts:pub, cms:homepage). If you're still seeing stale content, hard-refresh — the browser may be holding the old page.
In the D1 console, or via the admin Settings page.
DELETE FROM admins;
DELETE FROM settings WHERE key = 'status';Then visit the site — the onboarding wizard reappears so you can recreate the admin account.
The admin has a wipe endpoint that drops all content. To fully decommission: delete the Worker, the D1 database, and the KV namespace from their dashboard pages.
MIT — build something awesome with it.
PHCloud CMS — built for the edge, free forever on the Cloudflare free tier.