Skip to content

Local development

forculus dev runs your app on your machine with a small copy of our front door in front of it. Every request it passes to your app carries a real, signed identity token, so the code that verifies callers runs as it does in production. You choose who the requests come from: an owner, an editor, a viewer, an agent, a bypass token, a scheduled job, a webhook, someone the app isn’t shared with, or nobody.

It needs no account and no sign-in.

  1. In your project’s directory, start it:

    Terminal window
    forculus dev
    Creating a signing key for this session ...
    Your app is at http://127.0.0.1:8787, signed in as you@example.com (owner).
    Switch at http://127.0.0.1:8787/.forculus.

    Your app’s own output follows.

  2. Open http://127.0.0.1:8787, not the port your app listens on. Requests to your app’s own port skip the front door and carry no token.

    With the app from the SDK overview, which greets its caller:

    Terminal window
    curl http://127.0.0.1:8787/
    hello you@example.com
  3. Stop it with Ctrl-C. Your app stops too, with anything it started.

If forculus dev starts your app the wrong way, give the command after --:

Terminal window
forculus dev -- npm run dev
Option Default What it does
[DIR] . The project to run
--as <WHO> owner Who requests come from: one of the personas, or an email address
--role <ROLE> The role of an email (owner unless you say), an agent (editor) or a token (viewer)
--provider <PROVIDER> stripe Who signed the deliveries, with --as webhook: stripe, github, slack, shopify, standard_webhooks or hmac_sha256
--pattern <PATTERN> /hooks/* The public path the deliveries come to, with --as webhook: one path, like /hooks/stripe, or everything under one, like /hooks/*
--docker Run a container app in Docker. See Run a container in Docker
--port <PORT> 8787 Where the front door listens
--app-port <APP_PORT> 8788 Where your app listens. Your app is told it as PORT.
--app-id <APP_ID> 00000000-0000-4000-8000-000000000000 The app id tokens are made out to, which your app checks as the audience
--json Print one line of JSON on stdout once your app is starting, in place of the two lines that say so. See JSON for scripts
-- <COMMAND> worked out from the project What starts your app

To see the app as a viewer from the start:

Terminal window
forculus dev --as viewer

Or as a person of your choosing:

Terminal window
forculus dev --as someone@example.com --role editor

With --json, the line on stdout looks like this:

{"url":"http://127.0.0.1:8787","switch":"http://127.0.0.1:8787/.forculus","jwks":"http://127.0.0.1:8787/.forculus/jwks.json","app_port":8788,"app_id":"00000000-0000-4000-8000-000000000000","as":"signed in as you@example.com (owner)","caller":"person","email":"you@example.com","role":"owner"}
Field What it is
url Where to send requests
switch The page that changes who you are
jwks The keys your app verifies tokens with
app_port Where your app listens
app_id The audience in each token
as Who requests come from, in words, as the terminal says it
caller Who requests come from: person, agent, token, scheduler, webhook, outsider or nobody
email The person’s address, for person and outsider
role owner, editor or viewer, for person, agent, token and scheduler
agent The agent’s name, dev-agent, for agent
provider Who signs the deliveries, for webhook
pattern The path the deliveries come to, for webhook

A field that doesn’t apply to the caller isn’t there. They say who it was at the start. A change on the switch page isn’t printed.

With no command after --, it looks at the project the way forculus deploy does, and starts it like this:

The project is forculus dev runs
A static site wrangler dev serving the site’s build output as the last build left it
A Worker with a wrangler config wrangler dev with that config
A Worker without one wrangler dev on its entry file (worker.main in forculus.json, else index.js, src/index.ts and the like), with the compatibility date and flags from forculus.json
A container with container.start in forculus.json that command
A Node server npm start
FastAPI uvicorn <module>:app --host 127.0.0.1 --port 8788
Flask flask --app <module> run --host 127.0.0.1 --port 8788
Django with a manage.py python manage.py runserver 127.0.0.1:8788
Django without one gunicorn <project>.wsgi:application --bind 127.0.0.1:8788
Streamlit streamlit run <script> --server.port 8788 --server.address 127.0.0.1 --server.headless true
Gradio, or another Python script python <module>.py
Go with a main.go go run .
Rust cargo run
A Dockerfile, and no container.start the image, in Docker. See Run a container in Docker

Django needs a <project>/wsgi.py, with or without manage.py. Without one, forculus dev stops. Name the command after --, or in container.start in forculus.json.

A Go project with no main.go at its root needs the command, for example forculus dev -- go run ./cmd/server.

The ports in the table are the --app-port.

wrangler dev is the project’s own wrangler when it has version 4 or newer installed, and npx --yes wrangler@4.134.0 otherwise. Set FORCULUS_WRANGLER to run a different one.

A static site is served as its last build left it. To see a change, build again, or run the project’s own dev server with forculus dev -- npm run dev.

forculus dev starts your app with the same variables we set, pointed at itself:

Variable Value
PORT 8788 (the --app-port)
FORCULUS_APP_ID 00000000-0000-4000-8000-000000000000 (the --app-id)
FORCULUS_IDENTITY_ISSUER http://127.0.0.1:8787
FORCULUS_JWKS_URL http://127.0.0.1:8787/.forculus/jwks.json
FORCULUS_DEV 1

A Worker doesn’t read the environment it starts in, so forculus dev passes these to wrangler dev as --var bindings. Your env has them either way.

It doesn’t give your app key-value storage or a queue, and nothing answers at postgres.forculus.internal, so a Postgres connection doesn’t work either. In a Worker, env.KV and env.QUEUE are undefined. Check for them before you use them, and try that code on a preview instead.

Our issuer and key set are always https. The ones forculus dev uses are http on your machine, and the SDKs take those only when FORCULUS_DEV is 1. Without it, every request gets 401 with:

FORCULUS_IDENTITY_ISSUER and FORCULUS_JWKS_URL must use https, except under forculus dev (FORCULUS_DEV=1, on this machine)

That is all the variable does. The SDKs still check every signature, and they take an http issuer only at 127.0.0.1, localhost or [::1], with keys from there or from host.docker.internal.

We never set FORCULUS_DEV. You can’t set it on a deployed app either, since forculus env set refuses names starting with FORCULUS_, and a container starts with it empty whatever its image says. So a deployed app that still points at forculus dev refuses everyone, rather than trusting whatever answers on that port. The SDKs from version 0.3.0 work this way. Older ones take an http issuer without it.

forculus dev makes the key that signs the tokens when it starts and discards it when it stops. Nothing else trusts that key.

--as Your app gets
owner you@example.com, an owner
editor editor@example.com, an editor
viewer viewer@example.com, a viewer
agent The workspace’s agent dev-agent: kind is service, sub is agent:ag_dev, and the role is editor, or viewer with --role viewer
token A bypass token: kind is service, sub is service:bt_dev, and the role is viewer, or editor with --role editor
scheduler A scheduled job: kind is service, sub is scheduler:dev, and the role is viewer
webhook A delivery from --provider to the path --pattern opens. requireWebhook takes it, and requireUser refuses it. A request to any other path gets 401 and never reaches your app, as in production, where only an open path takes a webhook
outsider Nothing. We answer for your app as we do for someone it isn’t shared with: a page saying there’s nothing to open for outsider@example.com, or a bare 404 not found to anything that isn’t a page load
nobody A request with no token, as on a public path
an email That person, with --role (owner unless you say)

With examples/hello from our repository, which greets its caller:

Terminal window
forculus dev examples/hello --as agent
curl http://127.0.0.1:8787/
Hello, dev-agent, an agent. You're an editor here.

Open http://127.0.0.1:8787/.forculus in your browser. It shows who every request comes from, with a button for each persona and a form for any email and role. The change applies from the next request, and your app doesn’t restart.

A script or an agent can switch the same way the page does:

Terminal window
curl -H 'Origin: http://127.0.0.1:8787' -d persona=viewer http://127.0.0.1:8787/.forculus/as
curl -H 'Origin: http://127.0.0.1:8787' -d persona=agent -d role=viewer http://127.0.0.1:8787/.forculus/as
curl -H 'Origin: http://127.0.0.1:8787' -d persona=webhook -d provider=github --data-urlencode 'pattern=/hooks/github' http://127.0.0.1:8787/.forculus/as
curl -H 'Origin: http://127.0.0.1:8787' --data-urlencode email=ada@example.com -d role=editor http://127.0.0.1:8787/.forculus/as

The form accepts a post only with its own page’s Origin. A post from anywhere else gets 403 not from here.

forculus dev does what the edge does, in the same order:

  1. It deletes every X-Forculus-*, Cf-Access-* and Cf-Container-* header the client sent, spelled with dashes or underscores.
  2. It adds a new X-Forculus-Identity token that lasts 60 seconds, plus X-Forculus-Email for a person and X-Forculus-Role for anyone with a role, for display.
  3. It passes the request to your app, and your app’s response back unchanged. Streamed responses, server-sent events and WebSockets work, so hot reload does too.

As in production, a forged header has no effect:

Terminal window
curl -H 'X-Forculus-Email: boss@example.com' -H 'X-Forculus-Identity: forged' http://127.0.0.1:8787/
hello you@example.com

It answers only requests addressed to localhost, 127.0.0.1, [::1] or a name ending in .localhost, such as api.localhost. A request for any other name gets 421. This stops a web page from pointing a name it controls at your machine and sending requests your app would treat as yours.

The tokens are signed the same way as in production, but some values in them are made up:

  • sub is dev: and the email, like dev:you@example.com, not a usr_… id.
  • The workspace id is 00000000-0000-4000-8000-0000000000de.
  • The agent, token, schedule and webhook ids are made up: agent:ag_dev, service:bt_dev, scheduler:dev and webhook:dev.
  • A webhook’s pattern is the one you opened with --pattern, as in production. From Stripe, Slack or a Standard Webhooks sender every delivery gets a new delivery id and a signed_at of now. From GitHub, Shopify or HMAC-SHA256 it gets neither, since those providers sign neither. Nothing checks a provider’s signature here, since nothing sent one. To test that part, send a test delivery from the provider to a deployed app. See Webhooks and public paths.
  • Any email you enter is let in. forculus dev doesn’t know who your app is shared with. Pick outsider to see what someone gets when it isn’t shared with them.

Only forculus dev sends X-Forculus-Role. In production, read the role from the verified token.

--docker builds a container app’s image the way forculus deploy does, with your Dockerfile or the one we write, and runs it in Docker. A project that has only a Dockerfile runs this way without the flag.

Terminal window
forculus dev --docker

The image is built for your machine rather than for ours, and isn’t pushed anywhere. The container listens on PORT, 8080 unless forculus.json sets container.port, and Docker publishes it on 127.0.0.1:8788 only. That keeps it off your network, but anything on your machine can still reach it without going through the front door, and so can other containers on Docker’s network. Your app still has to verify X-Forculus-Identity, as it does on the platform, rather than trust that a request came through the front door. The container gets the variables above, except that FORCULUS_JWKS_URL is http://host.docker.internal:8787/.forculus/jwks.json, which is how a container reaches your machine. On Linux, forculus dev adds that name for the container and serves the keys, and nothing else, on Docker’s bridge address too.

When forculus dev stops, the container and the image it built are removed. Docker keeps its build cache, so the next start is quick.

You need Docker, or OrbStack, or another docker named by FORCULUS_DOCKER.

The front door can’t listen because something else has the port:

Creating a signing key for this session ...
error: Couldn't listen on 127.0.0.1:8787: Address already in use (os error 48).

Stop whatever holds it, or pick another port with --port.

Something else has the port your app is given. forculus dev starts your app itself, so whatever is there already isn’t your app, and nothing is built or started:

Creating a signing key for this session ...
error: 127.0.0.1:8788, where your app is to listen, is taken (something answers on 127.0.0.1:8788). Stop whatever holds it, or pick another port with --app-port.

Your app hasn’t started yet, or listens on a different port from the one it’s told:

Nothing is listening on 127.0.0.1:8788 yet (Connection refused (os error 61)).

Make your app listen on PORT. Some dev servers ignore it and take their port from a flag of their own. In that case, pass --app-port to match.

A name that is neither a persona nor an email:

error: "someone" isn't a persona (owner, editor, viewer, agent, token, scheduler, webhook, outsider or nobody) or an email address.

Every request gets 401 with must use https, except under forculus dev: your app runs with an SDK from 0.3.0 and didn’t get FORCULUS_DEV. forculus dev sets it from CLI 0.1.11, so update the CLI, or start your app in a way that keeps the environment it’s given.

Every request gets 401 from your app. Check that you opened port 8787 and not your app’s own port, and that your app reads the variables above rather than values of its own.