Skip to content
hacebo.dev

How to deploy an Astro blog to Cloudflare Workers

Edit page

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

Workers vs Pages

Cloudflare offers two approaches for hosting static sites:

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:

  1. Open Workers & Pages → your Worker
  2. Click SettingsDomains & Routes
  3. Add a custom domain (it must be on a zone managed by Cloudflare)

Cloudflare handles the SSL certificate automatically.

AstroPaper-specific notes

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

StepCommand
Install Wranglerpnpm add -D wrangler
Authenticatepnpm wrangler login
Buildpnpm run build
Deploypnpm run deploy
Check logspnpm wrangler tail

The entire setup fits in one config file and one CLI command. No dashboard clicking required after the initial domain setup.


Edit page
Share this post on:

Previous Post
Building a website for a solo doctor: Astro, Cloudflare Pages, and keeping it simple
Next Post
Hello World