Skip to main content

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.

v0.2 — Swicked is live in production (2026-05-13)

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 POS
  • swicked.shop (deferred) or swickedcycles.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.jsonc shop-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.DB cannot 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:

ResourceUsageCost
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 triggers3$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.sh will 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 deploy cycle 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_permissions table + screen.* permissions + role_permissions defaults) from migrate_aim.py into 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