GoodWebTools is a static Astro site deployed to Cloudflare Workers (with Static Assets), fronted by a small Worker that streams the AI-tool model files from an R2 bucket. Everything runs client-side; the Worker only serves files.
Deploys are push-to-deploy via Cloudflare Workers Builds — Cloudflare watches the GitHub branches and builds + deploys on every push. No GitHub Actions.
- Push to
main→ production (goodwebtools.com) - Push to
develop→ staging (goodwebtools-staging.workers.dev)
npm run build→ static site indist/(HTML, JS, CSS, WASM).worker/index.js→ serves/models/*from the R2 bucket (MODELSbinding), and delegates everything else to the static build (ASSETSbinding).wrangler.jsonc→ wires it together (worker name,assets,r2_buckets, and theenv.stagingenvironment).
Workers, not Pages. This deploys one Cloudflare Worker with Static Assets — not Cloudflare Pages. A Worker is required because a plain static site can't stream the large ML model files at
/models/*; the Worker routes/models/*to R2 and serves everything else from the bundled Static Assets.
BUILD & DEPLOY — automatic on every push (Cloudflare Workers Builds)
┌─────────────┐ git push ┌────────────────────────┐ npm run build ┌────────────────┐
│ GitHub repo │ ──────────▶ │ Cloudflare Workers │ ───────────────▶ │ dist/ │
│ main / │ (branch │ Builds (CI runner) │ + postbuild │ static site │
│ develop │ watched) │ then: wrangler deploy │ prune │ │
└─────────────┘ └───────────┬─────────────┘ └───────┬────────┘
│ deploys the Worker │ uploaded as
▼ │ Static Assets
┌──────────────────────────────────────────────────────────┐◀─────────────┘
│ Cloudflare Worker (worker/index.js) │
│ binding ASSETS ─▶ dist/ · binding MODELS ─▶ R2 │
└──────────────────────────────────────────────────────────┘
▲ read at runtime (NOT part of the git build)
┌──────────────────────────────────────────────────────────┐
│ R2 bucket "goodwebtools-models" — uploaded ONCE, by hand │
│ /imgly /mediapipe /esrgan-slim /ort /lama /ffmpeg (~540 MB)
└──────────────────────────────────────────────────────────┘
RUNTIME — every visitor request
Browser ──▶ Worker ──┬─ /models/* ─▶ env.MODELS.get(key) ─▶ R2 (hit → stream + immutable cache; miss → 404)
└─ everything ─▶ env.ASSETS.fetch() ─▶ dist/ static assets
What goes where
| Content | Lives in | Served by | Deployed by |
|---|---|---|---|
| HTML · JS · CSS · fonts · small wasm (mupdf, libarchive, sqlite) | Worker Static Assets (from dist/) |
ASSETS binding |
git push → Workers Builds |
Routing logic (/models/* → R2, else → assets) |
The Worker (worker/index.js) |
the Worker itself | git push → Workers Builds |
| Large ML models + ORT/ffmpeg wasm (~540 MB) | R2 bucket | MODELS binding |
one-time manual upload (npm run sync:r2) |
The models are the only piece not part of the git build — uploaded to R2 once (see below) and immutable, so a normal push never re-uploads ~540 MB.
Two Cloudflare Worker services, both connected to the same GitHub repo, each watching a different production branch:
| Environment | Worker service | Branch | Deploy command | URL |
|---|---|---|---|---|
| Production | goodwebtools |
main |
npx wrangler deploy |
goodwebtools.com |
| Staging | goodwebtools-staging |
develop |
npx wrangler deploy --env staging |
goodwebtools-staging.workers.dev |
The staging environment is defined in wrangler.jsonc
(env.staging). Each environment has its own R2 bucket
(goodwebtools-models / goodwebtools-models-staging).
git push origin main ─▶ Workers Builds ─▶ wrangler deploy ─▶ production
git push origin develop ─▶ Workers Builds ─▶ wrangler deploy --env staging ─▶ staging
You need a Cloudflare account and the Wrangler CLI (pinned as a devDependency).
npx wrangler login
npx wrangler r2 bucket create goodwebtools-models # production models
npx wrangler r2 bucket create goodwebtools-models-staging # staging modelsThe AI tools need model + runtime files (~540 MB total) that are not
committed — they live in R2 under /models/, mirroring the on-disk layout.
| Prefix | Tool(s) | Approx size |
|---|---|---|
imgly/ |
Background Remover, Portrait Blur | ~211 MB |
mediapipe/ |
Face Blur | ~35 MB |
esrgan-slim/ |
Image Upscaler | ~4 MB |
ort/ |
Object Remover (onnxruntime-web WASM) | ~76 MB |
lama/ |
Object Remover (LaMa ONNX) | ~200 MB |
ffmpeg/ |
Video/audio tools (ffmpeg.wasm) | ~31 MB |
Stage them locally, then sync to R2 (sync:r2 reads the bucket names from
wrangler.jsonc, so it targets whatever prod/staging are configured to use):
npm run stage:models # → public/models/** (gitignored; downloads LaMa + face model)
# Cloudflare auth first: set CLOUDFLARE_API_TOKEN, or `npx wrangler login`
npm run sync:r2 # upload to every configured bucket (prod + staging)
npm run sync:r2:prod # …or just production
npm run sync:r2:staging # …or just staging
npm run sync:r2 -- --dry-run # preview without uploadingUploads are idempotent (wrangler r2 object put --remote), so re-running retries
failures and re-staging + re-syncing updates a bumped model version. (You can
also drag the folders into the bucket via the dashboard, or use rclone.)
Until the models are in R2, the AI tools 404.
Local dev:
npm run stage:modelsalso letsnpm run devserve the models frompublic/models/**.public/models/is gitignored, so CI never uploads it.
In the Cloudflare dashboard → Workers & Pages → Create → Workers → Import a repository, create two Workers from the same repo:
| Production | Staging | |
|---|---|---|
| Worker name | goodwebtools |
goodwebtools-staging |
| Production branch | main |
develop |
| Build command | npm run build |
npm run build |
| Deploy command | npx wrangler deploy |
npx wrangler deploy --env staging |
Then attach the custom domain (goodwebtools.com) to the production Worker under
Settings → Domains & Routes. From now on, every push to main/develop
auto-builds and deploys the matching environment.
Analytics and indexing are auto-gated by branch in astro.config.mjs (using
the WORKERS_CI_BRANCH Cloudflare injects), so you barely configure anything:
Production (main) |
Staging / other branches | |
|---|---|---|
| Google Analytics | on — from SITE_GA_ID |
off |
robots |
index, follow |
noindex, nofollow |
The only build variable to set is SITE_GA_ID on the production Worker
(Workers Builds → Settings → Build → Variables) — your GA4 id, e.g.
G-XXXXXXXXXX. Leave it unset to disable analytics. Everything else is automatic;
you do not set PUBLIC_NOINDEX by hand (the branch gate handles it). GA is
also consent-gated — it only loads after the visitor accepts the cookie banner.
These are build-time variables (Vite inlines
import.meta.env.PUBLIC_*into the static output), so they must be Build variables, not Worker runtime bindings. Changes take effect on the next push/redeploy.
Just push — Workers Builds does the rest:
git push origin develop # → staging
git push origin main # → productionManual/CLI (runs the same commands Cloudflare runs) still works:
npm run deploy # production (= npm run build && wrangler deploy)
npm run deploy:staging # staging (= … && wrangler deploy --env staging)Both environments build from the same commit graph, so promotion is a fast-forward merge and a push:
git checkout main
git merge --ff-only develop
git push origin main # ← triggers the production deploy
git checkout developEach Worker only auto-deploys its own production branch. Pushing any other
branch triggers a preview build (Cloudflare runs npx wrangler versions upload, giving a temporary versioned preview URL) without touching the live
environment. Toggle this per Worker under Settings → Builds → Non-production
branches.
Every deploy is a Worker version. In the dashboard → the Worker → Deployments, pick a previous version and Rollback — instant, no rebuild. Or push a revert commit to the branch to redeploy the previous state.
# Production indexable, staging not:
curl -s https://goodwebtools.com/ | grep -o '<meta name="robots"[^>]*>' # → index, follow
curl -s https://goodwebtools-staging.workers.dev/ | grep -o '<meta name="robots"[^>]*>' # → noindex, nofollow
# Models serve from R2 (200):
curl -sI https://goodwebtools.com/models/lama/lama_fp32.onnx | head -1 # → HTTP/2 200Analytics: SITE_GA_ID is inlined in the production HTML and absent in staging.
wrangler deployneeds the R2 bucket to exist — create it first, or the deploy fails on theMODELSbinding.- Per-file asset limit is 25 MB. Site WASM (mupdf ~10 MB, libarchive ~1 MB)
is fine; large ML models go to R2 to avoid this. onnxruntime-web's ~26 MB wasm
is emitted into
dist/_astrobut unused (ORT loads from/models/ort/); thepostbuildstep (scripts/prune-dist.mjs) removes it so deploys stay under the limit. - Node heap: the asset-heavy build sets
--max-old-space-size=8192in thebuildscript (viacross-env) so Cloudflare's build doesn't OOM. - Peer deps:
.npmrcsetslegacy-peer-deps=truesonpm ciresolves the tfjs/upscaler peer conflict on Cloudflare. - Model versions must match.
@imgly/background-removaland its-datapackage are pinned together; a mismatch causes "Resource … not found". - 404s / routing: Astro emits
dist/tools/<id>/index.html; Cloudflare serves trailing-slash variants and usesdist/404.html(not_found_handling: "404-page"). - Secrets: none are committed. Wrangler auth is
wrangler loginor theCLOUDFLARE_API_TOKENenv var;.dev.vars,.env*, and.wrangler/are gitignored.
Cloudflare's free tier covers this comfortably: Static Assets have unlimited bandwidth, and R2 has a generous free tier for storage + egress (models are immutable and hard-cached, so they download once per visitor).
GoodWebTools is self-hostable on Cloudflare Workers. To run your own copy:
- Fork this repo and clone it.
- Rename the deployment identifiers in
wrangler.jsonc(name,env.staging.name) and the R2 bucket names to values you own. - Create the R2 buckets (production +
-staging, step 1 above). - Point branding at your domain in
src/config.ts(SITE_URL,REPO_URL) andastro.config.mjs(site); updatepublic/robots.txt. - (Optional) analytics: set
SITE_GA_IDas a production build variable. - Stage & upload the models:
npm run stage:modelsthennpm run sync:r2. - Connect Workers Builds to your fork (production branch
main) and push.
Nothing sends data anywhere except your own Cloudflare account.