---
title: "aadhar.sh/garage/off Pages, onto Workers"
description: "Why this site moved from Cloudflare Pages to Workers with static assets: the same bytes, atomic deploys and every binding in one checked-in config."
path: "/garage/workers"
section: "garage"
kind: "content"
updated: "2026-07-23"
source: "https://aadhar.sh/garage/workers"
---

> Site index: https://aadhar.sh/llms.txt
> Section index: https://aadhar.sh/garage/llms.txt
> This is the Markdown twin of a page on aadhar.sh. The HTML at the source
> URL below is the original, and is hand-written and unminified on purpose.

# Off Pages, onto Workers

This site ran on **[Cloudflare Pages][cloudflare-pages]** its whole life: a static `index.html` plus a `_worker.js` in Pages advanced mode. On **2026-06-30** it moved to **[Cloudflare Workers][cloudflare-workers] with static assets**. Same `www/` folder, same worker code, byte-for-byte identical responses (I diffed them before flipping the domain). What changed is everything *around* the bytes: how it deploys, what it can bind, and where its configuration lives.

## Why it moved

An outage forced it. A git-connected Pages build deployed the static files but silently dropped the Function, so every static path answered `200` and every worker route answered `404`. From the outside the site looked up; underneath, half of it was gone. That failure is structural to how the Pages build treated this repo, so rather than paper over it, I moved to Workers, where a deploy is **atomic**: the worker and its assets ship together or not at all. There is no window where the shell is live and the brain is missing.

## The real payoff: configuration as code

On Pages, the project's wiring lived in the dashboard. The [KV namespace][workers-kv], the [R2][r2] bucket, the [D1 database][d1], the [Durable Object][durable-object], the environment variables: all clicked in through a web UI, invisible to git, undocumented outside my own memory, impossible to diff or review. Rebuilding the project meant remembering every toggle I had ever flipped.

On Workers, all of it is one checked-in file, `wrangler.jsonc`. One line needs a primer before you hit it: `run_worker_first` opens with `"/*"`, meaning every request wakes the worker, and each entry starting with `!` carves that path back out to serve straight from the edge:

```
{
  "name": "aadhar-sh",
  "main": ".build/src/worker/index.ts",
  "assets": {
    "directory": ".build/public",
    "binding": "ASSETS",
    "run_worker_first": ["/*", "!/nav.js", "!/garage/*", "!/images/*.avif", ...]
  },
  "kv_namespaces":  [{ "binding": "RN_KV",   "id": "..." }],
  "r2_buckets":     [{ "binding": "PHOTOS_R2", "bucket_name": "aadhar-photos" }],
  "d1_databases":   [{ "binding": "RESTORE_DB", "database_id": "..." }],
  "durable_objects": { "bindings": [{ "name": "COUNTER", "class_name": "Counter" }] },
  "migrations":      [{ "tag": "v1", "new_sqlite_classes": ["Counter"] }]
}
```

Changing a binding is now a code review. A clean checkout plus one `wrangler deploy` reconstructs the entire project. There is exactly one honest exception: **secrets**. The signing key, the render token, and the cache-bust secret stay out of git on purpose, set once with `wrangler secret put`. A checkout is reproducible; by design, it needs those three set by hand before it fully works.

## What it opened up

Given the constraint I care about most here (this site is frameworkless and buildless, and staying that way), the interesting question was what the platform can do once the config is code. Four things I have already used, and a few more now in reach.

## 1 · Granular routing (already shipped)

That `run_worker_first` array is a [denylist][denylist]. The worker sees every request except the paths I negate with `!`, which serve straight from the edge and never wake it. I just cut the **[avif][avif] thumbnails** loose: they are the primary, highest-volume image format (every modern browser takes the avif `<source>`), so serving them edge-direct drops a worker invocation off the busiest request on the site.

I proved it with `wrangler tail`: fetched one avif and one jpg with unique query strings and watched the live log. The avif logged **zero** invocations; the jpg logged one. The jpg deliberately stays worker-first, because `/images/*.jpg` would deep-match the R2 originals at `/images/full/*.jpg` and starve that route. avif has no such twin, so it is a clean cut. You can watch the same split live in the prober below.

## 2 · Durable Objects, in-house (already shipped)

Pages cannot host a Durable Object at all. The homepage visit counter (the little odometer at the bottom of the front page) had to reach *across* to a separate Worker, `cf-garage`, and borrow its `Counter` class by cross-script binding. On Workers the class lives right here, in `src/worker/counter.ts`, exported from the entry, with its migration in the same config. To keep the number honest across the move, the new object self-seeds once from the count it had on the old one (about 2,375) instead of resetting to zero. Same odometer, now wired into its own dashboard.

```
export class Counter { async fetch(req){ /* atomic ++ in state.storage */ } }
```

## 3 · Atomic deploys and versions (relying on it)

