# How I Structure Fullstack Projects on Cloudflare

> A repo layout, a deploy pipeline, and a few opinions about where features live in a Cloudflare-only stack.

- Author: Jacob Gullberg
- Published: 2026-05-17
- Tags: cloudflare, fullstack, workers, astro
- Canonical: https://jccbbb.com/blog/structuring-fullstack-on-cloudflare/
- Site: https://jccbbb.com/

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.


## 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/` vs `workers/`**. Anything that serves HTML to humans goes in `apps/`. Anything that serves JSON, runs on a cron, or handles webhooks goes in `workers/`. The boundary maps cleanly onto the deploy artifact (Pages vs Worker) and the bindings you'll need.
- **`packages/db` is 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/auth` is 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 own `src/` 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.

```toml
# 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:

```yaml
# .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).

```bash
# 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

1. **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.
2. **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.
3. **Zod everywhere.** Every API input, every webhook payload, every form submission. Validation at the boundary, types inside.

## Three things I never do

1. **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.
2. **Don't sprinkle business logic into Pages Functions.** Functions are for routing, redirects, headers. Real work belongs in a Worker.
3. **Don't deploy from a laptop.** Always through CI, even for personal projects. `wrangler deploy` from 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](/blog/why-cloudflare-native/). The stack choice for the *frontend* layer of this picture is covered in [Astro + Cloudflare](/blog/astro-cloudflare-stack/).
