This is the setup I use to host this blog on Cloudflare Workers. The blog is built on AstroPaper, a minimal Astro theme — but the deployment approach works for any static Astro site. Cloudflare Workers gives you a globally distributed static site with zero cold starts, free SSL, and a straightforward deploy command — all without paying for a VPS or dealing with a CDN separately.
Table of contents
Open Table of contents
Prerequisites
- Node.js 18+ and pnpm
- A Cloudflare account (free tier works)
- Wrangler CLI:
pnpm add -D wrangler
Workers vs Pages
Cloudflare offers two approaches for hosting static sites:
- Pages — connect your GitHub/GitLab repo and Cloudflare builds and deploys on every push. Zero CLI setup required, but once you choose git integration you can’t switch to manual deploys later.
- Workers — you run
wrangler deployyourself, from CI or locally. More explicit, easier to script, and you can extend it with server-side logic without changing platforms.
Cloudflare’s official Astro guide recommends Pages. I use Workers because I prefer controlling deploys from the CLI and keeping the config in code (wrangler.jsonc) rather than the dashboard.
Step 1 — Install Wrangler
pnpm add -D wrangler
Verify the installation:
pnpm wrangler --version
Then log in to your Cloudflare account:
pnpm wrangler login
This opens a browser window to authorize the CLI. The token is stored locally so you only do this once per machine.
Step 2 — Configure wrangler.jsonc
Create a wrangler.jsonc at the root of your project:
{
"name": "your-worker-name",
"compatibility_date": "2026-05-29",
"assets": {
"directory": "./dist",
"not_found_handling": "404-page",
},
}
For a static blog, no main entry point is needed — Cloudflare serves the assets directly. not_found_handling: "404-page" tells Cloudflare to serve dist/404.html for any unmatched route. Replace your-worker-name with the name you want — this becomes the *.workers.dev subdomain on first deploy.
Step 3 — Add the deploy script
In package.json, add a deploy script that runs the full build before deploying:
{
"scripts": {
"build": "astro check && astro build && pagefind --site dist && cp -r dist/pagefind public/",
"deploy": "pnpm run build && wrangler deploy"
}
}
Note the build script includes astro check (TypeScript checking) before the build. The deploy script runs the full build — including Pagefind indexing — before uploading to Cloudflare.
Step 4 — Deploy
pnpm run deploy
The first time you run this, Wrangler creates the Worker in your Cloudflare account and assigns it a *.workers.dev subdomain. Subsequent deploys update the same Worker.
Connecting a custom domain
After the first deploy, go to your Cloudflare dashboard:
- Open Workers & Pages → your Worker
- Click Settings → Domains & Routes
- Add a custom domain (it must be on a zone managed by Cloudflare)
Cloudflare handles the SSL certificate automatically.
AstroPaper-specific notes
Pagefind search
AstroPaper uses Pagefind for client-side search. The build script runs pagefind --site dist after Astro’s build and copies the generated index to public/. Since deploy calls pnpm run build, the search index is always included automatically.
Project screenshots
Astro can optimize local images automatically when you use <Image> from astro:assets with files in src/assets/. For this site’s project screenshots, I tried that route but the output quality wasn’t acceptable at the sizes I needed. Instead, I compress screenshots manually with TinyPNG and place them in public/assets/images/, where they’re served as-is at full resolution. This is specific to high-detail screenshots — for everything else, Astro’s image pipeline is fine.
Dynamic OG images
This blog uses Satori to generate OG images at build time. Since this runs during astro build, no special Cloudflare configuration is needed — the generated .png files land in dist/ and are served as static assets.
Analytics with Umami
This blog uses Umami for privacy-friendly analytics. The setup is a single script tag in Layout.astro, conditionally rendered when PUBLIC_UMAMI_ID is set:
{PUBLIC_UMAMI_ID && (
<>
<script
is:inline
defer
src="https://cloud.umami.is/script.js"
data-website-id={PUBLIC_UMAMI_ID}
/>
<script is:inline>
document.addEventListener("click", e => {
const el = e.target.closest("[data-track-event]");
if (!el) return;
const name = el.getAttribute("data-track-event");
if (!name || !window.umami) return;
const data = {};
for (const attr of el.attributes) {
if (attr.name.startsWith("data-track-event-"))
data[attr.name.slice("data-track-event-".length)] = attr.value;
}
window.umami.track(name, data);
});
</script>
</>
)}
No cookies, no GDPR banner needed.
The Fragment wrapper (<>...</>) is required because two sibling <script> tags live inside the same conditional. Both are gated on PUBLIC_UMAMI_ID so neither loads if analytics is disabled.
The second script uses data-track-event instead of Umami’s native data-umami-event because Umami’s built-in auto-binding calls preventDefault() and re-navigates via location.href, which bypasses Astro’s ClientRouter and kills view transitions. The custom listener calls window.umami.track() directly without intercepting the click.
Environment variables
Astro’s PUBLIC_* variables are build-time — Vite statically replaces them in the output during astro build. This means they must be present in the environment when the build runs, not in Wrangler’s runtime config.
Set them in a .env file locally:
PUBLIC_UMAMI_ID=your-website-id
PUBLIC_GOOGLE_SITE_VERIFICATION=your-token
In CI, set them as environment secrets and expose them to the build step. Wrangler’s vars section has no effect on a purely static site — those bindings are only available to Worker runtime code, which doesn’t exist here.
Summary
| Step | Command |
|---|---|
| Install Wrangler | pnpm add -D wrangler |
| Authenticate | pnpm wrangler login |
| Build | pnpm run build |
| Deploy | pnpm run deploy |
| Check logs | pnpm wrangler tail |
The entire setup fits in one config file and one CLI command. No dashboard clicking required after the initial domain setup.