A Worker deploy is all-or-nothing, and that is precisely what makes the avif cut safe. The old Pages hazard was a deploy race: a real thumbnail could 404 for a blink during a deploy, and Cloudflare's edge would then cache that 404 for hours. Atomic deploys remove the blink, so a file that actually exists can't 404 mid-deploy. Workers also supports versioned, [gradual rollouts][gradual-deployments] (ship a version to 10% of traffic first), which Pages never offered for production.

## 4 · Observability and tail (already using it)

Structured logs are one line in the config (`"observability": { "enabled": true }`), and `wrangler tail` streams the live worker to my terminal. That is how I verified the routing change against production without guessing, and how I confirmed the counter migration ticked correctly. On Pages this was second-class; on Workers it is the default.

## Still on the table

Things the move makes reachable that I have not built yet, kept honest because none of them ship today:

- **[Cron Triggers][cron-triggers].** A Worker can run on a schedule; Pages cannot. The Spotify now-playing cache, the [/around](https://aadhar.sh/around) crawl, and the (undeployed) `cal/` booking sweep are all lazy-on-request today. Any of them could move to a scheduled handler that warms the cache before a visitor ever asks.
- **[Service bindings][service-bindings], [Queues][cloudflare-queues], [Workflows][cloudflare-workflows], Containers.** All bindable from the same config, none of which fit Pages advanced mode cleanly. I do not need them for a personal site, but the ceiling moved.
- **Compression.** Left at the platform default in this move. The old plan to ship precompressed [brotli][brotli] twins died on Pages (it ignored uploaded `.br` siblings). On Workers it is revisitable, just not done.

## See the routing split yourself

This fetches three live paths and prints their real response headers. The two static paths come back **cacheable** (a long `max-age`, `immutable`), which is exactly why they can serve from the edge without the worker. The homepage comes back **no-cache**: the worker renders it on every request (it re-randomizes the photo grid and reads the visit counter), so it can never be an edge-cached file. Every header is real: your browser hitting production.

## The honest scorecard

The old Pages project still exists, parked as a rollback.

|  | Pages (before) | Workers + assets (now) |
| --- | --- | --- |
| deploy | git build, could drop the Function | atomic, all-or-nothing |
| config | dashboard clicks, off git | one `wrangler.jsonc`, in git |
| routing | worker saw everything | per-path denylist, static skips it |
| Durable Objects | not possible, cross-script only | in-house class + migration |
| cron | none | scheduled handlers available |
| bytes served | identical | identical |
| secrets | dashboard | out of git, by hand (correct) |

← back to the [garage](https://aadhar.sh/garage) · [aadhar.sh](https://aadhar.sh/) · see also [four free Cloudflare features](https://aadhar.sh/garage/cloudflare)

the prober hits production headers on this live Worker. nothing mocked.

[cloudflare-pages]: https://developers.cloudflare.com/pages/ "Cloudflare's hosting service for static websites and front-end apps, deployed straight from a Git repository."
[cloudflare-workers]: https://developers.cloudflare.com/workers/ "Code that runs on Cloudflare's servers around the world and answers each request directly, with no server of the site's own."
[workers-kv]: https://developers.cloudflare.com/kv/ "Cloudflare's key-value store: values saved under names and copied worldwide, so reads are fast everywhere while a new write can take a minute to spread."
[r2]: https://developers.cloudflare.com/r2/ "Cloudflare's object storage for files. It speaks Amazon S3's API and charges nothing for downloading data back out."
[d1]: https://developers.cloudflare.com/d1/ "Cloudflare's SQL database for Workers, built on SQLite."
[durable-object]: https://developers.cloudflare.com/durable-objects/ "A Cloudflare Workers object with its own built-in storage, where each named instance lives in one place so every request to it is coordinated."
[denylist]: https://en.wikipedia.org/wiki/Blacklist_(computing) "A list of things to block, where anything not on the list is allowed through by default."
[avif]: https://en.wikipedia.org/wiki/AVIF "An image format that stores a picture compressed with the AV1 video codec. Every current major browser can display it."
[gradual-deployments]: https://developers.cloudflare.com/workers/versions-and-deployments/gradual-deployments/ "Moving live traffic to a new version of a Worker a percentage at a time, so the old and new versions serve side by side."
[cron-triggers]: https://developers.cloudflare.com/workers/configuration/cron-triggers/ "Cloudflare's way to run a Worker on a schedule, like every hour, written in cron syntax."
[service-bindings]: https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/ "A Cloudflare link that lets one Worker call another directly, without a trip over the public internet."
[cloudflare-queues]: https://developers.cloudflare.com/queues/ "Cloudflare's message queue: one Worker drops tasks in, and another works through them later, in batches and with retries."
[cloudflare-workflows]: https://developers.cloudflare.com/workflows/ "A Cloudflare Workers feature for multi-step jobs that saves progress after each step and retries the ones that fail."
[brotli]: https://en.wikipedia.org/wiki/Brotli "A compression algorithm from Google built for the web. It ships with a built-in dictionary of common web text, which helps on small files."

Source: https://aadhar.sh/garage/workers
