All writing

Cloudflare's free plan is where your first project should live

This blog runs on Cloudflare's free plan for R0 a month. What a developer starting out gets there, what they still own, and where the deal stops.

The site you’re reading costs R0 a month to host. It’s a static build served from Cloudflare’s edge, with a strict Content Security Policy (CSP), HSTS, a security.txt, cookieless analytics and email on its own domain. None of that needed a server, and none of it needed a credit card beyond the domain.

If you’re a developer shipping your first real project, or the security person a developer asks for advice, this is my answer: put it on Cloudflare’s free plan. You get a custom domain, TLS and DDoS protection that you would otherwise build and patch yourself on a VPS. Below is how this blog is set up, what went wrong, what you still own, and where the argument stops holding.

A static site on Workers costs nothing to serve

As of September 2026, Cloudflare’s docs say it plainly: “Requests to static assets are free and unlimited.” A Worker script on the free plan gets 100,000 requests a day. An assets-only Worker has no script, so that limit never comes into it.

That is why this site costs nothing to serve. Astro builds every page to a file in dist/, and Cloudflare serves those files. There is no Worker code, no server-side rendering and nothing that runs per request. The config is short enough to read in one go:

wrangler.jsonc
{
"name": "chadnorman-fyi",
"account_id": "<your account id>",
"compatibility_date": "2026-09-23",
"assets": {
"directory": "./dist/",
"html_handling": "auto-trailing-slash",
"not_found_handling": "404-page"
},
"routes": [{ "pattern": "chadnorman.fyi", "custom_domain": true }],
"workers_dev": false,
"preview_urls": true
}

The custom_domain route does more than it looks. When the zone is active on Cloudflare, “Cloudflare will create DNS records and issue necessary certificates on your behalf.” No certbot cron job, no renewal to forget.

The only fixed cost is the .fyi domain, renewed yearly through Cloudflare Registrar, which charges “what is charged by registries and ICANN” with no markup.

What the free plan does for you before you write a line

Here is what came with the zone on the Free plan, and what I would otherwise be running myself:

You get, on Free On a VPS, you would
Universal SSL: certificates issued and renewed by default Run and monitor certbot
“Standard, unmetered DDoS protection (layers 3-7)” Hope, or pay someone
The Cloudflare Free Managed Ruleset for “high-impact and widely exploited vulnerabilities” Install and tune a WAF
Email Routing, which forwards hello@ and security@chadnorman.fyi to my inbox Run a mail server, or pay for a mailbox
Web Analytics, which “does not use any client-side state, such as cookies or localStorage” Self-host Plausible or Umami, or put up a cookie banner
A _headers file for security headers on every asset Edit nginx config

And the part that matters most for a security blog: there is no server for me to patch. No SSH port, no OS, no runtime with a CVE next Tuesday. The attack surface of a static site on someone else’s edge is the build pipeline, the account and the content. That’s a much shorter list than a Linux box.

How this site gets from git to the edge

The pipeline has five steps, all in the repo:

  1. I write a post as an MDX file and open a pull request.
  2. GitHub Actions runs type checks, unit tests, and Playwright with axe for accessibility.
  3. It builds the site. The last step of pnpm build, scripts/csp.ts, hashes every inline script and style in the HTML and writes one CSP into dist/_headers. Markup that would need 'unsafe-inline' fails the build.
  4. Lighthouse runs against the build. A pull request gets a preview URL from wrangler versions upload.
  5. A merge to main runs wrangler deploy with an API token scoped to editing Workers scripts on one account. A daily scheduled run rebuilds main, so a post dated in the future goes live on its day.

The git log tells the rest. The first commit landed at 01:03 SAST on 25 September 2026, and the launch-hardening phase (headers, CSP, security.txt) merged at 03:47 the same morning.

What went wrong

Three things cost me time. None of them are in the getting-started guides.

