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.

Existing MTA installs require an explicit FBL cutover

Fresh setup writes the owned-v2 complaint protocol with a fresh-install acknowledgement. Re-running setup against an existing environment never invents that state. Before upgrading a legacy MTA, follow the coordinated quiescence and drain procedure in FBL deduplication upgrade, then set FBL_DEDUP_PROTOCOL=owned-v2 and FBL_DEDUP_CUTOVER_ACK=quiesced-v1-intake explicitly.

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. Sample data (unless blank or --no-seed) — POST /sample-data/install. A local dev deployment that already has OWLAT_DEV_MODE=true gets the fuller /seed/demo dataset instead (dummy teammate sign-ins + Postbox mailboxes); no install flow ever turns that flag on.
  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.
sample-data <install|remove|status>Opt-in demo content for a real install, and its exact removal. No dev mode required.
seed [--reset]Dev-only full demo seed (OWLAT_DEV_MODE), including the dummy teammate sign-ins.
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.
env --showList the env vars the current feature-flag state requires, whether each is set (secrets masked), and which flag requires it.
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"

sample-data

The demo dataset a real install can actually use. install POSTs to /sample-data/install, remove to /sample-data/remove, status to /sample-data/status — all three authenticated with the X-Instance-Secret header (the on-box operator secret from .env, the same one /seed/admin uses) and not gated on OWLAT_DEV_MODE. That distinction is deliberate: turning dev mode on to reach /seed/demo would also unlock the full-wipe /dev/reset endpoint and drop BetterAuth's rate limiting on a production instance.

owlat sample-data install   # idempotent — re-running inserts nothing new
owlat sample-data status    # how many sample rows are left
owlat sample-data remove    # deletes exactly those rows (prompts unless --assume-yes)

What it writes: 15 contacts across 3 topics, email templates and saved blocks, a sent campaign with per-recipient stats, an automation, a webhook, transactional-email history, and a verified demo.localhost sending domain. What it does not write: user accounts or mailboxes. The dev-only /seed/demo seeds dummy teammate sign-ins whose password hashes are published in the repo's fixtures — those have no business on an instance anyone else can reach, so the sample-data selection leaves out the accounts and mailboxes loaders (apps/api/convex/seedDemo/pipeline.tsSAMPLE_DATA_MODULES). It also leaves out the Gmail bulk-sender volume rollups, which on a real install would read as a genuine domain sitting just under the threshold in the compliance view.

The dataset is inert by construction. /seed/demo writes its automation active and its webhook enabled; the sample-data path runs the same loaders with { inert: true }, which writes the automation paused (no activatedAt) and the webhook disabled. That matters because neither subscription is seed-aware at fire time: the automation triggers on contact_created and the webhook pool picks up every active subscription, so a live sample row would mail your next real signup a demo promo and POST that person's details to a fixture URL. Turn either on yourself if you want to watch them run.

Removal is exact rather than a table wipe: every row a loader inserts carries seedTag: 'demo', removal deletes only tagged rows, and it re-checks the tag inside the delete transaction. A sample contact you edited is still tagged and still goes; a contact you created is untagged and stays. Deleting a sample contact or topic runs the same cascade a permanent contact delete does, so rows of your own hanging off one (an activity, an automation run, a membership) go with it instead of dangling. Because the tag has no index, removal scans page by page from an action rather than in one mutation, so it stays inside the per-transaction read limit on an instance that has since accumulated real data; if a table is large enough that the scan hits its page cap, the response carries truncated: true and the CLI says so — re-run to continue.

quickstart runs the same install step when you pick "explore with sample data", so a fresh install never needs OWLAT_DEV_MODE to look populated.

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 --show instead lists the variables your current feature-flag state requires — the same required-vars set doctor checks, minus the docker / container probing. Each row shows whether the variable is set (*_KEY / *_SECRET / *_PASSWORD values masked) and which flag (or the provider-conditional send path) requires it, so you can see at a glance what is still missing before deploying.

owlat env --show
  KEY                           SET / VALUE      REQUIRED BY
────────────────────────────────────────────────────────────
✗ GOOGLE_SAFE_BROWSING_API_KEY  (unset)          scan.urls
✓ LLM_PROVIDER                  openrouter       ai
✓ MTA_API_KEY                   ********…        send path (EMAIL_PROVIDER=mta)

It is read-only and always exits 0 — use doctor when you want a pass/fail gate.

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. It also mints a one-time setup token, writes it to the root-owned .env (OWLAT_SETUP_TOKEN), and prints it to the console — copy it into the wizard's final step. The /api/setup/* endpoints run unauthenticated (there is no admin yet), so they require this token in an X-Setup-Token header, verified with a constant-time compare; a missing or wrong token returns 401. Without it, the first caller to reach the wizard could create the platform-admin account and rewrite .env. 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_* / SMTP_RELAY_*, 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. The provider step is an honest instance-level choice — run your own MTA, Amazon SES, or an SMTP relay (with Mailgun/Postmark/SendGrid/Brevo presets, or a custom server), plus Resend — and it validates the credential before continuing (a live API check for Resend, a real SMTP handshake + AUTH for a relay).
  • Terminal wizard (--terminal) — a full TUI covering the same questions: deployment mode, feature picker, sending provider (own MTA / Amazon SES / SMTP relay / Resend), 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, and nothing in the install flow turns it on — demo content for a real install goes through sample-data, which is instance-secret authenticated and not dev-gated. /seed/admin itself is not dev-gated — it is the production admin-bootstrap path, protected by the instance secret and the one-shot check.