Ghostlite
A small, Ghost-flavored CMS that runs entirely on Cloudflare's free tier: Pages for hosting, D1 for metadata, R2 for post bodies and media, KV for caching. Pluggable email providers (Resend, Mailgun, SendGrid). Pluggable themes.
Designed to be cloned, set up, and deployed in a few minutes. Designed to be read and modified — no megabyte dependencies, no clever abstractions you have to keep in your head.
What works in v1
- Public blog: list, single post (Markdown rendered), tag pages
- Admin: login, dashboard, post CRUD, publish/unpublish, settings
- Media uploads to R2, served via
/api/media/... - KV cache for rendered post HTML (busted on edit)
- Pluggable email: pick Resend, Mailgun, or SendGrid in the dashboard
- Default theme; the contract supports adding more
What's intentionally not in v1
- Scheduled publishing (cron worker stub is in
workers/cron/, requires Workers Paid) - Newsletter / subscriber list
- Comments
- Theme upload via UI (the loader is ready; the upload flow isn't built)
- Rich-text editor (Markdown textarea is the v1 editor)
- Multi-language i18n
Prerequisites
- Node.js 20+
- pnpm 9+ (
npm i -g pnpm) - Wrangler 3.90+ (
npm i -g wrangler) jqandopenssl(standard on macOS/Linux; install via apt/brew if missing)- A Cloudflare account (free tier is fine)
Setup
git clone <your-fork> ghostlite
cd ghostlite
wrangler login # opens browser, OAuth to your CF account
pnpm run setup # provisions resources, migrates, seeds admin
pnpm run deploy # creates Pages project, pushes secrets, deploys
What pnpm setup does
- Creates the D1 database, R2 bucket, and KV namespace if they
don't already exist on your Cloudflare account.
- Writes the resulting IDs into wrangler.toml and
workers/cron/wrangler.toml (replaces the REPLACE_ME_* placeholders).
- Applies D1 migrations to both the remote database and the local
apps/web/.wrangler/state SQLite file used by pnpm dev.
- Generates
JWT_SECRETandEMAIL_KEKand writes them to.env.local
for local dev. Reused on re-run — never rotated, because rotating EMAIL_KEK would make any encrypted email API key already in D1 unreadable, and rotating JWT_SECRET would invalidate every active session.
- Seeds the admin user into both remote and local D1, so you can log
in immediately at pnpm dev _or_ on the deployed site.
The setup script is idempotent: re-running it won't create duplicates and won't rotate secrets.
What pnpm deploy does
scripts/deploy.sh handles the remote-only steps:
- Creates the Cloudflare Pages project (
ghostlite) on first run. - Pushes
JWT_SECRETandEMAIL_KEKfrom.env.localas Pages secrets,
only if they aren't already set (same no-rotate policy).
- Builds the Next.js app and runs
wrangler pages deploy.
Tearing it all down
pnpm run teardown
scripts/teardown.sh deletes the D1 database, R2 bucket (and contents), and KV namespace, then resets the repo to its pre-setup state: wrangler.toml IDs go back to REPLACE_ME_*, .wrangler/ and apps/web/.wrangler/ caches are removed, and .env.local is deleted. The Cloudflare Pages project itself must be removed from the dashboard manually.
Local development
Two modes, depending on what you're working on:
# Fast UI iteration. Uses local D1/R2/KV emulation via @cloudflare/next-on-pages.
pnpm dev
# Full Workers runtime locally. Slower startup, catches Workers-specific bugs.
pnpm preview
pnpm dev reads JWT_SECRET and EMAIL_KEK from .env.local and uses the local D1 SQLite file at apps/web/.wrangler/state/. Both are populated by pnpm setup.
Project layout
apps/web/ # Next.js app (App Router, edge runtime)
app/
(public)/ # Public blog routes
admin/ # CMS dashboard (auth-gated)
api/ # Route handlers = Pages Functions
lib/
auth.ts # getCurrentUser, requireUser
cf.ts # getEnv() for binding access
db.ts # All SQL — typed, single file
email/ # Pluggable email providers
storage.ts # R2 helpers
password.ts # PBKDF2 via Web Crypto
session.ts # Server-side opaque tokens
crypto.ts # AES-GCM for at-rest secret encryption
markdown.ts # Tiny zero-dep MD renderer
themes/
theme.types.ts # The theme contract
loader.ts # Theme registry + dynamic import
default/ # Built-in default theme
middleware.ts # Sets x-pathname header
workers/cron/ # Paid-tier scheduled-post worker (stub)
migrations/ # D1 SQL migrations
scripts/ # setup.sh, deploy.sh, teardown.sh, seed-admin.mjs, patch-wrangler.mjs
wrangler.toml # Bindings for the web app
Architecture quickref
[Reader] -> Pages Function -> D1 (metadata) + R2 (body)
\-> KV cache (rendered HTML)
[Author] -> /admin --(API)--> D1 / R2 (writes)
[Cron] -> separate Worker --> D1 (promote scheduled posts)
[Email] -> sendEmail() -> Provider HTTP API (Resend/Mailgun/SendGrid)
Adding a theme
A theme is any module that default-exports an object conforming to Theme (see apps/web/themes/theme.types.ts). To add one:
- Create
apps/web/themes/<name>/index.tsand component files. - Default-export a
Themevalue withmanifest,pages, optionallyLayout. - Register it in
apps/web/themes/loader.ts. - In the admin, set
settings.theme.active = "<name>"(CLI for now).
The contract gives themes:
site— title, description, logo URLtheme.config— theme-specific settings- Page-shaped data (posts, tags, etc.)
Themes cannot read env, query the DB, or call internal APIs. This is the isolation that will eventually make user-uploaded themes safe.
Adding an email provider
apps/web/lib/email/providers/<name>.ts — implement EmailProvider:
export class MyProvider implements EmailProvider {
name = "myprovider";
constructor(private apiKey: string) {}
async send(args: SendArgs) { /* fetch() to provider's HTTP API */ }
async verifyConfig() { /* cheap auth-check call */ }
}
Then register it in apps/web/lib/email/index.ts (factory) and apps/web/app/admin/settings/email/EmailSettingsForm.tsx (UI dropdown). About 30–80 LOC per provider.
Note: Cloudflare Workers cannot make raw outbound TCP connections on SMTP ports easily, so we don't ship SMTP support. All providers we support have HTTP APIs that work fine from the edge.
Cloudflare Free vs Paid
Everything in v1 works on Cloudflare's free tier. The Paid plan ($5/mo for Workers Paid) unlocks:
- Reliable Cron Triggers for scheduled publishing (
workers/cron/) - Cloudflare Queues for async fan-out (newsletter, etc.)
- Higher request and CPU ceilings
The web app reads no flag for this — paid features are simply absent until you deploy the cron worker or wire up Queues.
Security notes
- Passwords: PBKDF2-SHA256, 100k iterations, per-user salt, constant-time compare.
- Sessions: opaque random tokens stored server-side in D1; HttpOnly + Secure cookie.
- Email API keys: AES-GCM encrypted at rest in D1 with
EMAIL_KEKfrom Wrangler secrets. - All admin routes require an authenticated session; admin-only routes additionally check role.
If you find a security issue, please open a GitHub issue marked security or email the maintainers privately.
Contributing
The codebase is intentionally small and readable. New features should:
- Stay in
apps/web/unless they need a separate Worker (cron, queue consumers) - Add new SQL queries to
lib/db.ts, never inline in route handlers - Add new email providers behind the existing interface
- Add new themes behind the existing contract
Run pnpm typecheck before committing.
License
MIT.