Self-hosting
The production runbook behind eve's self-hosting spec — schema, proxy routes, auth off Vercel, and what each one does when it's wrong.
eve's deployment guide is short and
correct. eve build writes a Nitro server to .output/, eve start serves it, a workflow
world must be built against the same @workflow/* line as your eve release, vercelOidc() is
not a production authenticator off Vercel, and your proxy must forward both /eve/ and
/.well-known/workflow/. Every one of those statements is true.
It is a specification, not a runbook. It never names a database: no schema, no migration command, no connection-string variable, no reference topology, and no note about which of these failures are loud and which are silent. This page is that half — written from the stack in this repository, running.
Quickstart is the happy path on your laptop. This is what changes when the
process is built rather than eve dev, and something other than you can reach the port.
Reference topology
| Component | Port | Bound to | Notes |
|---|---|---|---|
Agent, eve dev | 2000 | 127.0.0.1 | A bare eve dev auto-increments if 2000 is taken. A scaffolded project does not: npm run dev passes --port, so a busy port is EADDRINUSE |
Agent, eve start | $PORT, else 3000 | --host, default all interfaces | Not 2000 — see below |
| Postgres | host 5433 → container 5432 | 127.0.0.1 in compose | 5433 so it never collides with a local Postgres on 5432. Override with POSTGRES_BIND |
| Dashboard | 4000 | 127.0.0.1 in compose | A control plane; see Expose the dashboard last |
eve dev listens on 2000; eve start defaults to $PORT and then 3000. The dashboard's
default EVESTACK_AGENT_URL is http://127.0.0.1:2000, so run the built server as
PORT=2000 eve start and the rest of the stack needs no reconfiguration. Otherwise set
EVESTACK_AGENT_URL to wherever it actually landed.
Both cells in that first row were wrong until 2026-08-09, and the binding one matters on Linux. They said "all interfaces" and "auto-increments if 2000 is taken". Neither is true of a scaffolded project:
eve devfalls back toDEFAULT_DEVELOPMENT_SERVER_HOST, which is127.0.0.1(dist/src/internal/nitro/host/dev-server-url.js). Loopback, not all interfaces.retryOnAddressInUseis set only when no port is passed, andscripts/dev.mjsalways passes--portfromEVESTACK_AGENT_PORT. So a collision throws rather than moving. (eve devwith no port does auto-increment — which is why the template's own comment describing that behaviour is correct and this table was not.)
The Linux consequence. The generated compose reaches the agent at
http://host.docker.internal:2000 with a host-gateway mapping. On macOS Docker Desktop
proxies that to loopback and it works. On Linux host-gateway is the bridge IP, which a
loopback-bound eve dev is not listening on — so Postgres reads keep working while everything
the dashboard drives fails, which reads like a dashboard bug rather than a networking one.
Run the agent as eve dev --host 0.0.0.0 (or use the built server, which honours --host) if
you need the container to reach it. Not reproduced here — this laptop is macOS — so treat it as
derived from the source above rather than measured.
Both mappings in the compose file are on loopback: the dashboard's is written
127.0.0.1:${DASHBOARD_PORT:-4000}:4000 and Postgres's is
${POSTGRES_BIND:-127.0.0.1}:${POSTGRES_PORT:-5433}:5432. Set POSTGRES_BIND if you
deliberately need the database reachable from another machine — and set POSTGRES_PASSWORD
in the same breath, because the repo's compose still defaults it to evestack, which is
only defensible while the port is on loopback.
This paragraph used to describe an asymmetry — the dashboard on 127.0.0.1 and Postgres
"published on every interface" — and told you to prefix the mapping yourself. That was true
when it was written and is not now: both compose files bind loopback, and the one
create-evestack generates also writes a per-project password rather than a default. Left
visible rather than quietly deleted, because a runbook that overstates an exposure spends
the reader's trust on the paragraphs that are still true.
There is no OTLP collector in this topology and nothing listens on 4318. Trace export goes to
the dashboard's own ingest route (http://localhost:4000/api/ingest/v1/traces), because
@vercel/otel uses the configured URL verbatim — the conventional collector address will not
reach it. See Observability.
Deploy
eve, the @workflow/* line, and the world package are one compatibility unit, and only one
of the three is checked for you. The shipped package.json pins eve at ^0.30.8 and
@workflow/world-postgres at exactly 5.0.0-beta.32 — an exact version, not a range and not
a dist-tag. >=0.30.0 is the floor for any deployment reachable from a network — see
Auth off Vercel.
docker compose up -d postgresThe compose file uses pgvector/pgvector:pg17 rather than plain postgres, because the
same database also backs agent memory — one container, two jobs. Data lives
in the named volume evestack-pgdata.
npm run db:bootstrapNothing creates it for you. @workflow/world-postgres runs its migrations only from its own
CLI and eve never invokes it, so a server started against a fresh database starts against a
database with no tables. Run this before the first boot and again after upgrading the world
package.
placeholderAuth() and vercelOidc() — what stock eve init scaffolds — are both wrong
off Vercel. agent/channels/eve.ts in this template ships httpBasic reading
EVESTACK_AUTH_USER / EVESTACK_AUTH_PASSWORD, which create-evestack generates per
project. JWT (HMAC or ECDSA), generic OIDC, and custom verifiers are equally valid; eve owns
that surface and documents it in
auth and route protection.
npm run build # eve build -> .output/
npm start # eve start, on EVESTACK_AGENT_PORTEvery eve command loads .env/.env.local from the app root first, eve start included,
so the same file that drives eve dev drives the built server. No PORT= prefix is needed
and this page used to print one: scripts/start.mjs passes EVESTACK_AGENT_PORT through as
--port, which beats $PORT and eve's own default of 3000. That is the number npm run verify probes and the number the generated compose file points the dashboard at, so there is
one answer to "where is the agent" rather than three.
eve does not supervise itself. A unit and a plist ship in the project at deploy/ — see
Operations, which also covers why the agent is
not a compose service beside Postgres and the dashboard.
Forward /eve/ and /.well-known/workflow/, unrewritten. Config for nginx and Caddy is
below. This is the step that silently half-works if you get it wrong.
curl https://agent.example.com/eve/v1/health
curl -u "$EVESTACK_AUTH_USER:$EVESTACK_AUTH_PASSWORD" https://agent.example.com/eve/v1/info/eve/v1/health is a Nitro-level route outside the channel's auth policy, so a 200 there
proves the proxy path and nothing about your credentials. /eve/v1/info is registered and
authenticated with the same auth input as the session routes, so it is the one that proves
both. Then drive a real turn: eve dev https://agent.example.com attaches the TUI to a
deployed server.
The Postgres world
Pin @workflow/world-postgres to an exact version — 5.0.0-beta.32, which is what
templates/default declares. Never latest, and no longer the beta dist-tag either.
latest is a whole major behind the protocol eve speaks and always has been. beta was the
documented workaround for that, and it stopped being safe: upstream ships World spec
changes inside the 5.0.0-beta.* line with no semver signal, so the same tag hands out
incompatible worlds on different days. Measured against eve 0.30.8:
world-postgres | pulls @workflow/world | World spec | eve 0.30.8 |
|---|---|---|---|
5.0.0-beta.32 | 5.0.0-beta.25 | 5 | boots |
5.0.0-beta.34 | 5.0.0-beta.27 | 6 | worker init failed at startup |
On 2026-08-19 the beta tag moved from .34 to .35 inside a single working session, so
"check the tag yourself" is not a defence — the answer expires. ^5.0.0-beta.32 and
~5.0.0-beta.32 are not fixes either: both still resolve to .34 and .35. Move the pin only
after installing a candidate and watching it boot.
Selecting the world is one field in agent/agent.ts, and it is conditional on the connection
string being present:
const workflow = process.env.WORKFLOW_POSTGRES_URL
? { world: "@workflow/world-postgres" }
: undefined;That conditional is the silent failure to know about. With WORKFLOW_POSTGRES_URL unset or
unreachable, eve falls back to its on-disk world under .eve/.workflow-data and keeps working —
the agent answers, sessions resume, and nothing in the logs reads like an error. You only find
out when the container is replaced and the history is gone. If you deliberately run the on-disk
world, mount that directory on persistent storage.
npm run db:bootstrap creates three schemas — workflow, workflow_drizzle, and
graphile_worker — and, inside workflow, the tables workflow_runs, workflow_events,
workflow_steps, workflow_hooks, workflow_waits, and workflow_stream_chunks. workflow_runs
is the one you will query: its attributes JSONB column holds eve's $eve.* run tags, which is
why the dashboard needs no ingest pipeline. See Architecture.
Run bootstrap through the db:bootstrap script, not as
npx --package=@workflow/world-postgres bootstrap. That CLI loads .env through dotenv and
never reads .env.local — the only env file create-evestack writes — so it falls back to
postgres://world:world@localhost:5432/world and dies on ECONNREFUSED. The script passes
--env-file-if-exists=.env.local explicitly.
Two more variables matter under load. world-postgres defaults to a worker concurrency of 50
against a pool of 10 and warns about it on every boot; WORKFLOW_POSTGRES_MAX_POOL_SIZE and
WORKFLOW_POSTGRES_WORKER_CONCURRENCY (both 20 in the shipped .env.example) silence the
warning and stop workers queueing on connections.
Auth off Vercel
eve fails closed: when no authenticator in the chain grants, routeAuth() answers 401. That is
the correct posture and it produces one result that reliably reads as a bug.
On a built server, 127.0.0.1 gets a 401 too — and that is correct. From eve 0.30,
localDev() grants on the process being an eve dev / vercel dev run (EVE_DEV=1) and
consults nothing in the request. eve build && eve start is not that process, so nothing is
granted implicitly and every request needs the Basic credentials, loopback included. Measured
against this stack: all hosts 401, correct credentials 200.
The inverse used to be true, and it was exploitable. On eve 0.29.x, localDev() decided "is this
my machine" from the request URL's hostname — derived from the client's Host header — matched
against an unanchored /^127\./ plus endsWith(".localhost"). So 127.evil.com, a name anyone
can register and point at your agent, received a full local-dev principal with no credentials:
session creation and tool execution, unauthenticated, from the internet. We measured it (that
host answered 200 where a plain foreign host answered 401) and shipped a strictLocalDev()
wrapper. Vercel fixed it properly upstream in 0.30.0 and isLoopbackRequest is gone, so the
wrapper was deleted rather than kept — on 0.30 it could add no protection and would have rejected
legitimate local-dev access over a LAN IP, a tunnel, or a container hostname.
Pin eve >=0.30.0. That is the release the fix landed in, and it is the same floor
SECURITY.md and every published peer range state; the template pins ^0.30.8 because that is
the release evestack tests against, which is a different question.
contract/contracts/07-auth.contract.mjs asserts both
halves of the fixed behaviour — that no hostile Host makes localDev() grant, and that it
still grants inside EVE_DEV=1 — and it goes red against 0.29.5. See
The contract suite in contract/ records which eve versions have been run against it.
The route-auth policy does not cover everything under /eve/
Two categories of framework route are outside it by design, and putting a blanket credential
check in front of /eve/ in your proxy breaks both:
- Callbacks.
/eve/v1/callback/:tokenand/eve/v1/connections/:name/callback/:tokenare unauthenticated by design. An OAuth IdP arrives by 3xx redirect from a user's browser with no eve credentials attached; the unguessable token is the capability that authorizes the resume. - Chat channels.
/eve/v1/slack,/eve/v1/telegram, and/eve/v1/discordeach carry their own inbound verification — an HMAC signature, a secret token, an Ed25519 signature. No chat provider can answer a Basic challenge, so fronting these paths with one trades a signature check for nothing. See Channels.
Reverse proxy
Two prefixes, both forwarded, neither rewritten:
/eve/— health, sessions, streams, channels, tools, subagents/.well-known/workflow/— workflow callbacks
Forwarding only /eve/ is the most common self-hosting failure and it is not a clean one: the
session starts, returns 202 with a continuation token, and then stalls forever because the run's
callback cannot get back in. Nothing errors. It just never finishes.
server {
listen 443 ssl;
server_name agent.example.com;
# No URI part on proxy_pass — that is what keeps the path unrewritten.
# `proxy_pass http://127.0.0.1:2000/;` (trailing slash) strips the location
# prefix and breaks both route families.
location /eve/ {
proxy_pass http://127.0.0.1:2000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 600s; # a turn can run for minutes
}
location /.well-known/workflow/ {
proxy_pass http://127.0.0.1:2000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}The equivalent Caddyfile — reverse_proxy passes the path through as received:
agent.example.com {
reverse_proxy /eve/* 127.0.0.1:2000
reverse_proxy /.well-known/workflow/* 127.0.0.1:2000
}You do not need to disable proxy buffering for nginx specifically: eve's message-stream route
(GET /eve/v1/session/:sessionId/stream) sets x-accel-buffering: no and
cache-control: no-store, no-transform on its own response. Any proxy that does not honour that
header has to be told not to buffer, or streamed turns arrive all at once at the end.
Sandbox backend
docker() is the default, set in agent/sandbox/sandbox.ts. Do not use vercel() off Vercel —
it creates hosted Vercel sandboxes from your self-hosted process, which is the one thing this
stack exists to avoid.
eve keys sandboxes to durable sessions and keeps one long-lived container per session, persisting
/workspace across turns with no idle timeout. Verified on this stack: a container named
eve-sbx-ses-docker-…-<runId>-__root__ came up per session, with working bash, uname -s
reporting Linux, and a working directory of /workspace. It needs a Docker daemon the agent
process can reach, and nothing else.
The sandbox ships with egress denied. EVESTACK_SANDBOX_NETWORK defaults to deny-all, so a
scaffolded agent's shell has no network until you say otherwise — which is the right default and
also the answer to "why can't the agent curl anything". resolveNetworkPolicy() in
templates/default/agent/sandbox/sandbox.ts treats an unrecognised value as a hard error rather
than falling back to either side, because allow_all and none are typos and guessing wrong
gives you either a broken shell or an open one.
One further limit worth knowing before you plan network policy: the Docker backend honours only
allow-all and deny-all. A domain allow-list needs a different backend. microsandbox() (macOS
on Apple Silicon, or Linux with KVM) is one. The other is
@evestack/sandbox-opensandbox,
which runs the sandbox on an OpenSandbox server you host instead of on your Docker daemon, and
which takes a domain allow-list from 0.4.0 on — but only as opensandbox({ networkPolicy }), fixed
for that sandbox's whole life. OpenSandbox sets a sandbox's default action when the sandbox is
created and its run-time egress API preserves it, so on that backend session.setNetworkPolicy()
rejects instead of half-applying a restriction, subnets (IP/CIDR) and per-domain transform /
forwardURL rules throw when the backend is constructed rather than being dropped, and changing
the policy afterwards means a new session rather than a re-egressed sandbox.
Be precise about what that buys you, because isolation is the reason people reach for it. An
OpenSandbox server can be configured on a gVisor, Kata or Firecracker runtime — but which
runtime a sandbox lands on is the server's decision, not the adapter's: the client SDK has no
runtime selector, so this backend neither requests one nor can verify it got one. On the plain
Docker runtime, which is what it was tested against, you get container namespaces — the same
isolation docker() already gives you, with a server in front of it. Do not choose it as
kernel-boundary isolation unless you have independently confirmed your own server runs a secure
runtime. Use Docker unless you have a specific reason not to.
The one-line swap is also a registry item, @evestack/docker-sandbox, if you
are adding it to an existing eve project rather than starting from the template.
The dashboard container
The dashboard ships as an image, not as an npm package — npm has no way to run a Next.js
application, so @evestack/dashboard is private: true and the deliverable is
ghcr.io/sammytourani/evestack-dashboard. It is public and needs no registry login to pull.
A scaffolded project's docker-compose.yml already points at it, pinned to the version tested
with that template:
docker compose --profile dashboard up -dPublished since dashboard-v0.1.0 as a multi-arch manifest — linux/amd64 and linux/arm64,
~230 MB compressed per platform, about 1 GB unpacked.
.github/workflows/publish-dashboard.yml builds each arch on its own native runner and merges
the digests, so neither is emulated. Check any tag yourself with
docker manifest inspect ghcr.io/sammytourani/evestack-dashboard:<version> and sum the layer
sizes.
Building it yourself
You need this for two reasons and no others: you are changing the dashboard, or you want an
image in a registry you control. Otherwise pull it. The context is the repository
root, not packages/dashboard:
docker build -t ghcr.io/sammytourani/evestack-dashboard:0.4.0 -f packages/dashboard/Dockerfile .That is not a style preference. packages/dashboard/package.json declares
"@evestack/schedules": "workspace:*", and workspace: is a pnpm protocol npm does not
implement; a build scoped to the package directory ends at EUNSUPPORTEDPROTOCOL before it
installs anything. The Dockerfile therefore runs pnpm install --filter @evestack/dashboard...
at the root, against the real lockfile, and builds @evestack/schedules first because its
dist/ is gitignored and a cold clone has none.
Tagging it with the published name rather than something like evestack-dashboard:local is
what makes a scaffolded project find it with no further configuration: Docker uses a local image
when one exists under that name and only reaches for the registry when it does not. From this
repository the same build is one command, because the root compose file declares both image:
and build::
docker compose --profile dashboard up -d # `--profile full` is the same profileTo run something else entirely — a fork, a private registry, a differently-named local build —
set EVESTACK_DASHBOARD_IMAGE. Both compose files read
${EVESTACK_DASHBOARD_IMAGE:-ghcr.io/sammytourani/evestack-dashboard:<version>}, so it is a
one-variable change and not a topology change.
The version tag tracks packages/dashboard/package.json. The publish workflow refuses to
release when the git tag, that version, and the tag the scaffolder writes into generated
compose files disagree — the failure it prevents is silent, since a release that publishes
:0.1.1 while every scaffold asks for :0.1.0 looks entirely successful and is a 404 for every
user.
Expose the dashboard last
The dashboard is not a viewer: it can start sessions, send follow-ups, resolve pending tool approvals, and cancel runs. Two independent controls stand in front of that, and it is worth being precise about which one does what.
The port mapping. docker-compose.yml publishes 127.0.0.1:4000:4000, so the container is
reachable from the host and from nowhere else. The process inside the container binds
0.0.0.0 — it has to, or nothing outside the container's own network namespace could reach it,
including Docker's own HEALTHCHECK. Exposure is decided by the mapping, not by the bind
address, and docker run -p 4000:4000 on the image by hand puts the control plane on every
interface the host has.
The credential. Every route requires EVESTACK_AUTH_USER and EVESTACK_AUTH_PASSWORD, and
with either missing the dashboard serves nothing usable: 503 on every request except
GET /signin, which renders the reason and no sign-in form, and GET /api/health, whose own
handler answers 503 {"status":"unconfigured"} and so reports the container unhealthy. There is
no bypass flag. Browsers get a signed HttpOnly
session cookie from /signin; scripts send HTTP Basic. Details in
packages/dashboard/README.md.
The one exception is trace ingest, whose caller is a program. /api/ingest/v1/traces takes
EVESTACK_INGEST_TOKEN in an x-evestack-ingest-token header, and the agent must hold the same
value — create-evestack generates it into the .env.local that both the host agent and the
dashboard container read. Split them across hosts and you have to copy it yourself; get it wrong
and every span is refused with a 401 that the agent's OTLP exporter records as a successful
export, so the symptom is an empty Traces tab rather than an error. See
Observability.
What the credential does not buy. It is one shared secret per deployment, so approver in
the audit log names an installation, not a person. There is no lockout, no rate limit and no
second factor in front of it. It is sent as Basic over whatever transport you provide, so
without TLS it is on the wire in base64. And it protects HTTP routes, not the database: the
credential does nothing for Postgres, which is on loopback in both compose files and carries
a per-project password only in the generated one. The repo's own compose still defaults to
${POSTGRES_PASSWORD:-evestack}, so widening POSTGRES_BIND without also setting a password
hands the session store to anyone who can route to the host.
So: still put the dashboard behind something you already trust before it leaves loopback — a reverse proxy doing OAuth, Cloudflare Access, Tailscale, a VPN. The difference from before is that a slip in that layer is no longer immediately an unauthenticated button that can approve a shell command.
For per-person attribution rather than per-installation, put a proxy that authenticates people
in front and set EVESTACK_TRUSTED_PROXY. Until that is set, X-Forwarded-User,
X-Forwarded-Email and EVESTACK_APPROVER_HEADER are not read at all — they are three
words of curl away from anyone who can reach the port, and an audit log that can be dictated to
is worse than one that admits it knows nothing.
Serve it over TLS if it is reachable from anywhere but your own machine. The session cookie is
Secure when the request is https, when EVESTACK_PUBLIC_URL is https, or when a trusted proxy
reports X-Forwarded-Proto: https.
If the dashboard is not on the same host as the agent, give it EVESTACK_AGENT_URL plus the
agent's own EVESTACK_AUTH_USER / EVESTACK_AUTH_PASSWORD — it attaches Basic credentials to
agent calls only when both are set, which is the same pair it signs you in with. Its required
variables are now WORKFLOW_POSTGRES_URL and that credential; everything on the observe side
is still a SQL read. See The dashboard.
Operations
Everything below is about the deployment you already have. Three things that only matter once you
leave it running — supervising the agent, capping container logs, and pruning a workflow schema
that nothing prunes for you — have a page of their own: Operations.
Restarts
State never lives in the process, so restarting is not an event. On boot against Postgres, world-postgres reclaims what was in flight and says so:
[world-postgres] Re-enqueued 2 active run(s) on startupWe proved this the hard way rather than by reading it: killed the dev server, stopped and started
the Postgres container, restarted the agent, and a follow-up on a pre-restart session recalled the
first message verbatim. Ordering follows from that — Postgres has to be accepting connections
before the agent starts, which is what the compose healthcheck and its service_healthy
condition enforce for the dashboard.
The agent has no such condition because it is not a compose service. Under a unit it exits fast and the restart policy retries, which is the same guarantee reached differently; see Operations for why the restart limiter has to be disabled for that to work.
Backups
It is your database, which is the whole trade. Everything durable is in the one Postgres:
| Schema | Owner | Holds |
|---|---|---|
workflow, workflow_drizzle, graphile_worker | @workflow/world-postgres | Durable sessions, runs, events, steps, hooks, waits, stream chunks |
evestack | evestack | memories (pgvector), approvals, the memory-deletion audit log, ingested spans |
So a database-level dump is the backup — pg_dump of the whole database, not of a schema, and
not a copy of the container. The evestack-pgdata volume is the other thing to know exists:
docker compose down -v removes it and takes every session with it.
Two schema-specific notes. evestack.approvals is retained forever by design — it is the row
someone wants a year later, when they ask why the agent deleted the thing it deleted. And the
agent's evestack.memories table is created lazily on the first remember call, so a restore
into an empty database is fine; the table comes back on use, HNSW index and all.
A third that is easy to assume the wrong way round: the workflow schema has no retention at
all. evestack.spans expires on a 30-day window and prunes itself, which makes it natural to
assume the rest of the database does something similar. It does not — world-postgres deletes
nothing, so every run, event and step is still there. Roughly 11 MB per month at 700 sessions,
measured. Operations has the
opt-in pruning procedure and, more importantly, what is safe to delete and what eve still needs.
A backup you have never restored is a hypothesis. Restoring into a scratch database and pointing a throwaway agent at it costs one container and answers the question.
The trace spool expires; your database does not
eve's zero-config trace spool under .eve/traces/v1 is written only by local dev and is bounded
on purpose: EVE_TRACES_MAX_AGE_MS defaults to 7 days, EVE_TRACES_MAX_TOTAL_BYTES to 512 MB,
and EVE_TRACES_RETAIN_COUNT keeps the newest 20 regardless. eve sweeps when a session finishes
and when the dev server starts. It is a debugging buffer, not a record.
Two consequences for a self-hosted deployment. The spool is not a system of record — anything you
need next quarter has to be in Postgres or in a backend you operate. And authoring
agent/instrumentation.ts at all disables that spool, so eve traces stops working the moment
you wire up trace export; delete the file to get it back. Details in
Observability.