Setup CLI & Installer
Operator reference for the Owlat self-host tooling: the install.sh one-liner, the owlat-setup CLI, the convex-deploy flow, and admin bootstrap.
Owlat ships a single self-host toolchain: a one-liner installer (install.sh) that bootstraps a host, and the owlat-setup CLI (built from apps/setup-cli/) that does everything from config through a fully deployed, seeded instance. This page is the command reference for both. For the narrative deploy guide, start at Self-Hosting; this page documents the individual commands those guides invoke.
The one-liner installer
install.sh is served at https://get.owlat.app. On a fresh Linux VPS with Docker and Docker Compose v2 installed:
curl -fsSL https://get.owlat.app | bash
The script (install.sh) does four things, then hands off to the CLI:
Preflight
Verifies curl, git, docker, that the Docker daemon is reachable (docker info), and that docker compose version works (Compose v2 is required).
Clone or detect
If you are already inside a clone (it looks for scripts/setup.sh + docker-compose.yml), it uses it. Otherwise it shallow-clones github.com/wolvesdotink/owlat into /opt/owlat (the default OWLAT_INSTALL_DIR). If the install directory's parent is not writable, it is created with sudo and chowned to you. An existing clone is fetched and checked out (detached) at the target ref.
Install the owlat CLI
Symlinks scripts/owlat to /usr/local/bin/owlat (using sudo if needed) so you can run owlat <command> from anywhere afterward.
Run the wizard
Execs the containerized quickstart: scripts/owlat quickstart --terminal. The --terminal flag is forced because the browser-based web wizard cannot run inside the containerized installer.
Because the wizard runs inside the wolvesdotink/setup Docker image, the host needs only Docker and Compose — no Bun or Node.
Installer environment variables
| Variable | Effect |
|---|---|
OWLAT_REPO | Git URL to clone (default https://github.com/wolvesdotink/owlat.git) |
OWLAT_REF | Tag/branch/ref to install (default: latest release tag; falls back to main only if the repo has no published release — HTTP 404. A hard API failure or rate-limit aborts the install rather than silently installing main.) |
OWLAT_BRANCH | Honored only as a fallback when OWLAT_REF is unset; it does not default to main |
OWLAT_INSTALL_DIR | Where to clone (default /opt/owlat; created with sudo + chowned to you if the parent isn't writable) |
OWLAT_ASSUME_YES | 1 passes --assume-yes to the wizard (CI / Ansible) |
OWLAT_CONFIG_FILE | Path to an answers file, passed as --config <path> |
OWLAT_LEGACY_WIZARD | 1 runs the legacy bash wizard (scripts/setup.sh) instead |
Non-interactive examples:
# Accept all defaults
OWLAT_ASSUME_YES=1 curl -fsSL https://get.owlat.app | bash
# Drive from a config file (CI / Ansible)
OWLAT_CONFIG_FILE=/path/to/answers.env curl -fsSL https://get.owlat.app | bash
owlat quickstart — end-to-end deploy
quickstart (apps/setup-cli/src/commands/quickstart.ts) takes a fresh clone all the way to a running, admin-bootstrapped, optionally-seeded instance. Every step is idempotent, so re-running is safe.
owlat quickstart
The flow:
- Sanity checks — confirms you are in the monorepo (a
turbo.jsonexists at--owlat-dir) and that the Docker daemon is reachable. - Config — if
.envordocker-compose.override.ymlis missing, it runs the setup wizard first; otherwise it reuses the existing config. - Mode prompt —
populated(admin + demo data, the default),blank(no admin, no data — to test the real/auth/registersignup flow), orcustom(decide per step). docker compose up -d— brings up the self-host stack.- Wait for Convex — polls
/versionon the Convex cloud port (3210). The application HTTP routes only exist after functions deploy, so the cloud port is probed here. - Deploy the backend — see Pushing Convex runtime env vars. This mints the admin key, deploys functions, and pushes runtime env vars.
- Wait for HTTP routes — polls
/api/v1/healthon the Convex site proxy (3211), where the freshly-deployedhttp.routehandlers (/seed/*,/dev/*, tracking, webhooks) live. - Bootstrap admin (unless
blank) — see Admin bootstrap. - Seed demo data (unless
blankor--no-seed). - Summary — prints the web app URL (
http://localhost:3000), the Convex URL, and the admin email.
A freshly-booted self-hosted Convex backend is empty: it serves the sync protocol and /version on the cloud port but has zero application functions, no schema, and no function-runtime env vars. Step 6 is what turns that empty backend into a working deployment — it is the step earlier installers omitted entirely.
Quickstart flags
| Flag | Purpose |
|---|---|
--mode <m> | populated | blank | custom (skips the mode prompt) |
--email <e> | Admin email for bootstrap |
--name <n> | Admin display name |
--password <p> | Admin password (minimum 12 characters) |
--no-seed | Skip demo-data seeding |
--assume-yes, -y | Accept defaults (implies populated mode, admin dev@example.com) |
--owlat-dir <dir> | Install directory (default: walk up to the monorepo root) |
# Fully scripted populated install
owlat quickstart -y --email admin@example.com --name "Admin" --password "a-strong-password"
Command reference
Run any command as owlat <command> (after the installer symlink) or bunx owlat-setup <command> from a clone. Pass --help for the full usage banner.
| Command | What it does |
|---|---|
quickstart | End-to-end deploy (config → up → deploy → bootstrap → seed). |
setup | First-run config wizard — writes .env + docker-compose.override.yml only. |
config | Re-open the wizard for an existing install (skips already-set fields). |
bootstrap-org | Create the first admin user + the singleton org. |
seed [--reset] | Populate the running instance with realistic demo data. |
reset | Wipe the instance back to blank (to re-test the signup flow). |
feature <key> <on|off> | Toggle a single feature flag and regenerate the compose override. |
pack <key> <on|off> | Toggle every flag in a feature pack. |
env <KEY> <VALUE> | Set a single environment variable in .env. |
doctor | Diagnose a broken install (.env, required vars, override, container health). |
bootstrap-org
Creates the first admin user and the singleton organization by POSTing to /seed/admin (see Admin bootstrap). It hashes the password with BetterAuth's scrypt format client-side, so the backend stores a credential that matches a normal login. It is idempotent: a 409 (admin already exists) exits 0.
owlat bootstrap-org --email admin@example.com --name "Admin" --password "a-strong-password"
seed and reset
seed POSTs to /seed/demo; reset POSTs to /dev/reset. Both endpoints are fail-closed behind OWLAT_DEV_MODE (see the dev-mode note) and require the X-Instance-Secret header. seed --reset wipes seed-tagged rows before re-inserting. reset deletes all users, the org, and every seeded row, so the next visit to http://localhost:3000 redirects to /auth/register — use it only on throwaway instances.
owlat seed # idempotent insert
owlat seed --reset # wipe seed rows, then re-seed
owlat reset # full wipe (prompts unless --assume-yes)
feature and pack
feature toggles one flag from the feature flags catalogue and regenerates docker-compose.override.yml; dependency cascades are applied and reported. pack toggles a whole grouping at once. The three packs are:
| Pack | Flags it controls |
|---|---|
emailClient | inbox, chat, postbox |
marketing | campaigns, automations, transactional |
ai | ai, ai.agent, ai.autonomy, ai.knowledge, ai.knowledge.autoLink, ai.knowledge.graphRetrieval, ai.knowledge.analytics, ai.assistant, ai.visualizations |
owlat feature ai on # enable a single flag
owlat pack marketing on # enable campaigns + automations + transactional
Both print the new active compose profiles and remind you to run owlat restart to apply.
env
Sets a single variable in .env. The key must be an uppercase shell-safe identifier; values for *_KEY, *_SECRET, and *_PASSWORD are masked in the output.
owlat env LLM_API_KEY sk-...
env writes the compose .env file, which configures the container processes (web, convex, mta). It does not push the value into the Convex function runtime. Variables read by Convex functions (the runtime env keys) must additionally be set with convex env set — re-run the deploy step, or set them through the convex-deploy container, for the functions to see them.
doctor
Runs a checklist against the install and exits non-zero on any failure:
.envexists and parses.- Every env var required by the active feature set is populated.
docker-compose.override.ymlexists.- At least one compose service is running (
docker compose ps).
owlat doctor
Wrapper lifecycle commands
The installed /usr/local/bin/owlat wrapper (scripts/owlat) also exposes day-2 ops commands that are pure docker compose ceremony — these are wrapper-only and have no owlat-setup equivalent:
| Command | What it does |
|---|---|
start | docker compose up -d (activating the profiles for enabled features). |
stop | docker compose down. |
restart [service] | docker compose restart [service]. |
status | docker compose ps. |
logs [service] | docker compose logs -f [service]. |
shell <service> | docker compose exec <service> sh (defaults to the web service). |
version | Print the running stack version (read from the web container, falling back to the local .env / package.json). |
The wrapper additionally provides backup, restore, backup-schedule, and upgrade for backups and in-app updates.
Pushing Convex runtime env vars (convex-deploy)
Convex functions read their configuration from the deployment, not from the compose .env. The compose .env only configures the container processes; it does not reach the Convex function sandbox. So a deploy has three host-side steps (apps/setup-cli/src/lib/convexDeploy.ts), all of which are pure docker compose calls:
Mint the admin key
generateConvexAdminKey() runs docker compose exec convex ./generate_admin_key.sh. The key is issued by the running backend and cannot be fabricated client-side — a random string is rejected. The minted key is written to .env as CONVEX_ADMIN_KEY so the next two steps can authenticate via compose interpolation.
Deploy functions
deployConvexFunctions() runs the one-shot deploy profile, pushing apps/api functions, schema, and http.route handlers:
docker compose --profile deploy run --rm convex-deploy
Push runtime env vars
setConvexEnvVars() runs convex env set for every populated runtime var, again through the convex-deploy container (which already pins the CLI and receives CONVEX_SELF_HOSTED_URL + CONVEX_SELF_HOSTED_ADMIN_KEY). Values are passed as argv to the container shell and consumed via positional parameters, so secrets are never interpolated by a host shell.
The list of keys that get pushed is CONVEX_RUNTIME_ENV_KEYS, defined in packages/shared/src/convexRuntimeEnv.ts and re-exported from apps/setup-cli/src/lib/convexDeploy.ts, kept in sync with the EnvKey union in apps/api/convex/lib/env.ts. It covers auth and instance secrets, site URLs, email/MTA defaults, provider keys (Resend, SES), the LLM configuration, vector store, analytics, and the inbound-channel webhook secrets. Compose-only vars (ports, image versions, NUXT_PUBLIC_*, REDIS_*) are deliberately excluded — they never belong in the deployment.
Convex serves the sync protocol and the built-in /version on the cloud port (3210), but the application http.route handlers — /seed/admin, /seed/demo, /dev/reset, tracking, webhooks, unsubscribe — live on the site proxy (3211) and only exist after functions deploy. The CLI probes 3210 while waiting for the backend to boot, then 3211 once functions are deployed. Posting /seed/* to 3210 silently 404s.
Setup wizard: web vs terminal
owlat setup (apps/setup-cli/src/commands/setup.ts) is the first-run config wizard. It writes .env and the compose override only — it does not deploy or create your admin. (config is the same wizard, re-opened for an existing install.)
There are two paths:
- Web wizard (default when a browser is available) — primes
.envfor setup mode and points you athttp://localhost:3000/setup. For SSH installs without a local browser, use the terminal wizard (below) instead. On the final "Launch" step its/api/setup/applyendpoint creates the admin (POST /seed/admin) and pushes the function-runtime env vars (EMAIL_PROVIDER,RESEND_API_KEY/AWS_SES_*, and the rest ofCONVEX_RUNTIME_ENV_KEYS) into the Convex deployment over the admin API — the HTTP equivalent ofconvex env set, since the read-only web container can't run theconvex-deploystep. So the email-provider choice actually reaches the sending code, not just.env. - Terminal wizard (
--terminal) — a full TUI covering the same questions: deployment mode, feature picker, sending provider (Owlat MTA / Resend / Amazon SES), AI provider (OpenRouter / OpenAI / custom OpenAI-compatible), optional integrations (Google Safe Browsing, PostHog), the admin account, and — for the self-hosted MTA — your EHLO and bounce domains.
The wizard generates all required secrets automatically (see below), mirrors the resolved flag state to .owlat-flags.json (so doctor, feature, and pack share the same baseline), and finishes by telling you to run owlat quickstart to actually bring the stack up.
The one-liner forces --terminal because the browser-based wizard cannot run inside the containerized installer. Choose the web wizard only when running owlat setup directly on a host with a browser.
Generated secrets
ensureSecrets() (apps/setup-cli/src/lib/secrets.ts) fills in any missing secret while preserving operator edits:
| Variable | Format |
|---|---|
BETTER_AUTH_SECRET | 48-char URL-safe token |
INSTANCE_SECRET | 64 hex chars (32 bytes) — see the note below |
UNSUBSCRIBE_SECRET | 48-char URL-safe token |
MTA_API_KEY | mta_ + 40-char token |
MTA_WEBHOOK_SECRET | whsec_ + 40-char token |
REDIS_PASSWORD | 32-char token |
CONVEX_ADMIN_KEY is intentionally not generated here — it must be minted by the running backend during the deploy step.
The self-hosted Convex backend hex-decodes INSTANCE_SECRET at boot and crashes (Couldn't hexdecode key) if it is not valid hex. The wizard generates 64 hex chars; if you set it by hand, mirror the legacy installer's openssl rand -hex 32.
Admin bootstrap
bootstrap-org (and quickstart) create the first admin by POSTing to POST /seed/admin (apps/api/convex/seedAdmin.ts).
POST /seed/admin
X-Instance-Secret: <INSTANCE_SECRET>
Content-Type: application/json
{ "email": "admin@example.com", "name": "Admin", "passwordHash": "<scrypt-hash>" }
The handler:
- Requires a matching
X-Instance-Secretheader (timing-safe comparison against the deployment'sINSTANCE_SECRET); a mismatch returns401. - Is one-shot: if any user already exists it returns
409. - On success (
201) creates the BetterAuth user, the credential account (storing the scrypt password hash), the singleton organization (slug derived from the email local part), anownermembership, the user profile, and the instance settings.
| Status | Meaning |
|---|---|
201 | Admin + org created |
400 | Invalid JSON or missing email / name / passwordHash |
401 | Missing or wrong X-Instance-Secret |
409 | A user already exists (endpoint is one-shot) |
The CLI hashes the password before sending: bootstrap-org derives a BetterAuth-compatible scrypt hash (apps/setup-cli/src/lib/passwordHash.ts) so the seeded credential matches a normal login. You never send the plaintext password to the backend.
Dev-only endpoints
/seed/demo and /dev/reset are guarded by OWLAT_DEV_MODE (apps/api/convex/devShortcuts/_guard.ts) and are fail-closed: unless OWLAT_DEV_MODE is truthy in the Convex function runtime, they return 403. Local dev installs default it on; production self-host leaves it off. quickstart flips it on only when you ask it to seed demo data. /seed/admin itself is not dev-gated — it is the production admin-bootstrap path, protected by the instance secret and the one-shot check.