Self-hosting — browse guides

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

Production

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

VariablePurpose
DATABASE_URLPostgres connection string.
QA_AUTH_DUALWhether built-in auth runs alongside Clerk.
QA_BLOCK_PRIVATE_HOSTSWhether to refuse loopback/RFC1918 targets. See below.
QA_ALLOW_SETUP_COMMANDSWhether project setup actions may run shell commands.

Commonly set

VariablePurpose
PORT / HOSTWhere the server listens. Defaults to 127.0.0.1:4545.
QA_ENCRYPTION_KEYKey for sealing stored credentials and BYOK AI keys.
S3_*Object storage for artifacts. Omit to use local disk.
QA_MAX_BROWSERSCap on concurrent browsers process-wide.
CLAUDE_CLI_PATHPath 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$1
lt;salt>
lt;hash>
UPDATE users SET password_hash = 'scrypt$v1$32768$8$1
lt;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

WhatWhere
Projects, tests, runs, historyPostgres
Videos, failure screenshots, visual diffsS3, 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).sql

Restore 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 none

The 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 need QA_AI_PROVIDER=openai-compatible for 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_URL is 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