A repo can be honest about its constraints, or it can fight them. Most fullstack repos on Cloudflare end up fighting — folder structures inherited from the AWS era, configs that pretend the runtime is Node, deploy pipelines that try to be CI/CD platforms.
This post is the shape I’ve landed on after building a half-dozen production systems on Cloudflare. Not the only shape — the one I keep returning to.
Table of contents
Open Table of contents
The repo
my-app/
├── apps/
│ ├── web/ # Astro site (storefront, marketing, blog)
│ └── admin/ # React or Astro dashboard (auth-gated)
├── workers/
│ ├── api/ # Main Hono API on Workers
│ ├── jobs/ # Cron workers (scheduled tasks)
│ └── webhooks/ # External webhook receivers
├── packages/
│ ├── db/ # Drizzle schema + migrations + queries
│ ├── auth/ # Session, JWT, Turnstile helpers
│ └── shared/ # Zod schemas, types, constants
├── infra/
│ ├── wrangler.toml # Per-worker config
│ └── migrations/ # D1 migration runner scripts
├── package.json
├── pnpm-workspace.yaml
└── tsconfig.base.json
A few opinions in that layout:
apps/vsworkers/. Anything that serves HTML to humans goes inapps/. Anything that serves JSON, runs on a cron, or handles webhooks goes inworkers/. The boundary maps cleanly onto the deploy artifact (Pages vs Worker) and the bindings you’ll need.packages/dbis shared. Schema lives once. Migrations live once. Query helpers live once. Both the API worker and the admin app import from@my-app/db.packages/authis also shared. Sessions are validated identically wherever they’re checked. JWT logic isn’t duplicated between routes.- No top-level
src/. Every workspace has its ownsrc/so the editor doesn’t get confused about which tsconfig applies.
The deploy story
One wrangler.toml per worker. Pages config lives in a wrangler.toml too — Pages Functions get the same treatment.
# workers/api/wrangler.toml
name = "my-app-api"
main = "src/index.ts"
compatibility_date = "2026-05-01"
compatibility_flags = ["nodejs_compat"]
[[d1_databases]]
binding = "DB"
database_name = "my-app"
database_id = "..."
[[kv_namespaces]]
binding = "SESSIONS"
id = "..."
[[r2_buckets]]
binding = "UPLOADS"
bucket_name = "my-app-uploads"
[[durable_objects.bindings]]
name = "WORKSPACE_HUB"
class_name = "WorkspaceHub"
Deploy in CI:
# .github/workflows/deploy.yml
- run: pnpm install --frozen-lockfile
- run: pnpm -F api deploy
- run: pnpm -F jobs deploy
- run: pnpm -F web build
- run: npx wrangler pages deploy apps/web/dist --project-name=my-app
Three deploys, no orchestration tool. If one fails, the others can still go. If they all succeed, the system is live. Cloudflare doesn’t gate the deploys on each other — and that’s a feature: you can roll out a Pages change without bouncing the API.
Where features live
This is the part that always trips people. New feature comes in — “let users export their data as JSON” — and someone has to decide which worker owns it. My defaults:
| Feature | Where it lives |
|---|---|
| Page load, server-rendered HTML | apps/web (Astro + adapter) or apps/admin |
| Authenticated API call (CRUD) | workers/api |
| Webhook from a third party | workers/webhooks |
| Nightly job, cleanup, billing | workers/jobs (cron triggers) |
| Long-running async (>30s) | A Workflow or a Queue consumer in workers/jobs |
| Per-tenant realtime state | A Durable Object class in workers/api |
| Static asset, image, PDF | R2 + a thin workers/api proxy if private |
| AI inference | workers/api (Workers AI binding) |
That table is the rule. The exceptions are usually performance-driven — pulling a hot endpoint into its own worker because it has a different memory profile.
The local dev loop
The local-dev story is the part Cloudflare did most quietly but did really well. wrangler dev runs the actual Workers runtime locally — same V8 isolate, same compatibility flags, same bindings (mocked or proxied to remote).
# Run the API + a local D1 + a local KV
pnpm -F api dev
# Run Astro against the local API
VITE_API_URL=http://localhost:8787 pnpm -F web dev
When a Pages app needs a Worker, you can run both in a single wrangler pages dev — Pages binds the Worker as a service. Local dev is one of the few places where running on Cloudflare is genuinely easier than running on AWS-equivalents.
Three things I always do
- Drizzle, not raw SQL. D1 has a great REST API, but query construction in TypeScript is faster to write and faster to refactor than string-building. Migrations stay clean. Type inference lights up in the editor.
- Hono, not handler-per-file. Workers can scale to thousands of routes, but they’re nicer to read when grouped. Hono gives me middleware, validation, and a routing tree without bloat.
- Zod everywhere. Every API input, every webhook payload, every form submission. Validation at the boundary, types inside.
Three things I never do
- Don’t reach for a third-party DB. D1 covers more cases than people give it credit for, and the cross-region latency penalty for hitting Postgres-as-a-service is real.
- Don’t sprinkle business logic into Pages Functions. Functions are for routing, redirects, headers. Real work belongs in a Worker.
- Don’t deploy from a laptop. Always through CI, even for personal projects.
wrangler deployfrom your shell is fine for hotfixes, but the long-term tax is real.
The full edge-first argument is in Why I’m Building Cloudflare-Native Systems. The stack choice for the frontend layer of this picture is covered in Astro + Cloudflare.