Monorepos and external IaC
Running the Lunora worker as one node in a larger dev graph, deploying it from Terraform/Pulumi/Alchemy, and consuming its generated API from a sibling package.
Last updated:
lunora dev and lunora deploy default to owning the whole show, which is
right for a standalone app and wrong for a repo that already has seven Workers
and a task runner. Every piece is separable; this page is the map.
Running as one node in a dev graph
lunora dev normally starts three things: wrangler dev, the Studio server,
and the codegen watcher. Each can be turned off independently, so an external
supervisor (Turbo, Nx, vis run dev, a Procfile) can own the parts it already
manages while Lunora keeps the parts only it can do.
| Flag | Turns off |
|---|---|
--no-worker | the wrangler dev spawn — your runner owns the worker |
--no-studio | the embedded Studio server |
--no-codegen | the codegen watcher |
The common monorepo shape is attached mode — your runner starts the worker, Lunora keeps regenerating types and serving Studio:
// package.json — one node in the graph, alongside your other workers
{
"scripts": {
"dev": "run-p dev:worker dev:lunora",
"dev:worker": "wrangler dev --port 8788",
"dev:lunora": "lunora dev --no-worker --worker-port 8788",
},
}--worker-port still matters with --no-worker: Studio and the printed hints
need to know where the externally-owned worker is listening.
Studio is single-app. With several Lunora-touching Workers, run it against one of them rather than expecting a combined view, and mind that /__lunora is
mounted on the worker's own routes.
Deploying from external IaC
lunora deploy is codegen + validate + wrangler deploy. Under Terraform,
Pulumi, Alchemy or any other IaC that wants to be the source of truth for
bindings, do not use it — run the pieces as steps in your graph instead.
Every step is exported from @lunora/cli as a function, so alchemy.run.ts
(or equivalent) can call them directly rather than shelling out:
import { runCodegenCommand, runImportCommand, runMigrateGenerateCommand } from "@lunora/cli";
// Build step: generate + validate. Throws on a hard failure; the returned
// `failedAdvisories` is non-zero when an ERROR-level advisory blocked it.
const codegen = runCodegenCommand({ cwd: "./backend", logger });runDeployCommand, runResetCommand, runRpcCommand, runExportCommand and
runAddCommand are exported the same way — see the package's index.ts for the
full set.
Migrations belong inside the IaC graph as a post-deploy step, not as a separate CLI invocation that races it:
lunora migrate up --url https://my-worker.example.workers.devHanding the requirements to your IaC program
The duplication that bites here is the binding list: wrangler.jsonc declares
what the Worker reads, your IaC program declares what to create, and nothing
keeps them in step. lunora build --emit-bindings closes that by emitting the
requirements as data your program can consume:
lunora build --emit-bindings .lunora/bindings.json{
"version": 1,
"name": "acme-backend",
"compatibilityDate": "2026-04-07",
"bindings": [
{ "type": "d1", "binding": "DB", "resource": "acme", "resourceId": "…" },
{ "type": "durable_object", "binding": "SHARD", "className": "ShardDO", "sqlite": true },
{ "type": "r2", "binding": "FILES", "resource": "acme-files" },
{ "type": "vectorize", "binding": "VECTORS", "resource": "acme-index" },
],
"crons": ["*/5 * * * *"],
"vars": ["API_BASE"],
"unknown": [],
}It is derived from wrangler.jsonc after the pre-deploy pipeline has
reconciled it, so it describes what the bundle actually needs rather than what
happened to be written down. Three things worth knowing:
- Every binding is listed, including the ones Lunora cannot provision. A KV namespace id, a Hyperdrive id, a Vectorize index — Lunora warns about these and never writes them, which makes them exactly the ones your program has to create.
varscontributes names only. The file is committed and read by CI, and a var can hold something you would rather not publish.unknownis the honest escape hatch. A wrangler section this version does not model lands there by name rather than being dropped, so an under-provisioned deploy is visible here instead of at runtime. A non-emptyunknownis worth reading before you trust the rest.
wrangler.jsonc is declaration-only under external IaC. Lunora's validator reads it to check that every binding a schema needs is declared, but it
never provisions anything. When your IaC owns the real ids, keep the binding names accurate and treat the id fields as placeholders — a real id there is a
footgun, because nothing keeps it in step with the IaC state and a stale one is indistinguishable from a correct one.
Consuming the generated API from a sibling package
lunora/_generated/api.ts is emitted inside the backend package. A sibling
package (your web app, another Worker) reaches it through exports entries on
the backend's package.json:
// backend/package.json
{
"name": "@acme/backend",
"exports": {
"./api": "./lunora/_generated/api.ts",
"./dataModel": "./lunora/_generated/dataModel.ts",
"./server": "./lunora/_generated/server.ts",
},
}Consumers then import @acme/backend/api rather than reaching across the repo
by path, which keeps the generated file an implementation detail.
api.ts and dataModel.ts carry no server dependency — dataModel.ts has
no imports at all, and the query-DSL bindings that need @lunora/server live in
server.ts. So a consumer that only wants Doc/Id and the typed api needs
@lunora/client and nothing else; only a package importing ./server pulls the
server surface in.
These entries point at raw TypeScript, not built output, so every consumer compiles it. That is fine inside one workspace and awkward across published
package boundaries — if you publish the backend, build the generated files into your dist and point the exports there instead.
Calling the backend from a sibling Worker
ctx.run* covers calls within one Lunora app. For another Worker in the
same account, bind it and use @lunora/client/service:
// services/llm-gateway/wrangler.jsonc
{
"services": [{ "binding": "BACKEND", "service": "acme-backend" }],
}import { createServiceClient } from "@lunora/client/service";
import { api } from "@acme/backend/api";
export default {
async fetch(request: Request, env: Env) {
// `env` is a handler argument on Workers, never a module-scope global —
// build the client inside the handler (or pass `env` into a factory).
const backend = createServiceClient(env.BACKEND);
const threads = await backend.query(api.threads.list, { userId });
return Response.json(threads);
},
};The call is typed against the same api the browser uses, so a renamed function
or a changed argument is a compile error rather than a runtime 404. Arguments and
results go through the same wire codec as the HTTP path, so a bigint or byte
array survives the hop, and a thrown LunoraError arrives with its code and
data intact.
Prefer this to calling the backend's public URL. A service binding incurs no request fee, never leaves the edge, and is authenticated by construction — the
binding is the capability, so the callee needs no public route and you need no shared secret. Only public functions are reachable: an internal*
procedure is absent from api and refused by the callee, because a binding is a boundary between deployments rather than the intra-app ctx.run* seam.
Pass { shardKey } as the third argument for a sharded app — without it the
callee routes to its default shard, exactly as the HTTP path does.