Self-hosting Vera
Run Vera on your own infrastructure: prerequisites, production build, the configuration that matters, backups, and the two settings to get right.
Vera runs on your own infrastructure. In self-hosted mode every feature is on — there is no plan gating and no billing — and no test data leaves your network.
What you need
- Node 22.12 or newer (the job queue, pg-boss 12, requires it), and
pnpm - PostgreSQL — the single source of truth for projects, tests, runs and history
- Playwright browsers, installed once
- Optional: S3-compatible object storage for run artifacts (videos, screenshots, diffs). Without it, artifacts are written to local disk.
- Optional: Google Chrome, if you want real Chrome mode
Development stack
The repository ships a compose file with Postgres and MinIO (an S3-compatible store):
docker compose up -d # Postgres on :5439, MinIO on :9000 (console :9001)
pnpm setup # install deps, build the DB package, install browsers
pnpm dev # API on :4545, dashboard on :4546Production
pnpm install
pnpm playwright:install
pnpm build # builds shared → db → server + dashboard
pnpm start # serves the API and the built dashboard on one port (:4545)pnpm start serves everything from a single origin — API, dashboard, and run
artifacts — so there is no CORS configuration and no second deploy target.
Migrations are committed to the repository and applied automatically on startup, so a deploy does not need a separate migrate step.
Configuration
Every environment variable goes through one validated schema. .env.example
documents all of them; these are the ones that matter first.
Required
| Variable | Purpose |
|---|---|
DATABASE_URL | Postgres connection string. |
QA_AUTH_DUAL | Whether built-in auth runs alongside Clerk. |
QA_BLOCK_PRIVATE_HOSTS | Whether to refuse loopback/RFC1918 targets. See below. |
QA_ALLOW_SETUP_COMMANDS | Whether project setup actions may run shell commands. |
Commonly set
| Variable | Purpose |
|---|---|
PORT / HOST | Where the server listens. Defaults to 127.0.0.1:4545. |
QA_ENCRYPTION_KEY | Key for sealing stored credentials and BYOK AI keys. |
S3_* | Object storage for artifacts. Omit to use local disk. |
QA_MAX_BROWSERS | Cap on concurrent browsers process-wide. |
CLAUDE_CLI_PATH | Path to the claude binary, for the key-free AI provider. |
Many runtime knobs are additionally admin-editable from the /admin console
(browser cap, scheduler tick, recorder idle timeout, private-host policy, AI
provider defaults) — env supplies the default, the database overlays overrides, and
no restart is needed.
A few are deliberately env-only and cannot be changed over the API: the port and
host, DATABASE_URL, the S3 credentials, the encryption key and CLAUDE_CLI_PATH.
That last one names a binary the server spawns, so making it editable over HTTP
would be a remote-code-execution vector.
Two settings to get right
HOST. The API has no authentication in local mode, so it binds 127.0.0.1 by
default. Setting HOST=0.0.0.0 exposes it to the network — do that only where the
network is trusted, or behind a reverse proxy that authenticates.
QA_BLOCK_PRIVATE_HOSTS. Vera navigates to URLs you give it, and testing your
own app on localhost is the core use case, so loopback and private ranges are
allowed by default. Cloud-metadata and unspecified addresses are always blocked.
On a shared or multi-tenant host, set this to true — otherwise a tenant can point
a test at your internal network.
Accounts and password reset
Built-in email-and-password accounts are the self-hosting sign-in strategy.
They exist so a private deployment, CI and local development work with no identity
provider at all. Vera Cloud does not use them — it signs people in through Clerk,
which owns registration and verification there — and they are deliberately not a
second signup funnel: there is no self-registration endpoint. Accounts come from
the seeded platform admin (QA_SEED_ADMIN_EMAIL / QA_SEED_ADMIN_PASSWORD) and
from invitations that admin sends.
A user who forgets their password uses Forgot your password? on the sign-in
page. That link and the two routes behind it are live only where built-in accounts
exist (QA_AUTH_MODE=builtin, or Clerk with QA_AUTH_DUAL=true); elsewhere they
return 404 and the identity provider's own reset flow applies. The emailed link
works once and expires after an hour, and redeeming it ends every signed-in
session on that account.
Reset needs QA_PUBLIC_URL on any network-reachable deployment. The link in
the email must point at your deployment, and the only trustworthy statement of
where that is comes from your configuration: Origin and Host are request
headers, and this endpoint is reachable without signing in, so building the link
from them would let anyone send a real Vera reset email whose link delivers the
token to them. Vera therefore uses QA_PUBLIC_URL, and accepts the request's own
origin only when the server binds loopback and is not behind a proxy — local
development and single-machine self-host. Anywhere else, with QA_PUBLIC_URL
unset, no reset email is sent at all and the server logs a warning saying why.
Reset needs a working mail channel. Set RESEND_API_KEY and QA_EMAIL_FROM
(with SPF, DKIM and DMARC published for the sending domain). Without them the
request still succeeds — it always answers the same way, whether or not the address
has an account, so it cannot be used to discover who your users are — but nothing is
delivered, and the server logs a warning saying so.
Resetting a password without email. Vera stores a versioned scrypt hash, never a plaintext or a plain digest, so there is no SQL-only way to set one: generate the hash with the same function the seed uses, then write it.
pnpm --filter @vera/server exec tsx -e \
"import('./src/services/passwords.js').then((m) => m.hashPassword('the-new-password')).then(console.log)"
# → scrypt$v1$32768$8$1lt;salt>lt;hash>UPDATE users SET password_hash = 'scrypt$v1$32768$8$1lt;salt>lt;hash>'
WHERE lower(email) = 'admin@example.com';
DELETE FROM sessions WHERE user_id = (
SELECT id FROM users WHERE lower(email) = 'admin@example.com'
);The DELETE is the manual half of what the reset route does for you: changing the
hash does not by itself invalidate a session cookie already issued.
Data & storage
| What | Where |
|---|---|
| Projects, tests, runs, history | Postgres |
| Videos, failure screenshots, visual diffs | S3, or data/ on local disk |
Any S3-compatible store works. One flag matters and is easy to get wrong:
S3_FORCE_PATH_STYLE. MinIO needs path style (endpoint/bucket/key) and is the
default whenever S3_ENDPOINT is set; Railway Buckets, Tigris and AWS want
virtual-hosted (bucket.endpoint/key) and need S3_FORCE_PATH_STYLE=false. Your
store's own credentials page states which it requires — it is not safely inferable from
the endpoint.
The server serves only the videos, screenshots and diffs subdirectories of the
data directory. That is deliberate and worth preserving if you change it: serving the
whole directory would expose anything else kept there.
Backups
Postgres holds everything that cannot be regenerated. pg_dump is safe while the
server is running:
pg_dump "$DATABASE_URL" > vera-$(date +%F).sqlRestore into a fresh database and point DATABASE_URL at it. Artifacts are large and
regenerable by re-running, so back them up only if run history matters to you as
evidence.
The dump contains project credentials and sealed keys. Treat it as a secret.
Running hosted Vera rather than self-hosting? This section is the self-hosting answer, where you own the database and can decide a periodic dump is enough. The operator-side story — provider snapshots, PITR, artifact protection, RPO/RTO and a repeatable restore rehearsal — is
disaster-recovery.md.
Whichever you run: sealed columns in the dump are AES-256-GCM, so restoring under a
different QA_ENCRYPTION_KEY does not degrade gracefully — the rows restore fine and
then every read of a credential throws. Back the key up somewhere that survives losing
the machine, or the backup is only half a backup.
Keeping it running
Run it under whatever supervisor you already use — systemd, PM2, a container orchestrator. Two things to configure whichever you pick:
- Graceful shutdown. On SIGINT/SIGTERM Vera stops the scheduler, ends recorder sessions, closes browsers, then closes the server, with a hard deadline. Give it a stop timeout of at least 15 seconds so in-flight runs finish writing rather than being killed mid-write.
- Restart on failure, and start on boot.
Retention
Run retention is off by default: history accumulates until you set a policy. On a long-lived instance that is worth configuring — runs and their artifacts are the bulk of the storage growth. See Analytics.
AI
AI features are bring-your-own-key. Self-hosted, you can either configure a provider
key or use the local claude CLI as a key-free provider by pointing
CLAUDE_CLI_PATH at it. Nothing is sent to a Vera-operated service either way.
Any OpenAI-compatible endpoint
QA_AI_PROVIDER=openai-compatible points generation at anything speaking OpenAI's
chat-completions dialect — Ollama, LM Studio, vLLM, an API gateway, a free-tier host:
QA_AI_PROVIDER=openai-compatible
QA_AI_BASE_URL=http://127.0.0.1:11434/v1 # note the /v1 — Ollama's OpenAI surface
QA_AI_BASE_MODEL=llama3.2 # free-form; one id serves every tier
QA_AI_BASE_API_KEY= # optional; a local Ollama needs noneThe URL and the model must be set together — half a declaration is refused at boot. They are environment-only on purpose: the base URL is a server-side fetch target, so no admin screen and no workspace setting can repoint it.
The in-app assistant, and an Ollama sidecar
"Ask Vera" is the assistant in the command palette: ask a question in your own words and get a short answer written out of the published guides, with links to the pages it used. It is optional, and off unless you give it a model.
- Without one, ⌘K still searches the guides offline — a ranked index built into the dashboard, no request and no model. That is the answer this feature sits on top of, and it keeps working whatever you do here.
- With one, the same question also gets a written answer. The endpoint is
QA_AI_BASE_URL— the block above — and you do not needQA_AI_PROVIDER=openai-compatiblefor it. Declare the endpoint and leave the provider alone, and the assistant uses it while generation, healing and repair carry on wherever they were pointed.
The assistant never uses a workspace's own API key and never spends the free generation grant. It runs on the endpoint you supply or it does not run — which is why it is safe to leave it off, and why the compute bill is yours to size.
A small model is the right size for it: the job is to rephrase documentation that is already in the prompt, not to reason. A 3B–8B instruct model on the same host is plenty, and nothing leaves that host.
Ollama beside the server (docker compose):
services:
ollama:
image: ollama/ollama:latest
volumes:
- ollama:/root/.ollama
# No published port: only the Vera server needs to reach it, over the
# compose network. Publishing 11434 would expose an unauthenticated
# inference API on the host.
restart: unless-stopped
vera:
# …your existing service…
environment:
QA_AI_BASE_URL: http://ollama:11434/v1
QA_AI_BASE_MODEL: llama3.2
depends_on:
- ollama
volumes:
ollama:Pull the model once, into the volume: docker compose exec ollama ollama pull llama3.2.
Two things to watch:
- CPU-only is slow but fine. A 3B model on a few CPU cores answers a documentation question in seconds. Budget RAM for the model (roughly its file size) on top of what Vera and Postgres already use.
- Keep the endpoint private.
QA_AI_BASE_URLis fetched by the server, and an Ollama with a published port is an open inference API. Bind it to the compose network (as above) or to loopback.
Not a managed offering. Vera's own hosted deployment ships no such endpoint — the plumbing is here, the compute is the operator's. On a hosted Vera the assistant is off and ⌘K's guide search is the answer.
See also
- Getting started — first project and first green test
- The local agent — the alternative: cloud control plane, local execution
- CI integration — running your instance from a pipeline
- Test engines — adding a Selenium grid