Skip to content
▚ evestack docs

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

ComponentPortBound toNotes
Agent, eve dev2000127.0.0.1A 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 interfacesNot 2000 — see below
Postgreshost 5433 → container 5432127.0.0.1 in compose5433 so it never collides with a local Postgres on 5432. Override with POSTGRES_BIND
Dashboard4000127.0.0.1 in composeA 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 dev falls back to DEFAULT_DEVELOPMENT_SERVER_HOST, which is 127.0.0.1 (dist/src/internal/nitro/host/dev-server-url.js). Loopback, not all interfaces.
  • retryOnAddressInUse is set only when no port is passed, and scripts/dev.mjs always passes --port from EVESTACK_AGENT_PORT. So a collision throws rather than moving. (eve dev with 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 postgres

The 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:bootstrap

Nothing 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_PORT

Every 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-postgrespulls @workflow/worldWorld speceve 0.30.8
5.0.0-beta.325.0.0-beta.255boots
5.0.0-beta.345.0.0-beta.276worker 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/:token and /eve/v1/connections/:name/callback/:token are 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/discord each 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 -d

Published 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 profile

To 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 startup

We 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:

SchemaOwnerHolds
workflow, workflow_drizzle, graphile_worker@workflow/world-postgresDurable sessions, runs, events, steps, hooks, waits, stream chunks
evestackevestackmemories (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.