Skip to content

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/tinbox no build script (it's built and deployed only from a local machine), so turbo build / nx build skip it, and don't reference it from any root build / 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 with defineConfig from tinbox/cf for autocomplete.
  • wrangler.jsonc — the Cloudflare bindings (D1/R2/KV/Queues/Assets). It ships with REPLACE_WITH_* placeholders for the resource ids; tinbox setup fills 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, and doctor are pnpm built-ins — a bare pnpm deploy runs pnpm's command and ships nothing. The tinbox: 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 the tinbox:* scripts are identical. Just use pnpm update tinbox && pnpm tinbox:update to take a new version, and skip the tinbox build step (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.yaml has "!apps/tinbox".
  • apps/tinbox/pnpm-workspace.yaml exists with packages: [] (own workspace root).
  • You run pnpm install from inside apps/tinbox; its own pnpm-lock.yaml is committed and the cross-repo file: path is not in the root lockfile.
  • The folder has no build script and isn't in any root build/CI command.
  • Scripts are prefixed tinbox:*; deploys go through pnpm tinbox:redeploy.
  • .env.prod, .dev.vars, .wrangler/, .tinbox/, public/ are gitignored.

See also