Run Tinbox inside a monorepo
Where a Tinbox instance lives in a monorepo (one app folder), how to isolate its lockfile so the cross-repo dep never leaks into CI, and the exact files, scripts, and deploy loop — modelled on a real tenant.
A Tinbox instance is one self-contained Cloudflare Worker — it does not import any of your monorepo's packages, and nothing in your monorepo should import it. That makes the right structure simple: one app folder, treated as its own island. This guide is the concrete layout — where the folder goes, the files in it, and the one thing you must get right so the instance never breaks your other apps' CI.
It builds on Consume Tinbox from your own repo
(the file: dependency form, building dist/ first, the zod-4 peer) — read that
for the dependency mechanics. This guide is the structural companion: where it
all sits in a monorepo and how to keep it sealed off.
TL;DR — the shape
your-monorepo/
├── pnpm-workspace.yaml # add "!apps/tinbox" here ← excludes the instance
├── pnpm-lock.yaml # your root lockfile — the tinbox dep MUST stay out of it
├── apps/
│ ├── web/ # your other apps (in CI, deploy from Cloudflare's builder)
│ ├── admin/
│ └── tinbox/ # ← the Tinbox instance: its OWN island
│ ├── pnpm-workspace.yaml # `packages: []` → standalone workspace boundary
│ ├── pnpm-lock.yaml # its own lockfile (the file: dep lives ONLY here)
│ ├── package.json # depends on `tinbox`, nothing @tinbox/*
│ ├── tinbox.config.ts # the one file you edit
│ ├── wrangler.jsonc # Cloudflare bindings (ids filled by `tinbox setup`)
│ ├── src/worker.ts # 3-line boilerplate: createWorker(config)
│ ├── tsconfig.json
│ ├── .gitignore # ignores .env.prod, .wrangler/, public/, .tinbox/
│ └── .dev.vars.example
The single rule that makes this work: apps/tinbox is its own pnpm workspace
root with its own lockfile, and the parent workspace explicitly excludes it. Get
that right and everything else is just the standard single-instance scaffold.
Why the isolation matters (read this before anything else)
A monorepo instance almost always links the Tinbox source via a cross-repo file:
dependency (you have a tinbox-mono checkout next to your monorepo, and you're not
on npm yet):
// apps/tinbox/package.json
{ "dependencies": { "tinbox": "file:../../../tinbox-mono/packages/tinbox" } }
If apps/tinbox were a normal member of your root pnpm workspace, that file:
path — which points outside your repo — would get written into your root
pnpm-lock.yaml. Then any teammate or CI runner that doesn't have tinbox-mono
checked out at that exact relative path gets a broken pnpm install — and that
breaks the install for apps/web, apps/admin, and everything else, not just
the Tinbox folder. On Cloudflare's build server (which builds your other apps and
has no tinbox-mono sibling), the lockfile is unsatisfiable.
The fix is two small files that, together, seal the instance off:
1. Exclude it from the root workspace — add a negation to the parent
pnpm-workspace.yaml:
# your-monorepo/pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
- "!apps/tinbox" # local-only Tinbox instance — installs with its own lockfile
2. Make the folder its own workspace root — drop a pnpm-workspace.yaml
inside apps/tinbox:
# apps/tinbox/pnpm-workspace.yaml
# Standalone workspace boundary: pnpm stops walking up the tree when it finds this
# file, so `pnpm install` here writes to apps/tinbox's OWN lockfile and never
# touches the parent. That keeps the cross-repo file: dep out of the root lockfile.
packages: [] # this folder is a single package, not a sub-workspace
With both in place, you always run pnpm install from inside apps/tinbox, it
resolves into its own apps/tinbox/pnpm-lock.yaml, and your root lockfile never
sees the cross-repo path. Commit apps/tinbox/pnpm-lock.yaml — it's the instance's
own reproducible lock, independent of the root.
Keep it out of your build graph too. Give
apps/tinboxnobuildscript (it's built and deployed only from a local machine), soturbo build/nx buildskip it, and don't reference it from any rootbuild/ CI command. Deploying it is a deliberate local act (see the deploy loop below), not part of your repo's CI.
Getting the folder there
Two ways to create apps/tinbox:
A. Scaffold with the CLI, then seal it. From your monorepo root:
npx tinbox init <your-domain> apps/tinbox --no-deploy
init copies the standard instance scaffold into apps/tinbox, brands every
placeholder to your domain/org, and installs. The --no-deploy stops it after
scaffold + install so you can add the two isolation files before going live.
Then add the parent "!apps/tinbox" exclusion and the inner pnpm-workspace.yaml
(above), switch the dependency to your file: path, re-install from inside the
folder, and deploy with tinbox setup --deploy.
B. Copy the canonical example. Everything init produces mirrors
examples/cloudflare-basic in the tinbox-mono repo — copy that folder to
apps/tinbox and find-replace the acme / acme.com / mail.acme.com
placeholders yourself. Same result; useful if you want to see exactly what you're
getting first.
Either way you end up with the same handful of files. The two that carry all the configuration:
tinbox.config.ts— the one file you actually edit.org,domains,deployHost,features, and (optionally) your assistant prompt, skills, agents, and plugins. Authored withdefineConfigfromtinbox/cffor autocomplete.wrangler.jsonc— the Cloudflare bindings (D1/R2/KV/Queues/Assets). It ships withREPLACE_WITH_*placeholders for the resource ids;tinbox setupfills them in when it provisions. The inbox UI is served straight from the package (assets.directory: ./node_modules/tinbox/public) — no build/copy step in your folder.
src/worker.ts is three lines you rarely touch:
import { createWorker } from "tinbox/cf";
import config from "../tinbox.config";
export default createWorker(config);
The deploy loop (linked source)
Because pnpm resolves a file: dep into its content-addressed store at install
time, node_modules/tinbox is a snapshot, not live at the source's dist/. So
rebuilding the engine in tinbox-mono alone ships stale code — you must
re-install in apps/tinbox too. The CLI bundles a build verb that does both
(rebuild source and relink here), and the instance wraps the whole chain in one
script. Wire these tinbox:* scripts into apps/tinbox/package.json (the scaffold
already has them):
"scripts": {
"tinbox:setup": "tinbox setup", // provision CF + secrets + migrate (idempotent)
"tinbox:doctor": "tinbox doctor", // read-only health report
"tinbox:dev": "wrangler dev", // run locally
"tinbox:build": "tinbox build", // rebuild linked source + re-install HERE
"tinbox:deploy": "wrangler deploy",
"tinbox:redeploy": "tinbox build && tinbox migrate && wrangler deploy", // the whole dance, one command
"tinbox:update": "tinbox update",
"tinbox:migrate": "tinbox migrate"
}
Name them
tinbox:*, not bare verbs.setup,deploy,test, anddoctorare pnpm built-ins — a barepnpm deployruns pnpm's command and ships nothing. Thetinbox:prefix is mandatory, not cosmetic.
Day-to-day, from inside apps/tinbox:
cd apps/tinbox
pnpm install # isolated — its own lockfile (see the isolation rule above)
pnpm tinbox:setup # first time only: create D1/R2/KV/Queues, push secrets, migrate
pnpm tinbox:redeploy # ship: rebuild linked source → re-install → migrate → deploy
To take a newer Tinbox: git pull in tinbox-mono, then pnpm tinbox:redeploy
again — the rebuild + re-install is what defeats the stale-snapshot problem.
Installing from npm instead of
file:? Then the isolation is optional (an npm version pins cleanly in any lockfile) — but the one-app-folder layout and thetinbox:*scripts are identical. Just usepnpm update tinbox && pnpm tinbox:updateto take a new version, and skip thetinbox buildstep (there's no local source to rebuild).
Secrets and what's gitignored
Secrets never go in a tracked file. tinbox setup generates the required ones
(BETTER_AUTH_SECRET, ASSET_SIGNING_KEY, the VAPID push trio), pushes each via
wrangler secret put, and mirrors them to a gitignored .env.prod. The
scaffold's .gitignore already covers the things that must never be committed:
.env.prod # generated secrets (also on Cloudflare)
.dev.vars # local dev secrets
.wrangler/ # wrangler state
.tinbox/ # per-run provision/deploy logs
public/ # guard: the UI is served from node_modules/tinbox/public, never copied
dist/
Multiple instances in one monorepo
One Tinbox deployment serves one domain. If you run several (say a brand per
domain), give each its own app folder — apps/tinbox-acme, apps/tinbox-beta
— each with its own tinbox.config.ts (org + domains), its own
pnpm-workspace.yaml island and lockfile, and its own "!apps/tinbox-*" exclusion
in the parent workspace. They share nothing but the linked tinbox source.
Checklist
- Instance lives in one app folder (e.g.
apps/tinbox), imports no@tinbox/*workspace packages, and nothing in the monorepo imports it. - Parent
pnpm-workspace.yamlhas"!apps/tinbox". -
apps/tinbox/pnpm-workspace.yamlexists withpackages: [](own workspace root). - You run
pnpm installfrom insideapps/tinbox; its ownpnpm-lock.yamlis committed and the cross-repofile:path is not in the root lockfile. - The folder has no
buildscript and isn't in any rootbuild/CI command. - Scripts are prefixed
tinbox:*; deploys go throughpnpm tinbox:redeploy. -
.env.prod,.dev.vars,.wrangler/,.tinbox/,public/are gitignored.
See also
- Consume Tinbox from your own repo — the
file:dep form, buildingdist/first, theexportssubpaths, the zod-4 peer. - Deploy to your Cloudflare account — the full provisioning + deploy paths.
- Updating Tinbox — taking a new version on a live instance.
tinbox doctor— read-only "what's set up vs still needed", any time.