Skip to content
runlot
Get startedRunning locally

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 dev

runlot 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 only

State 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/cron

The 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 workerd

Or 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_dev

Teams 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 production

The 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

On this page