{"id":51779315,"url":"https://github.com/boomzero/ftle","last_synced_at":"2026-07-20T10:02:22.765Z","repository":{"id":370997877,"uuid":"1294676057","full_name":"boomzero/ftle","owner":"boomzero","description":"A free-to-deploy, dynamically editable blog engine on Cloudflare Workers + D1 — 0 bytes of JS, ≤14KB pages, faster and more secure than WordPress.","archived":false,"fork":false,"pushed_at":"2026-07-12T14:56:44.000Z","size":380,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-07-12T16:13:39.442Z","etag":null,"topics":["blog","blog-engine","cloudflare-d1","cloudflare-workers","edge-computing","hono","markdown","self-hosted","serverless","typescript","wordpress-alternative"],"latest_commit_sha":null,"homepage":"https://boomzero.uk","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/boomzero.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null}},"created_at":"2026-07-09T05:41:08.000Z","updated_at":"2026-07-12T14:56:48.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/boomzero/ftle","commit_stats":null,"previous_names":["boomzero/ftle"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/boomzero/ftle","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/boomzero%2Fftle","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/boomzero%2Fftle/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/boomzero%2Fftle/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/boomzero%2Fftle/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/boomzero","download_url":"https://codeload.github.com/boomzero/ftle/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/boomzero%2Fftle/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35682457,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-07-20T02:08:10.276Z","status":"ssl_error","status_checked_at":"2026-07-20T02:08:09.736Z","response_time":111,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["blog","blog-engine","cloudflare-d1","cloudflare-workers","edge-computing","hono","markdown","self-hosted","serverless","typescript","wordpress-alternative"],"created_at":"2026-07-20T10:02:21.741Z","updated_at":"2026-07-20T10:02:22.755Z","avatar_url":"https://github.com/boomzero.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# ftle\n\n[![CI](https://github.com/boomzero/ftle/actions/workflows/ci.yml/badge.svg)](https://github.com/boomzero/ftle/actions/workflows/ci.yml)\n[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)\n\nA dynamically-editable blog engine that runs entirely on Cloudflare's free tier — no server to patch, no database to babysit, no monthly hosting bill.\n\nMost self-hosted blog engines (WordPress and friends) trade you a web-based editor for a PHP server, a MySQL instance, plugin security patches, and a hosting bill. ftle keeps the part that's actually useful — write and publish from a browser, from anywhere — and throws away the rest: it's a single Cloudflare Worker and a D1 (SQLite) database, deployed with one command, with nothing to patch because there's no server process to compromise.\n\n- **Free to run.** Fits comfortably in Cloudflare's free Workers + D1 tier. Edge cache hits skip the Worker and the database entirely — no CPU time billed, no D1 read — so only a cache miss (a new post, or the first reader after a purge) touches either. Saving a post purges just the affected cache tags, so edits still show up immediately; there's no stale-cache tradeoff. (Cache hits still count toward the Free plan's 100,000 requests/day quota — this isn't literally unlimited traffic, just cheap traffic.)\n- **Fast.** Reader-facing pages ship **0 bytes of JavaScript** and **≤ 14KB compressed HTML**, served from Cloudflare's edge cache. A regression test enforces this budget on every commit — see [Performance budget](#performance-budget).\n- **Small attack surface.** No PHP, no plugin ecosystem, no database credentials to leak. The admin panel is gated by [Cloudflare Access](#cloudflare-access-setup-admin-auth) — Cloudflare verifies your identity before a request ever reaches the Worker.\n- **Edit from anywhere.** A web-based Markdown editor with live LaTeX math preview — no local tooling, no build step, no static-site regeneration.\n\n## Features\n\n- Markdown posts with raw HTML passthrough and server-side [KaTeX](https://katex.org) math rendering (`$inline$` and `$$display$$`)\n- Draft / unlisted / listed post visibility\n- Image uploads from the editor — button or paste — via a pluggable external host (see [Image uploads](#image-uploads))\n- Tags, an Atom feed (`/rss.xml`), `sitemap.xml`, and `robots.txt`\n- OpenGraph, Twitter Card, and JSON-LD `BlogPosting` metadata on every post\n- Dark-mode-aware styling with Tailwind, inlined into each page (no external stylesheet)\n- Cache-tag-based CDN invalidation — edits go live immediately, not after a TTL\n\n## Quickstart\n\n### Option A: Deploy to Cloudflare button\n\n[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/boomzero/ftle)\n\nThis clones the repo into your own GitHub account, provisions a D1 database, and deploys the Worker in a few clicks. The `deploy` script (`package.json`) runs `wrangler d1 migrations apply DB --remote` before `wrangler deploy`, so the database schema is applied automatically as part of that same build — nothing extra to run.\n\nOne manual step remains before your site is safe to use: **set up Cloudflare Access.** The button can't create a Zero Trust application on your behalf — until you complete [Cloudflare Access setup](#cloudflare-access-setup-admin-auth), `/admin` is either unprotected or (if `ACCESS_AUD`/`ACCESS_TEAM_DOMAIN` are left as placeholders) simply broken. Do this before you publish anything you care about.\n\n### Option B: Manual setup\n\n1. `npm install`\n2. Create the D1 database: `npx wrangler d1 create ftle` — copy the returned `database_id` into `wrangler.jsonc`'s `d1_databases[0].database_id`.\n3. Apply migrations locally: `npm run migrate:local`\n4. Set the Worker vars in `wrangler.jsonc` (`vars`) — see [Configuration](#configuration) below.\n5. Generate types: `npx wrangler types`\n6. Generate the self-hosted KaTeX assets: `npm run prepare:katex`\n7. `npm run dev` to try it locally, then see [Deploying](#deploying) when you're ready to go live.\n\n## Configuration\n\nAll configuration lives in `wrangler.jsonc`'s `vars` block — no secrets, no `.env` file required.\n\n| Var | Purpose |\n|---|---|\n| `SITE_URL` | Canonical origin, e.g. `https://example.com` — used to build absolute URLs, RSS, and sitemap entries |\n| `SITE_TITLE` | Site name, shown in the nav and page titles |\n| `SITE_DESCRIPTION` | Default meta description |\n| `SITE_AUTHOR` | Author name, used in feed/JSON-LD metadata |\n| `SITE_NAV_LINKS` | Optional extra nav links, as `Label\\|URL` pairs separated by commas, e.g. `Twig\\|https://twig.example.com,Sinv\\|https://sinv.example.com`. Leave empty for no extra links. |\n| `IMAGE_UPLOAD_URL` | Base URL of the image-hosting service the editor uploads to — see [Image uploads](#image-uploads). Defaults to `https://image.langningchen.com`. |\n| `ACCESS_TEAM_DOMAIN` | Your Cloudflare Access team domain — see below |\n| `ACCESS_AUD` | Your Access application's AUD tag — see below |\n\n## Image uploads\n\nThe admin editor can upload images straight from the browser — via an \"Insert image\" button, or by pasting a screenshot directly into the source textarea. ftle has no built-in object storage (no R2, no KV), so uploads go straight from the browser to an external image-hosting service configured via `IMAGE_UPLOAD_URL`.\n\nThe default, `https://image.langningchen.com`, is a hosted instance of [langningchen/Image](https://github.com/langningchen/Image) (GPL-3.0) — a small Cloudflare Worker that stores uploaded images in a private GitHub repo and serves them back over HTTP. ftle uses it purely as a hosted HTTP API (no code from that project is vendored into this repo); many thanks to its author for letting ftle's editor use it, credited next to the upload button in the admin UI.\n\nUploaded images live on that external host indefinitely — deleting a post, or removing an image reference from its source, does not delete the image from the host. If you'd rather not depend on someone else's instance, point `IMAGE_UPLOAD_URL` at your own deployment of [langningchen/Image](https://github.com/langningchen/Image) (or a compatible host exposing the same `POST /upload` / `GET /:id` API).\n\n## Cloudflare Access setup (admin auth)\n\n`/admin*` isn't protected by a username/password login — it's protected by [Cloudflare Access](https://developers.cloudflare.com/cloudflare-one/applications/), which sits in front of the Worker and only lets a request through after Cloudflare itself has verified your identity. The Worker additionally verifies the `Cf-Access-Jwt-Assertion` JWT in-process via [`jose`](https://github.com/panva/jose) against Access's public keys (`src/auth/access.ts`) as defense in depth, but Access is the actual gate.\n\n1. **Attach your domain to the Worker first.** Your domain needs to already be on Cloudflare (proxied) before it'll show up as an option below — see step 2 of [Deploying](#deploying). Access sits in front of the existing route; it doesn't create one.\n2. **Create the application.** In the Cloudflare dashboard, go to **Zero Trust → Access controls → Applications → Add an application → Self-hosted**. This is a self-hosted, DNS-routed app (not a \"Private\" app requiring the WARP client) — visitors reach it through normal HTTPS. Under **Add public hostname**, pick your domain and set the path to `/admin*` so the policy covers the whole admin panel.\n3. **Add an Allow policy restricted to your email.** On the same screen, add a policy with **Action: Allow** and an **Include** rule of type **Emails**, with your email address as the value. Use the exact-match **Emails** selector, not **Emails ending in** a domain — the latter would let anyone with an email at that domain request a login code.\n4. **Leave One-Time PIN as the login method** (it's on by default) unless you already have an identity provider configured — no extra signup service is required for a single-author blog.\n5. **Save the application**, then find its **AUD tag**: back in **Access controls → Applications**, select your app, open **Configure**, and copy the **Application Audience (AUD) Tag** from the Overview/Additional settings panel. Paste it into `wrangler.jsonc`'s `ACCESS_AUD`.\n6. **Find your team domain**: **Zero Trust → Settings → Custom Pages** (or **General**) shows your **Team name and domain**, in the form `https://\u003cyour-team\u003e.cloudflareaccess.com`. Paste it into `ACCESS_TEAM_DOMAIN`.\n\n   If you deployed via the button and don't have `wrangler` set up locally, you don't need to edit `wrangler.jsonc` at all — set both values as plaintext environment variables instead, in **Workers \u0026 Pages → your Worker → Settings → Variables and Secrets**.\n7. Redeploy (or just apply the new vars with `npx wrangler deploy`). Visiting `/admin` should now redirect you through a Cloudflare-hosted login page before the Worker ever sees the request. If you set the vars via the dashboard instead, saving them there applies immediately — no separate deploy step needed.\n\nNo other secrets are required — there's no client secret, API token, or session cookie for the Worker to manage.\n\n## Architecture\n\nOne Cloudflare Worker, one D1 (SQLite) database. No KV, no R2, no queues, no build step for content.\n\nPosts render **at write time**, not read time: saving a post runs Markdown ([`marked`](https://github.com/markedjs/marked), with raw HTML passed through) and KaTeX server-side once, storing both the original `source` and the pre-rendered HTML in D1. The read path is then just: edge cache hit → serve; miss → one indexed D1 query → wrap in the layout template → serve and populate the cache. Nothing is ever rendered on a reader's request.\n\nThe single trusted author is the security model for content: HTML is intentionally *not* sanitized (you're the only one who can save a post, and that's gated by Cloudflare Access), which is what lets raw HTML and math pass through untouched without a client-side sanitizer or extra request.\n\n## Performance budget\n\nEnforced by a regression test (`tests/perf/page-weight.test.ts`), not just a guideline:\n\n| Metric | Budget |\n|---|---|\n| JavaScript on reader-facing pages | 0 bytes |\n| Blocking external requests | 0 — CSS is inlined into the HTML |\n| Typical post page, compressed | ≤ 14KB |\n\nAdmin pages are exempt — they may use minimal JS for the editor.\n\n\u003cimg src=\"docs/images/lighthouse-performance-100.png\" alt=\"Lighthouse Performance score of 100, with First Contentful Paint 0.2s, Largest Contentful Paint 0.4s, Total Blocking Time 0ms, Cumulative Layout Shift 0, Speed Index 0.7s\" width=\"700\"\u003e\n\n*A Lighthouse audit of a post page on a live deployment, captured 2026-07-12. Your own numbers will vary with content and network conditions — [run your own audit](https://pagespeed.web.dev) to check.*\n\n## Commands\n\n```sh\nnpm test               # full suite (vitest + @cloudflare/vitest-pool-workers)\nnpm run dev             # wrangler dev with local D1\nnpm run typecheck       # tsc --noEmit\nnpm run migrate:local   # apply D1 migrations to local dev DB\nnpm run migrate:remote  # apply D1 migrations to the deployed DB\nnpm run deploy           # apply pending remote migrations, then wrangler deploy\n```\n\n## Deploying\n\n1. `npm run deploy` — applies any pending remote D1 migrations, then deploys the Worker.\n2. In the Cloudflare dashboard, attach your domain to the Worker and confirm the Access application from [setup](#cloudflare-access-setup-admin-auth) covers `/admin*` on that domain.\n\nDeploying requires Wrangler ≥ 4.69.0. No cache-purge secrets are needed — this project uses Cloudflare's native Workers Caching (`\"cache\": { \"enabled\": true }` in `wrangler.jsonc`), with `ctx.cache.purge()` called in-process on save/delete/rerender via cache-tag-based invalidation.\n\n## Known limitations\n\nExplicitly out of scope for v1: comments, search, and multi-author support. KaTeX assets are self-hosted but not glyph-subsetted yet (loaded only on pages containing math, so this doesn't affect the 14KB budget on pages without it).\n\n## License\n\n[MIT](LICENSE)\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fboomzero%2Fftle","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fboomzero%2Fftle","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fboomzero%2Fftle/lists"}