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

VariableEffect
OWLAT_REPOGit URL to clone (default https://github.com/wolvesdotink/owlat.git)
OWLAT_REFTag/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_BRANCHHonored only as a fallback when OWLAT_REF is unset; it does not default to main
OWLAT_INSTALL_DIRWhere to clone (default /opt/owlat; created with sudo + chowned to you if the parent isn't writable)
OWLAT_ASSUME_YES1 passes --assume-yes to the wizard (CI / Ansible)
OWLAT_CONFIG_FILEPath to an answers file, passed as --config <path>
OWLAT_LEGACY_WIZARD1 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:

  1. Sanity checks — confirms you are in the monorepo (a turbo.json exists at --owlat-dir) and that the Docker daemon is reachable.
  2. Config — if .env or docker-compose.override.yml is missing, it runs the setup wizard first; otherwise it reuses the existing config.
  3. Mode promptpopulated (admin + demo data, the default), blank (no admin, no data — to test the real /auth/register signup flow), or custom (decide per step).
  4. docker compose up -d — brings up the self-host stack.
  5. Wait for Convex — polls /version on the Convex cloud port (3210). The application HTTP routes only exist after functions deploy, so the cloud port is probed here.
  6. Deploy the backend — see Pushing Convex runtime env vars. This mints the admin key, deploys functions, and pushes runtime env vars.
  7. Wait for HTTP routes — polls /api/v1/health on the Convex site proxy (3211), where the freshly-deployed http.route handlers (/seed/*, /dev/*, tracking, webhooks) live.
  8. Bootstrap admin (unless blank) — see Admin bootstrap.
  9. Seed demo data (unless blank or --no-seed).
  10. Summary — prints the web app URL (http://localhost:3000), the Convex URL, and the admin email.
Why a fresh backend needs the deploy step

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

FlagPurpose
--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-seedSkip demo-data seeding
--assume-yes, -yAccept 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.

CommandWhat it does
quickstartEnd-to-end deploy (config → up → deploy → bootstrap → seed).
setupFirst-run config wizard — writes .env + docker-compose.override.yml only.
configRe-open the wizard for an existing install (skips already-set fields).
bootstrap-orgCreate the first admin user + the singleton org.
seed [--reset]Populate the running instance with realistic demo data.
resetWipe 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.
doctorDiagnose 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:

PackFlags it controls
emailClientinbox, chat, postbox
marketingcampaigns, automations, transactional
aiai, 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 only updates .env

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:

  1. .env exists and parses.
  2. Every env var required by the active feature set is populated.
  3. docker-compose.override.yml exists.
  4. 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:

CommandWhat it does
startdocker compose up -d (activating the profiles for enabled features).
stopdocker compose down.
restart [service]docker compose restart [service].
statusdocker compose ps.
logs [service]docker compose logs -f [service].
shell <service>docker compose exec <service> sh (defaults to the web service).
versionPrint 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.

The cloud port vs the site proxy

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 .env for setup mode and points you at http://localhost:3000/setup. For SSH installs without a local browser, use the terminal wizard (below) instead. On the final "Launch" step its /api/setup/apply endpoint creates the admin (POST /seed/admin) and pushes the function-runtime env vars (EMAIL_PROVIDER, RESEND_API_KEY / AWS_SES_*, and the rest of CONVEX_RUNTIME_ENV_KEYS) into the Convex deployment over the admin API — the HTTP equivalent of convex env set, since the read-only web container can't run the convex-deploy step. 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.

Web wizard inside the installer

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:

VariableFormat
BETTER_AUTH_SECRET48-char URL-safe token
INSTANCE_SECRET64 hex chars (32 bytes) — see the note below
UNSUBSCRIBE_SECRET48-char URL-safe token
MTA_API_KEYmta_ + 40-char token
MTA_WEBHOOK_SECRETwhsec_ + 40-char token
REDIS_PASSWORD32-char token

CONVEX_ADMIN_KEY is intentionally not generated here — it must be minted by the running backend during the deploy step.

INSTANCE_SECRET must be hex

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-Secret header (timing-safe comparison against the deployment's INSTANCE_SECRET); a mismatch returns 401.
  • 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), an owner membership, the user profile, and the instance settings.
StatusMeaning
201Admin + org created
400Invalid JSON or missing email / name / passwordHash
401Missing or wrong X-Instance-Secret
409A 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.