Running locally
runlot dev runs your worker on your own machine with the runlot bindings wired up, so you can see a change without deploying.
runlot devrunlot dev builds the same bundle runlot deploy would upload, runs it under workerd — the runtime your deployment runs on — and wires env.db, env.storage, env.auth, env.email and env.assets to local stand-ins. It prints the address to open and a table of which bindings are real and which are stand-ins, then rebuilds and restarts whenever you save a file.
runlot dev — my-app
http://127.0.0.1:8787
env.db real real PostgreSQL (postgres://***@localhost:5432/app)
env.storage local real bytes in .runlot/dev/storage
env.auth fake one dev user, a stub sign-in page, no providers
env.email fake writes .eml into .runlot/dev/mail, sends nothing
env.assets / env.ASSETS real the bundle's assets directory
fetch() widened dev also allows private and loopback addresses; production allows public onlyState lives in .runlot/dev. Add .runlot to your .gitignore.
Options
runlot dev [dir] [--port 8787] [--cron "*/5 * * * *"]--port is the address you open; 8787 is the default. --cron fires the worker's scheduled() handler on that 5-field UTC schedule. You can also fire it once, at any time, by asking for it:
curl localhost:8787/__runlot/cronThe runtime
runlot dev runs your worker on the real workerd binary — the same runtime we run it on when you deploy — not a Node emulation. That matters: the defects a local run is supposed to catch — a Node built-in that nodejs_compat does not open at your compatibilityDate, a module graph the runtime will not load, a Request that is not Node's — do not exist under Node, so a green Node run would tell you nothing about the deploy.
The binary installs with the CLI. If your install skipped optional dependencies, add it to the project:
npm i -D workerdOr point at one you already have with RUNLOT_DEV_WORKERD=/path/to/workerd.
What is real and what is not
env.db
With a PostgreSQL URL, env.db is that database — statements run on it, sessions hold transactions open, and a failing statement comes back with its real SQLSTATE in error.code. Cells are decoded with the same table production uses, so an int8 is a string here exactly as it is there.
export RUNLOT_DEV_DATABASE_URL=postgres://localhost:5432/my_app_devTeams that share one address can put it in runlot.json instead; the environment variable wins.
{ "dev": { "database": "postgres://localhost:5432/my_app_dev" } }Without a URL, env.db is a fake that accepts every statement and answers zero rows, and runlot dev says so at startup. That is enough for a worker that mentions the database to boot — it is not enough to tell you your query is right.
runlot pg migrate applies migrations/ to a deployed project. Against a local database, run them with psql.
env.storage
Real bytes, in .runlot/dev/storage. put, get, head, delete and list behave as they do in production, including a cursor page and a delete of a missing key being a success. presign returns a signed URL on the dev server's own port, and the signature is checked — including the byte count, when you bind one, so a size-bound upload of the wrong length is refused locally too.
env.auth
One user and a stub sign-in page at /__runlot/auth/sign-in. Any address is accepted, no password is checked, and no provider is contacted. env.auth.user(request) answers that user once the cookie is set and null before that — which is the distinction your code actually depends on. To skip the page entirely:
export RUNLOT_DEV_USER=[email protected]env.auth.users reads work; writes are not simulated.
env.email
env.email.send writes an .eml file into .runlot/dev/mail and prints one line. Nothing is delivered. Open the file in any mail client to see what you sent.
env.ai
env.ai runs locally when you are logged in. The call is relayed through the control plane to the same lane Studio's local runtime uses — the same catalog, the same prices, the free plan's caps on every plan (4,096 output tokens, 30 calls a minute, two at a time) — and is billed to your org's AI credit as lane = dev, so the dashboard ledger shows what development spent. runlot.json needs an "org" (or pass --org), and the project needs AI turned on, which every project has unless it was turned off. Streaming (stream: true) is refused by name: the relay answers one JSON body, and a whole answer where your code expects chunks would pass locally and change shape on deploy. RUNLOT_DEV_AI=off makes a run refuse env.ai instead; not logged in, it refuses and says so in the binding table.
env.assets
The bundle's assets directory, served from disk. "notFound": "spa" is honoured, and "runWorkerFirst": false puts the asset router ahead of your worker exactly as it does in production.
Durable Objects
Real, not a stand-in. workerd is the runtime that implements Durable Objects, so runlot dev configures the same namespaces on it from your durable_objects and migrations blocks, with the same new_classes / new_sqlite_classes split. ctx.storage, ctx.storage.sql, transactionSync, alarm and idFromName are the runtime's own — a defect you find against them here is a defect you would have found in production.
State is written to .runlot/dev/objects, which is outside the directory each rebuild erases. An object whose counter resets when you save a file is not a Durable Object, so it does not. The store is keyed by your project's name: renaming the project in runlot.json leaves the old state behind rather than carrying it.
What dev does not have is the platform around the object. There is one process, so there is no placement, no locationHint or jurisdiction, no cross-node stub, no park and wake, and no backup. The same local env.db, env.storage, env.auth, env.email, env.ai, assets and secrets are wired into both the default export and each object class. Local latency still says nothing about production: an object's env.db call travels to the project's home there.
env.ROOMS real class Room, state in .runlot/dev/objects
durable objects local only one process, no placement, no park and wake, no backup — env.db from an object is local here and a network call in productionThe manifest rules dev enforces are the deploy's, and the guarantee runs one way: nothing is refused here that a deploy would accept. The reverse is not promised — a manifest that passes here can still be refused at deploy.
Secrets
Names and values come from .env.runlot in the project root — one NAME=value per line, # for comments. Add the file to your .gitignore. runlot dev prints which names it loaded and never prints a value, and the names must pass the same rule a deploy enforces, so a name that works here works there.
Values reach the worker through the process environment, never through a file on disk — the same split the platform uses.
fetch()
Outbound fetch from your worker is widened in dev to reach private and loopback addresses, because pointing a local app at a local API is the ordinary reason to run one. A deployed worker reaches public addresses only. If your code calls http://localhost:…, it will work here and fail there.
Frameworks
framework: "next" builds through OpenNext on each change and serves the built output. That is the right place to check bindings, routing and the shape the deploy will have — but it is not a fast UI loop. Use next dev for the UI, and runlot dev to check what will actually ship. See Frameworks.
Pointing the CLI at another API endpoint
The CLI talks to https://dash.runlot.io unless you say otherwise. RUNLOT_API_URL or the --api flag sends a single command, or a whole shell session, somewhere else.
Login credentials are stored per endpoint, so switching targets never sends one host's token to another.
runlot versions --api https://dash.runlot.io