Automatic analytics cost two Lighthouse points. Web Analytics’ automatic setup “will inject the JS snippet on all pages” at the edge. That beacon loads from a second origin before first paint, and Lighthouse counts it. The live home page sat at 98 on performance while the same build scored 100 on a preview URL, because previews get no injection. The fix was to switch automatic setup off in the dashboard and load Cloudflare’s JS snippet myself, after the load event, only on the production host. Performance went back to 100. The cost is that a visitor who leaves before the page finishes loading isn’t counted. When a live score trails a preview’s, check the live HTML for scripts you didn’t put there.

Some steps are dashboard-only. Wrangler’s OAuth login deploys Workers fine, but its token cannot edit DNS records or zone rules. The www to apex redirect, Email Routing and the Web Analytics setup were all clicks in the dashboard. That’s fine for a one-person blog, but it means part of the site’s config lives outside git. Write those steps down somewhere you will find them in a year.

The headers file has a line limit. Each line in _headers has a 2,000 character limit. A hashed CSP is one long line, and each inline script adds a 50-odd character hash. The line holds about 30 hashes, and this site uses 22 of them. The build script fails loudly past the limit rather than ship a truncated policy, which I would rather find in CI than in a browser console.

The defaults you get, and the ones you still own

Cloudflare handles the transport and the edge. Everything above that is still yours.

  • Your account is the whole estate. Domain, DNS, hosting, email and analytics all hang off one Cloudflare login. Turn on two-factor authentication, with a security key if you have one. If someone takes that account, they take your domain and your email with it.
  • Scope your API tokens. The token in CI should do one job. Cloudflare’s tokens can be limited by permission, by account or zone, by client IP and by expiry. Use the global API key for nothing.
  • Secrets go in wrangler secret put, not in config. Cloudflare’s own docs say “Do not use vars to store sensitive information”. Keep local secrets in .dev.vars and keep that file out of git.
  • Headers and CSP are yours to write. The _headers file applies to static assets only; once you add Worker code, its responses need their own headers. Nobody sets a CSP for you.
  • Pin your dependencies. This site pins every npm package exactly, with no ^, and pins each GitHub Action to a commit SHA. The supply chain is the part of a static site that still runs code, at build time, with your deploy token in reach.

If you go on to build AI agents on Workers AI, read my note on the lethal trifecta before you give one a tool.

Where the free plan stops holding

I would still pick it, but go in knowing where it ends.

Dynamic code has a ceiling. A Worker script on the free plan gets 100,000 requests a day, resetting at midnight UTC. If you route asset requests through your script with run_worker_first, those count too, and past the limit they get a 429 instead of the file. Static stays free; logic doesn’t.

Size limits. A Worker version on the free plan holds 20,000 static files, each up to 25 MiB. A blog will never notice. A site with a large image or video archive will.

Email only comes in. Email Routing forwards mail; it doesn’t send it. Replying from your own domain needs another service.

The free WAF is narrow. The Free Managed Ruleset covers widely exploited vulnerabilities. The fuller Cloudflare Managed Ruleset and the OWASP Core Ruleset are on paid plans.

Lock-in is real, and it’s mostly the domain. Cloudflare Registrar requires Cloudflare’s nameservers: “all domains on Cloudflare Registrar use Cloudflare nameservers”. The site itself is portable, because dist/ is plain files that any static host can serve and the posts are MDX in git. The dashboard-only config is not.

One provider is one point of failure. Registrar, DNS, hosting, email and analytics on one account means one outage or one compromised login takes all of them. For a personal blog I accept that trade. For a business, I’d at least keep the registrar and the mail provider somewhere else.

Ship it this week

Here’s the first step, sized for a weekend:

  1. Build your project to static files, whatever the framework.
  2. Add a wrangler.jsonc with an assets block and a custom_domain route, and run wrangler deploy.
  3. In the dashboard, turn on 2FA for your account, check the Free Managed Ruleset is deployed, and switch on Web Analytics.
  4. Add a _headers file with HSTS, X-Content-Type-Options: nosniff and a CSP, then check the result with your browser’s developer tools.
  5. Give CI a token scoped to one account and one job.

That’s a real, secure, fast site for the price of a domain. Small bill. No server.