Deployment topology
One Worker, one D1, one R2 bucket — per shop. The pattern is repeated for every shop that signs on. There is no shared infrastructure between shops.
The Worker is deployed against a real Cloudflare D1 (kvick-helm-swicked, id 134ea8a4-8582-4fdc-84ec-f0eb8f4e4b60, region WNAM). The "deploy unit" below describes the shape; the cutover note further down describes the change from "local dev + remote schema-only" to "remote everything."
The deploy unit
A "Helm deployment" is the tuple:
- 1 Cloudflare Worker (helm-{shop_slug})
- 1 D1 database (helm-{shop_slug}-db)
- 1 R2 bucket (helm-{shop_slug}-assets)
- 1 KV namespace (helm-{shop_slug}-kv, planned)
- 1 hostname (helm-{shop_slug}.kvick.bike)
- N cron triggers (daily / weekly / monthly)
- 1 git branch (shops/{shop_slug})
- 1 wrangler.jsonc (per-branch overrides on top of the base config)
- 1 secrets bundle (Stripe keys, Twilio sub-account SID, etc.)
Each shop deploys independently. Swicked's deploy at 9am does not touch Mike's Bikes' Worker.
Per-shop hostname
helm-{shop_slug}.kvick.bike → routes to helm-{shop_slug} Worker
{shop_slug}.shop → routes to the shop's public marketing site Worker (separate codebase)
Examples:
helm-swicked.kvick.bike— Swicked's POSswicked.shop(deferred) orswickedcycles.com— Swicked's public site
The two are different Workers. Sharing a domain would invite cross-app cookie leakage; separate domains keep the security model clean.
Git topology
main ← canonical Helm code; protected
shops/swicked ← Swicked's branch; rebased onto main weekly
shops/mike-s-bikes ← Mike's branch
shops/{N} ← one per shop
A shop's branch differs from main by at most:
wrangler.jsoncshop-specific bindings (DB binding IDs, R2 names)- An optional
shop-overrides/directory for shop-specific tweaks (logo, color, copy)
Code in src/ is identical across shops; configuration differs.
Deploy flow
For solo dev mode (no PR review), wrangler deploy --env swicked from the laptop works the same way. CI exists for shops where ops handoff is needed.
Single-tenant means data isolation by deployment
There is no tenant_id column in any table in any shop's database. The isolation property is enforced at the deployment layer:
- Worker A only has bindings for D1-A and R2-A
- Worker A's
env.DBcannot reach D1-B - A bug in Worker A cannot leak Shop B's data because Worker A has no way to talk to Shop B's storage
This is the strongest data-isolation property the architecture can offer. See single-tenant per shop and ADR-0003.
Cost shape per shop
Approximate Cloudflare bill per shop per month at typical Swicked load:
| Resource | Usage | Cost |
|---|---|---|
| Worker requests | ~50k/day | ~$0.50 |
| D1 reads | ~500k/day | ~$1.00 |
| D1 writes | ~10k/day | ~$0.40 |
| D1 storage | ~100MB | $0.00 (under free tier) |
| R2 storage | ~5GB | ~$0.07 |
| R2 ops | ~5k/day | ~$0.05 |
| KV (when wired) | minimal | ~$0.10 |
| Cron triggers | 3 | $0.00 |
| Total | ~$2.10 |
Anthropic Claude API is the most-variable cost; depends on AI bubble usage. Budget: $5-15/shop/month for AI when fully wired.
Scaling to N shops
The deployment pattern scales by repetition. There is no shared state that becomes a bottleneck. The constraints are:
- Cloudflare account limits — sub-account or single account with many Workers; up to ~500 Workers per account on enterprise. Past 50 shops Kvick splits across Cloudflare accounts.
- Deploy automation — a
scripts/onboard-shop.shwill templatize new shop creation. ~10 minutes per new shop today, target 2 minutes. - Operator (Kvick) cognitive load — the actual scaling bottleneck. Slice 11's AI bubble + good runbooks + observability are what makes 50 shops feasible for a one-person ops team.
Failure isolation
When Shop A's Worker has a bug:
- Shop B is unaffected (different Worker process)
- The deploy fix touches only Shop A's branch
- Rollback for Shop A is
wrangler rollback --env swicked(~30 seconds)
When Cloudflare itself has an outage:
- All shops are affected (this is the unavoidable shared dependency)
- See disaster recovery
Local development — removed 2026-05-13
The wrangler dev workflow is no longer in use. Once Layer 1 OAuth gates the production Worker (see security model), iteration against the real URL is fast enough that maintaining a separate local-only stack stops being worth the divergence cost.
The new workflow:
# Edit code, then:
npm run deploy # wrangler deploy → live Worker
# To re-apply migrations after editing migrations/*.sql:
wrangler d1 migrations apply DB --remote
# To bootstrap a fresh remote D1 from the locally-populated SQLite:
python scripts/dump_local_for_remote.py
# then for each generated migration_dumps/*.sql:
wrangler d1 execute DB --remote --file migration_dumps/<file>.sql
The predev hook + dev script are gone from package.json; only predeploy remains (refreshes build_info.json on every deploy).
Why removing local dev was the right call
- OAuth gate makes prod-URL iteration safe. Anyone hitting the URL bounces unless they're on the allowlist. There's no risk in pointing the live URL at a half-finished feature behind a Sys Admin sign-in.
- No more "works locally, breaks in prod" drift between local SQLite and remote D1. Schema changes apply to the same DB the live Worker uses.
- Real D1 is fast enough. Per-request latency to D1 is ~5-15ms; a
wrangler deploycycle is 20-30s. A local-edit-save-test loop against prod is plenty for normal slice work. - Migration 016 ports the slice-1 bootstrap (
staff_screen_permissionstable +screen.*permissions +role_permissionsdefaults) frommigrate_aim.pyinto a proper SQL migration so remote D1 hydrates cleanly without needing Python to have run first.
For a deeper-dive sandbox (e.g., rebuilding the database from AIM dumps), python migrate_aim.py --all --target=local still works against an out-of-band local SQLite file; it just isn't the day-to-day iteration loop anymore.
See also
- Local dev setup — getting a developer going
- Deploy to production — the prod release runbook
- Disaster recovery — when things break
- ADR-0007: GitHub Actions for CI