Self-hosting and operations
A short operator page: the environment variables that matter, what changes when you run more than one host, and what is in the system admin area. The README in the repository is the longer version.
Environment variables
Core
| Variable | Default | What it is for |
|---|---|---|
DATABASE_URL | local file | Postgres connection string. Required for embeddings and semantic search, and so for everything built on them. |
APPFIGURES_API_KEY | none | Token for the review sync. |
OLLAMA_BASE_URL | http://localhost:11434 | Where embeddings are computed. |
EMBEDDING_MODEL | mxbai-embed-large | Embedding model id. |
EMBEDDING_DIM | 1024 | Vector dimension. Must match the model and the schema. |
HOST and PORT | 0.0.0.0, 8000 | Where the server listens. |
ENABLE_SCHEDULER | true | Whether this instance runs the automatic sync. |
SYNC_INTERVAL_HOURS | 6 | Hours between automatic syncs. |
Accounts, mail and access
| Variable | Default | What it is for |
|---|---|---|
SESSION_SECRET | dev fallback | Signs session and flash cookies, and derives the key that protects registered connector secrets. Required in production, and identical on every host. |
SITE_URL | https://userken.com | Public origin used in magic links and emails. |
PUBLIC_BASE_URL | SITE_URL | The issuer for connector sign-in and the base of the /mcp resource. |
SMTP_URL | unset, mail is logged | smtps://user:pass@host:465 or smtp://user:pass@host:587. |
MAIL_FROM, MAIL_REPLY_TO | userken addresses | Headers on outgoing mail. |
DEV_LOGIN | false | Enables a loopback-only development login. Never in production. |
APP_ENV | unset | production marks a deployed instance. |
In production the server refuses to boot without SESSION_SECRET. That is deliberate: a rotating or per-host secret would sign people out at random and would break connectors that have already been approved.
Running more than one host
userken is built to run on two hosts behind a load balancer with one Postgres, no sticky sessions and no shared disk. That shapes two rules.
State lives in Postgres, never on a host. Sessions, magic links, API keys, connector grants, job progress and run heartbeats are all rows. Uploaded files are held in memory for the length of the request and never written to local disk. Any host can serve any request, including the poll that follows a job it did not start.
Scheduled work runs on one host only. The hosts elect a leader through a lease row in Postgres, renewed continuously. The leader runs the syncs and the scheduled regeneration; a secondary serves reads and takes over within a lease if the leader stops. Long-running work such as a survey run or an onboarding job is claimed atomically by whichever host is free, with a heartbeat, so a run whose host dies is picked up rather than stranded.
One deliberate exception: the per-address limit on login attempts is counted per host, so two hosts means twice the ceiling. It is a throttle, not a security boundary.
The system admin area
/app/admin is the operator area inside the app shell, visible to superusers only:
- System: the overview, including which host holds the lease.
- Data: sync schedules, and running one now.
- Embeddings: coverage, and backfilling.
- Personas: regeneration per app and per category.
- Catalogue: apps by category, adding an app, theme taxonomies.
- Sources: connector status, running one now, and the uploads.
- Accuracy: evaluation runs, starting one, and publishing a result.
- Jobs: every queued job with its status and step.
An older token-gated dashboard at /admin still exists for the same operations. The app area is the one to use: it authenticates the person rather than passing an operations token through a browser.