· 18 min read · Cloudflare Workers web development tutorial
Cloudflare Workers Tutorial 2026: Astro SSR and Wrangler
A 2026 Cloudflare Workers tutorial from a real site: deploy Astro SSR with Workers Builds, plus wrangler.jsonc, secrets, edge caching and logs.
Cloudflare Workers is Cloudflare's serverless platform: your code runs in Cloudflare's data centers around the world and you never manage a server. The 2026 path looks like this: create a project with npm create cloudflare@latest, or run npx astro add cloudflare in an existing Astro project; keep the configuration in wrangler.jsonc; deploy with npx wrangler deploy, or connect Workers Builds if you want every push to go live automatically; store credentials with wrangler secret put; turn on observability and read logs in the dashboard, or stream them with npx wrangler tail. The wrangler publish command that plenty of older tutorials still use was removed in Wrangler v4.
My site, kelvinwang.info, is Astro SSR running on Workers: Astro 6, @astrojs/cloudflare 13, Wrangler 4. Every config below comes from that repo, with no secret values. Platform behaviour follows the official Cloudflare and Astro docs, checked on 2026-11-03.
At the time of writing, the latest releases on npm are Astro 7.4 and @astrojs/cloudflare 14.4; my site is still on Astro 6 and adapter 13. The main, handle() and cloudflare:workers patterns used below still hold in the v14 docs. For a new project, follow the official docs.
What Workers is and when it fits
Workers runs your code in V8 isolates, lightweight sandboxes that start quickly; a single runtime can run hundreds or thousands of them (How Workers works). Cloudflare lists four kinds of use: front-end static assets, back-end APIs, AI inference, and scheduled and background jobs (Workers overview).
My site uses three of them:
- Public pages (home, articles, about) are generated at build time and served as static assets.
- The signed-in back office, and one section whose every page reads Supabase, are rendered on demand (SSR) by the Worker.
- A second, background Worker,
studio-social, uses Cron Triggers and Queues to publish Threads and X posts.
Services that must run around the clock, such as an n8n server, do not belong in Workers. I covered where those go (a VPS or Containers) in my Zeabur migration guide.
Why I picked Cloudflare and Astro is in Day 1 and Day 2.
What older tutorials get wrong now
wrangler publishis gone. It was deprecated in v3 and removed in v4; usewrangler deploy(v3 to v4 migration, deprecations).node_compatwas removed too, in favour of thenodejs_compatcompatibility flag.- Use
wrangler.jsonc.wrangler.tomlstill works, but Cloudflare recommends JSON for new projects and some newer features only work with a JSON config (Configuration). - Astro's Cloudflare adapter no longer supports Pages. The Astro 6 upgrade notes say Pages support was removed and recommend Workers (Astro Cloudflare adapter).
Astro.locals.runtime.envis gone. Read variables withimport { env } from 'cloudflare:workers'.
I hit the last one myself. On 11 June 2026 my login check still read a variable from locals.runtime.env, which throws; I switched it to cloudflare:workers the same day (commit 75ba367).
Set up the project: Astro and the Cloudflare adapter
For a new project, C3 runs Astro's setup tool and configures Cloudflare:
npm create cloudflare@latest -- my-astro-app --framework=astro
For an existing Astro project, add the adapter:
npx astro add cloudflare
This sets output: 'server', so every page renders in the Worker by default; pages that don't need it opt back into build-time rendering with export const prerender = true (Cloudflare's Astro guide). Astro 6 and later need Node.js 22.12.0 or newer; the default Node version in Workers Builds meets that.
My astro.config.mjs (excerpt, comments lightly edited):
export default defineConfig({
// SSR by default so middleware always runs for signed-in routes.
// Public pages opt back into prerendering with `export const prerender = true`.
output: 'server',
site: 'https://kelvinwang.info',
adapter: cloudflare({ imageService: 'compile' }),
vite: {
// One id per build: the edge-cache key carries it.
define: { __BUILD_ID__: JSON.stringify(Date.now().toString(36)) },
// twitter-text's bare punycode import would otherwise become a Node builtin
// that Cloudflare's runtime cannot load.
resolve: { alias: [{ find: /^punycode$/, replacement: 'punycode/' }] },
},
});
output: 'server'makes the middleware run on every request, which is what guards the back office. Public pages setprerender = trueone by one.imageService: 'compile'transforms images at build time for prerendered pages only; on-demand pages pass images through untouched.__BUILD_ID__goes into the edge-cache key; see the caching section.- The
punycodealias: thetwitter-textpackage importspunycode, which would otherwise resolve to a Node builtin that doesn't load on Workers.
In Astro 6, astro dev and astro preview use Cloudflare's Vite plugin and run inside the real Workers runtime (workerd), so local behaviour is much closer to production. workerd doesn't support CommonJS, though, so some dependencies fail in dev or at build; the Astro docs show how to pre-compile them.
wrangler.jsonc, field by field
This is my site's wrangler.jsonc, minus the three comment lines above env, with one comment reworded and the staging domain replaced by an example. It contains no secrets:
{
"compatibility_date": "2026-06-10",
"compatibility_flags": ["global_fetch_strictly_public"],
"name": "personal-website",
"main": "./src/worker.ts",
"assets": {
"directory": "./dist",
"binding": "ASSETS",
// Pages (not hashed bundles) go through src/worker.ts first for redirects.
"run_worker_first": ["/*", "!/_astro/*", "!/icons/*"]
},
"observability": {
"enabled": true
},
"env": {
"staging": {
"name": "personal-website-staging",
"routes": [{ "pattern": "staging.example.com", "custom_domain": true }],
"observability": { "enabled": true }
}
}
}
name, main and compatibility_date are the three keys a deploy requires. compatibility_date pins the runtime behaviour to a given day; set it to today when you create the project and move it forward when you want newer behaviour (Configuration).
compatibility_flags toggle individual runtime features. With global_fetch_strictly_public, a fetch() to your own zone goes through Cloudflare's front door like any request from the internet. Without it, such requests go straight to the zone's origin, skipping any Worker on that URL and Cloudflare's security settings (Compatibility flags).
main is the Worker entry point. From Astro 6 on, it points either at the adapter's @astrojs/cloudflare/entrypoints/server or at your own entry file that calls handle() from @astrojs/cloudflare/handler (Astro adapter upgrade notes). Cloudflare's Astro guide still shows ./dist/_worker.js/index.js in its manual setup; the two docs differ, and I followed Astro's. Adapter v14 also offers cf() and finalize() from @astrojs/cloudflare/fetch, for people who want to compose routing with astro/fetch in their own entry file (Astro adapter).
I started on the adapter's entry point and switched to my own src/worker.ts on 29 September 2026 (commit 17b926d). Prerendered pages never pass through Astro's middleware, so redirects for old URLs had to happen in the Worker entry. Simplified version below (the full file also adds a noindex header on staging and exports an entrypoint for the background Worker):
import { handle } from '@astrojs/cloudflare/handler';
import { edgeCached, isCacheablePage } from '@/lib/edge-cache';
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const r = redirectFor(url, request.headers.get('cookie'), request.headers.get('accept-language'));
if (!r) return isCacheablePage(url)
? edgeCached(request, ctx, () => handle(request, env, ctx))
: handle(request, env, ctx);
return new Response(null, { status: 301, headers: { location: r.location } });
},
};
assets names the static folder and its binding. run_worker_first defaults to false: a request that matches a static file gets the file without running the Worker. I set it to a list of patterns so everything except the hashed /_astro/* bundles and /icons/* goes through the Worker first, which is what makes the redirects possible. Patterns starting with ! are exclusions and win over the others (Static assets binding).
That has a cost. Static asset requests are free and unlimited, but requests matching run_worker_first always invoke the Worker and are billed as Worker requests; on the Free plan, once you exceed the daily request limit they get a 429 instead of falling back to the static file (Billing and limitations). Keep that list to the paths that really need the Worker first.
observability.enabled turns on Workers Logs; see the logging section.
env.staging is a second environment. Each environment deploys as its own Worker, named <name>-<env> by default (Environments). A route with custom_domain: true makes Cloudflare create the DNS record and certificate for that hostname (Custom Domains). Bindings such as vars are not inherited, so each environment needs its own copy; my background Worker repeats its vars under staging.
With Astro, or any framework built on Cloudflare's Vite plugin, you pick the environment at build time with CLOUDFLARE_ENV; setting it only at wrangler deploy has no effect (Vite plugin: Cloudflare environments). So staging deploys like this:
CLOUDFLARE_ENV=staging npm run build && npx wrangler deploy
npm run build writes a flattened config to dist/server/wrangler.json, and npx wrangler deploy reads that file, not the wrangler.jsonc at the repo root.
Environment variables and secrets: three kinds
My site splits them into three groups:
| Kind | Where it's set | When it's readable | My examples |
|---|---|---|---|
| Build variables | Dashboard: Worker → Settings → Build → Build variables and secrets | At build time only | PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY |
| Runtime variables | vars in wrangler.jsonc |
At runtime, in plain text | The background Worker's STUDIO_FACEBOOK_GRAPH_VERSION |
| Secrets | npx wrangler secret put <KEY> |
At runtime; the value is hidden in the dashboard and Wrangler once set | DEPLOY_HOOK_URL, SUPABASE_SERVICE_ROLE_KEY |
Sources: Workers Builds build variables are not available at runtime, and runtime variables are set separately under Settings → Variables & Secrets (Builds configuration); vars are not encrypted and suit ordinary configuration (Environment variables); secrets are environment variables whose values you can't see after setting them (Secrets).
Why I need build variables at all: article pages are prerendered and read Supabase during the build, and Astro's import.meta.env values are replaced statically at build time (Astro environment variables). The Workers Builds build environment must have both variables (my README records this); without them, article pages can't read Supabase at build time and the build fails.
At runtime, read variables like this:
import { env } from 'cloudflare:workers';
const value = env.SOME_VAR;
Things the docs mention that are easy to miss:
wrangler secret putcreates a new version and deploys it immediately. With gradual deployments, usewrangler versions secret putinstead.- Variables you change in the dashboard are overwritten by the config file on the next
wrangler deploy. Addkeep_vars: trueto keep dashboard edits. - Local secrets go in
.dev.varsor.env, not both; if.dev.varsexists,.envvalues are not loaded. Keep both out of git. My.gitignoreignores every.env*except.env.example. - Run
wrangler typesafter changing the config to regenerate the TypeScript types forenv. I wrapped it asnpm run generate-types.
Deploying: Workers Builds and wrangler deploy
Connect GitHub and deploy on push
For an existing Worker, go to Workers & Pages → your Worker → Settings → Builds → Connect, then pick the repo and branch (Builds). On each push, Workers Builds runs two steps:
- Build command (optional):
npm run buildfor Astro. - Deploy command:
npx wrangler deployby default, using the Wrangler version in yourpackage.json.
The Worker's name in the dashboard must match name in wrangler.jsonc, or the build fails. If preview builds are on, non-production branches run the Preview command instead (npx wrangler preview by default), which produces a preview URL without touching production.
My site has been connected since 29 September 2026: a push to main is a deploy. I wrote that into the repo's CLAUDE.md, along with the manual fallback, so AI coding tools know from the start that pushing means going live. How I write CLAUDE.md is in my Claude Code tutorial.
To check a deploy, I look at the "Workers Builds: personal-website" check run on the GitHub commit; when it fails, the build log is under Deployments → View build history in the dashboard (kept for 90 days). I can't read build logs from the CLI, so to reproduce a failure I build a clean clone with no .env.
On 30 October 2026 a push didn't trigger a build at all; I pushed an empty commit to set it off again (commit 51eeb63).
Deploy Hooks: rebuild without a push
Settings → Builds → Deploy Hooks gives you a URL; a POST to it starts a build of the chosen branch (Deploy Hooks). When a Deploy Hook is configured, my site's article publishing flow calls it so the static article pages are rebuilt.
curl -X POST "https://api.cloudflare.com/client/v4/workers/builds/deploy_hooks/<DEPLOY_HOOK_ID>"
The hook needs no other authentication; the URL itself is the credential. That's why the repo is set up to keep it as the secret DEPLOY_HOOK_URL, not in vars.
Manual deploys with wrangler deploy
The fallback when automatic deploys break:
npm run build && npx wrangler deploy
Two rules for manual deploys:
- Deploy from the latest main. Running
wrangler deployfrom a worktree that is behind main overwrites the newer version that's live;git fetchfirst and build from the tip oforigin/main. - The second Worker doesn't deploy itself. Workers Builds only deploys the Worker connected to the repo. The background Worker is deployed by hand:
npx wrangler deploy --config workers/studio-social/wrangler.jsonc
The site and the background Worker share provider code. Deploy only one and the other keeps running the old code, so deploy both together.
Caching SSR pages at the edge
Workers run in front of Cloudflare's cache (How the Cache works). By default the CDN does not store responses your Worker generates, so every request runs the Worker. If each render queries a database, you have two options:
Cache API (caches.default) |
Workers Cache | |
|---|---|---|
| How to turn it on | cache.match() and cache.put() in your code |
"cache": { "enabled": true } in the config |
| Scope | Only the data center that stored it; no replication | Two tiers: a lower tier near the visitor and an upper tier across the network |
| On a hit | The Worker still runs, it just skips rendering | The Worker doesn't run at all |
| Control | You choose the key and what to store | The response's Cache-Control |
Sources: Cache API, Workers Cache. Workers Cache has no separate price, but once it's on, every request to that Worker is billed at the Workers request rate, including static asset requests and service binding calls that are normally free; cache hits don't use CPU time (Pricing). The docs also say Workers on custom domains have working Cache API operations, while Cache API calls in the dashboard editor and Playground previews have no effect.
I use the Cache API. Here is a simplified version of my site's cache module (types omitted):
/** Seconds a 200 stays in the edge cache. */
export const EDGE_TTL = 900;
/** The header the pages send; browsers always revalidate. */
export const BROWSER_CACHE_CONTROL = 'public, max-age=0';
export async function edgeCached(request, ctx, render, cache = workersCache()) {
if (!cache || request.method !== 'GET') return render();
const url = new URL(request.url);
const key = new Request(`${url.origin}${url.pathname}?build=${BUILD}`);
const hit = await cache.match(key).catch(() => undefined);
if (hit) return withCacheControl(hit, BROWSER_CACHE_CONTROL);
const res = await render();
if (res.status === 200) {
ctx.waitUntil(cache.put(key, withCacheControl(res.clone(), `public, max-age=${EDGE_TTL}`)).catch(() => {}));
}
return res;
}
- Only 200s are stored. A database error (503) or a missing page (404) is never cached; the next request renders again.
- The cached copy lives for 15 minutes, so each data center queries Supabase at most once every 15 minutes for each page. Browsers get
max-age=0and revalidate every time. cache.put()sits insidectx.waitUntil(), so the response goes out first and the cache write finishes in the background.- The key uses only the path; query strings don't change these pages.
The build= part of the key was added on 25 October 2026 (commit f8f3953). Before that, the key was just the URL, so after a deploy the cache kept serving the old HTML, which pointed at the previous build's CSS and JS file names. Those files no longer existed, and pages could render unstyled for up to 15 minutes. Now every build gets a new __BUILD_ID__, and a fresh deploy never reads the old entries.
It also spares me cache purging: cache.delete() only clears the data center it runs in (Cache API, How the Cache works), and a global purge needs other tools. The build id plus a 15-minute TTL is all I rely on.
Cron Triggers and Queues: the background Worker
The studio-social config (excerpt):
{
"name": "studio-social",
"main": "src/index.ts",
"workers_dev": false,
"preview_urls": false,
"triggers": { "crons": ["* * * * *", "0 19 * * *"] },
"services": [{ "binding": "SITE", "service": "personal-website", "entrypoint": "StudioSocialInternal" }],
"queues": {
"consumers": [
{ "queue": "studio-social-publish", "max_batch_size": 1, "max_retries": 3,
"retry_delay": 60, "dead_letter_queue": "studio-social-publish-dlq" }
]
}
}
workers_dev: falseandpreview_urls: false: this Worker has no public URL. It's reached only through the service binding and its schedule.- Cron Triggers run on UTC (Cron Triggers).
0 19 * * *is 3 a.m. in Taiwan, when it runs cleanup and token refresh;* * * * *checks every minute for posts that are due. The Worker needs ascheduled()handler to receive them. - The per-minute run only puts due jobs on a queue. Each message handles one step, retries up to three times, then lands in the dead letter queue.
serviceslets the background Worker call theStudioSocialInternalentrypoint exported by the site Worker without a public URL, which is why the site has to be deployed first.
Logs and debugging
Add "observability": { "enabled": true } to wrangler.jsonc and redeploy, and Workers Logs starts collecting invocation logs, console.log output, errors and uncaught exceptions, all searchable under the Worker's Logs tab (Workers Logs). Newly created Workers have observability on by default. Retention depends on the plan: the docs list 3 days on Free and 7 days on Paid. From 1 December 2026, Workers Logs moves to Cloudflare Observability pricing.
To watch requests live right after a deploy:
npx wrangler tail
Or use Live in the dashboard's Logs view (Real-time logs). Under heavy traffic, real-time logs may switch to sampling.
Running npm run build and astro preview locally inside workerd catches a lot before you ever need the logs. Some differences only show up in the Workers runtime, though, and unit tests under Node miss them. The clearest one I hit:
fetch(url, { redirect: 'error' }) works in Node but throws a TypeError on Workers before any request is sent; the docs state Workers only implements follow and manual (Request). On 8 October 2026 every OAuth exchange on my site failed with status 0 for exactly this reason. The fix was redirect: 'manual' plus an explicit 3xx check (commit 1bca515), and a test that scans the source so redirect: 'error' never comes back.
Pricing and limits
Prices and limits change; the official pages are the reference: Pricing, Limits. As of 2026-11-03:
- The Free plan allows 100,000 requests a day and 10 ms of CPU time per invocation.
- Static asset requests are free and unlimited; only SSR requests and paths matched by
run_worker_firstcount as Worker requests (with Workers Cache off). - On Free, each request can make up to 50 subrequests (outgoing
fetchcalls from the Worker, plus calls to Cloudflare services such as KV, R2 and D1).
On 6 October 2026 I designed the background Worker around the Free limits: each queue message does one step, so every run stays within the subrequest and CPU caps, and its README says to move to Paid if CPU-limit errors appear in the logs. What the whole site costs per year is in Day 4; for the database side, see Supabase pricing.
Common errors
| Symptom | Cause | Fix |
|---|---|---|
wrangler publish doesn't work |
Removed in Wrangler v4 | Use npx wrangler deploy |
| The first Workers Builds run fails because the build can't read data | Build variables missing; runtime variables aren't available during the build | Add them under Settings → Build |
Reading Astro.locals.runtime.env throws |
Removed in Astro 6 | import { env } from 'cloudflare:workers' (happened to me) |
Every fetch with redirect: 'error' fails with status 0 |
Workers doesn't support redirect: 'error' |
Use redirect: 'manual' and check for 3xx (happened to me) |
| Pages lose their styles briefly after a deploy | The edge cache still serves the old build's HTML | Put a build id in the cache key (happened to me) |
| A push doesn't trigger a build | Unknown | Push an empty commit or call a Deploy Hook (happened to me) |
| The build fails over the Worker name | Dashboard name differs from name in wrangler.jsonc |
Make them match |
| The build succeeds but the site is a 404 or blank | assets.directory points at the wrong folder, or no build command ran |
Check the build command and assets.directory |
npm ci says the lockfile is out of sync |
The lockfile came from a newer npm | Set packageManager in package.json |
| A variable set in the dashboard disappears after a deploy | wrangler deploy applies the config file |
Add keep_vars: true, or put the variable in the config |
The last four come from Cloudflare's Troubleshooting builds and Configuration pages.
FAQ
Workers or Pages? For a new Astro project, Workers. Astro's Cloudflare adapter has dropped Pages support, and Cloudflare's Astro guide deploys to Workers too.
Can I still use wrangler.toml?
Yes. Cloudflare recommends wrangler.jsonc for new projects, and some newer features only work with a JSON config.
Is the Free plan enough?
It depends on how many requests actually run the Worker. With Workers Cache off, static pages don't count; SSR pages and paths matched by run_worker_first do. Past the daily limit, requests matched by run_worker_first get a 429 and other Worker requests get Error 1027. Estimate that traffic and compare it with the official Limits page.
Can I use wrangler deploy and Workers Builds together?
Yes. Both create a new version and make it live, and whichever deploys last wins. Before a manual deploy, make sure you're building the latest main.