Skip to content

Configuration

Writ edited this page Aug 5, 2026 · 2 revisions

Configuration

Everything is environment variables in .env at the repository root.

.env.example is the authoritative reference. Every variable is documented where it is defined, next to its default, and it ships with the code so it can never drift from what the app reads. This page is the map: what matters, in what order, and which settings interact.

Precedence

  1. docker/docker-compose.yml pins a few values that must not be overridable — the storage paths inside the container (WRIT_DB_PATH, WRIT_FILES_DIR), because they have to match the volume mount.
  2. Everything else comes from .env.
  3. Two settings are also editable at runtime in the UI, under Settings → Network, and take effect without a restart: the public URL and the trusted-host list. Once saved there, the stored value wins over the env on subsequent boots.

Secrets

./scripts/gen-env.sh generates all of these. You should never write them by hand.

Variable What it protects
WRIT_JWT_SECRET signs session tokens (mapped onto JWT_SECRET_KEY)
API_SECRET_KEY fallback token-signing key
HMAC_SECRET_KEY webhook / request signatures
RECORDER_AUTH_SECRET agent tokens
INTERNAL_API_SECRET internal service endpoints
GATEWAY_SECRET gateway service-to-service calls
DOC_EXTRACT_SECRET the document/OCR service
SECRET_ENCRYPTION_KEY encrypts your stored credentials at rest

With ENVIRONMENT=production (the default) the coordinator refuses to boot if any is missing, blank, shorter than 32 characters, or still a shipped default. That is deliberate: HS256 signs happily with an empty key, so a half-filled template would otherwise leave the signing secret publicly known.

SECRET_ENCRYPTION_KEY is the one with no recovery path — see Backup and restore.

Identity — the settings that decide whether it works at all

WRIT_PUBLIC_URL is the load-bearing one. Everything else about reachability is derived from it:

  • agents dial it, and it is baked into the /agent.sh install one-liner;
  • its hostname is automatically trusted by the Host-header allowlist;
  • absolute links in emails and OAuth metadata use it.

Required in production. Set it to the URL people actually open.

Variable Notes
WRIT_PUBLIC_URL https://writ.example.com. Required in production.
ALLOWED_HOSTS Extra hostnames only. You usually need none — the public URL's host and loopback are trusted for you. Supports *.suffix.
CORS_ORIGINS Explicit origins; * is refused in production. The SPA is same-origin, so this only matters for API clients running in a browser.
FORWARDED_ALLOW_IPS Which upstream may set X-Forwarded-For. Must name your proxy.
ENVIRONMENT production (default) or development.

FORWARDED_ALLOW_IPS is the one people get wrong. If your proxy's address is not listed, every request appears to come from the proxy: all clients share a single rate-limit bucket, one attacker's failed logins lock out everyone, and audit logs record the proxy instead of the caller. With the bundled Caddy it is 127.0.0.1,172.16.0.0/12 (the container range); with your own proxy it is that proxy's address. Never * on anything internet-reachable.

TLS

Read only by the bundled Caddy, under the tls compose profile. ./scripts/deploy.sh fills them in.

Variable Notes
WRIT_DOMAIN the hostname to request a certificate for
WRIT_ACME_EMAIL Let's Encrypt expiry / failure notices
WRIT_ACME_CA directory URL. Never blank. --staging swaps it.
WRIT_DOC_EXTRACT_DOMAIN optional second hostname for the extractor

Rate limiting

Variable Default Notes
GLOBAL_RATE_LIMIT_ENABLED true per-IP ceiling across the whole surface
GLOBAL_RATE_LIMIT_REQUESTS 600 per window
GLOBAL_RATE_LIMIT_WINDOW 60 seconds

Deliberately generous — a DoS backstop, not a quota. One browser tab loading the app and polling already spends tens of requests a minute. Static assets, the healthcheck and CORS preflights are exempt. The paths that matter carry their own much stricter limits (login and password-reset brute-force lockout, single-use pairing codes, webhook caps).

Document extraction

Variable Default Notes
DOC_EXTRACT_URL http://127.0.0.1:8092 as your AGENTS see it, not the coordinator
DOC_EXTRACT_SECRET generated must match on both sides
DOC_EXTRACT_OCR_MODE auto read the text layer when there is one; OCR only true scans
DOC_EXTRACT_TIER light rich adds docling for complex tables, and pulls torch

Set DOC_EXTRACT_URL= (empty) to disable the lane entirely.

Operational

Variable Default Notes
LOG_LEVEL info
ENABLE_METRICS off exposes unauthenticated Prometheus metrics at /metrics
REQUIRE_ADMIN_MFA false turn on after every admin has enrolled a factor
ALLOW_PRIVATE_TARGETS false let crawls reach RFC1918 / loopback. Other SSRF screens stay on regardless.
ALLOW_INSECURE_DEV false boot with the shipped default signing key. Development only, never on a network.

Things that are not configurable, on purpose

  • One worker process. The JWT blacklist, rate limits, presence and the scheduler live in in-process state over a single SQLite file. Extra workers each get an empty keyspace, so token revocation silently stops working and the scheduler double-fires. Setting WEB_CONCURRENCY>1 refuses to start. Scale browser work by connecting more agents instead.
  • Storage paths. Pinned to the /data volume by compose.

Clone this wiki locally