Self-Hosting Architecture
How Owlat runs as a fully self-hosted stack using Docker Compose — open-source Convex backend, custom MTA, and a pluggable LLM provider.
Self-Hosting Architecture
Owlat is designed to run entirely on your own infrastructure. Every component is open-source, and the entire stack deploys via a single Docker Compose file.
Data sovereignty, compliance requirements, air-gapped environments, or simply wanting full control. Owlat does not require any cloud services to function — not even for AI features, if you run a local model.
The Stack
docker compose upThe self-hosted stack is defined in the repo's root docker-compose.yml:
| Service | Role | Image |
|---|---|---|
| Convex Backend | Database, real-time subscriptions, file storage, vector search, serverless functions | ghcr.io/get-convex/convex-backend |
Convex Dashboard (--profile dashboard) | Admin/debugging UI for the backend (port 6791, bound to 127.0.0.1 only, no built-in auth — reach it over an SSH tunnel) | ghcr.io/get-convex/convex-dashboard |
| Web | Nuxt application (dashboard, email builder, settings) | ghcr.io/wolvesdotink/web (or built from apps/web/Dockerfile) |
| MTA | Custom mail transfer agent — SMTP delivery, bounce processing, IP warming | ghcr.io/wolvesdotink/mta (or built from apps/mta/Dockerfile) |
| Redis | Job queue for MTA, distributed coordination, rate limiting state | redis:7.4-alpine |
| ClamAV | Antivirus scanning for email attachments | clamav/clamav:stable |
| Updater | In-app update sidecar — drives docker compose pull && up -d | ghcr.io/wolvesdotink/updater (or built from apps/updater/Dockerfile) |
Caddy (--profile tls) | Reverse proxy with automatic HTTPS for production | caddy:2.8-alpine |
Convex Deploy (--profile deploy) | One-shot job that pushes the tenant functions to the backend | ghcr.io/wolvesdotink/convex-deploy |
Code Worker (--profile inbox-codetasks, also dev) | AI coding-agent sidecar for the feature-request → prototype pipeline | Built from apps/code-worker/Dockerfile |
Ollama (--profile ai, also dev) | Optional local LLM inference (no published ports; internal network only) | ollama/ollama |
A local LLM ships as an optional service. Bring up the bundled Ollama container with --profile ai (also enabled under --profile dev) and set LLM_PROVIDER=ollama (the backend resolves it at http://ollama:11434/v1), or point the same variable at an Ollama instance you supply — see LLM configuration below.
What Convex provides natively
The open-source Convex backend (ADR-006) bundles capabilities that would otherwise require separate services:
- Database — document-oriented with indexes, full-text search, and ACID transactions
- Vector search — native vector indexes available for embedding-based retrieval (the substrate the Knowledge Graph and semantic file search are built on as those features mature)
- File storage — binary file uploads and downloads via
ctx.storage(media library, attachments, semantic files) - Real-time subscriptions — reactive queries that push updates to the UI over WebSocket
- Scheduled functions — cron jobs and one-off scheduled tasks (campaign sending, analytics reporting, sending-reputation cleanup, knowledge decay)
- HTTP actions — webhook endpoints, API routes, tracking pixels
This means no PostgreSQL, no MinIO, no separate vector database. The Convex backend is the single stateful service.
Docker Compose
The canonical definition lives in the repo's root docker-compose.yml. The excerpt below shows the core service wiring — refer to the real file for the full set of build args, health checks, and version-pinning env vars.
services:
# Database + serverless functions
convex:
image: ghcr.io/get-convex/convex-backend:${CONVEX_BACKEND_VERSION:-...}
ports:
- "${CONVEX_PORT:-3210}:3210" # Backend API
- "${CONVEX_SITE_PORT:-3211}:3211" # HTTP actions / site proxy
volumes:
- convex-data:/convex/data
environment:
INSTANCE_SECRET: ${INSTANCE_SECRET}
CONVEX_CLOUD_ORIGIN: ${NUXT_PUBLIC_CONVEX_URL:-http://localhost:3210}
CONVEX_SITE_ORIGIN: ${NUXT_PUBLIC_CONVEX_SITE_URL:-http://localhost:3211}
# Admin/debugging UI (separate service, port 6791)
convex-dashboard:
image: ghcr.io/get-convex/convex-dashboard:${CONVEX_DASHBOARD_VERSION:-...}
ports:
- "${DASHBOARD_PORT:-6791}:6791"
environment:
CONVEX_URL: http://convex:3210
# Nuxt web app
web:
image: ghcr.io/wolvesdotink/web:${OWLAT_VERSION:-latest}
ports:
- "${WEB_PORT:-3000}:3000"
environment:
NUXT_PUBLIC_CONVEX_URL: ${NUXT_PUBLIC_CONVEX_URL:-http://localhost:3210}
NUXT_PUBLIC_CONVEX_SITE_URL: ${NUXT_PUBLIC_CONVEX_SITE_URL:-http://localhost:3211}
NUXT_PUBLIC_SITE_URL: ${NUXT_PUBLIC_SITE_URL:-http://localhost:3000}
OWLAT_DEPLOYMENT_MODE: ${OWLAT_DEPLOYMENT_MODE:-selfhost}
INSTANCE_SECRET: ${INSTANCE_SECRET} # for calling the updater sidecar
mta:
image: ghcr.io/wolvesdotink/mta:${OWLAT_VERSION:-latest}
ports:
- "${MTA_SMTP_PORT:-25}:25" # Inbound SMTP (bounce processing)
- "${MTA_HTTP_PORT:-3100}:3100" # HTTP API
environment:
REDIS_URL: redis://redis:6379
CONVEX_SITE_URL: http://convex:3211
MTA_API_KEY: ${MTA_API_KEY}
MTA_WEBHOOK_SECRET: ${MTA_WEBHOOK_SECRET}
redis:
image: redis:${REDIS_VERSION:-7.4-alpine}
command: redis-server --appendonly yes
volumes:
- redis-data:/data
clamav:
image: clamav/clamav:${CLAMAV_VERSION:-stable}
volumes:
- clamav-data:/var/lib/clamav
# In-app update sidecar (reached only via the internal network)
updater:
image: ghcr.io/wolvesdotink/updater:${OWLAT_VERSION:-latest}
# mounts only the install dir; reaches Docker through the least-privilege docker-socket-proxy (DOCKER_HOST), not the raw socket
# Reverse proxy with automatic HTTPS (production)
caddy:
image: caddy:${CADDY_VERSION:-2.8-alpine}
ports: ["80:80", "443:443"]
profiles: [tls]
# One-shot: push tenant functions to the backend after first boot
convex-deploy:
image: ghcr.io/wolvesdotink/convex-deploy:${OWLAT_VERSION:-latest}
environment:
CONVEX_SELF_HOSTED_URL: http://convex:3210
CONVEX_SELF_HOSTED_ADMIN_KEY: ${CONVEX_ADMIN_KEY}
profiles: [deploy]
# AI coding-agent sidecar (feature request → prototype)
code-worker:
build: { context: ., dockerfile: apps/code-worker/Dockerfile }
profiles: [inbox-codetasks, dev]
volumes:
convex-data:
redis-data:
clamav-data:
code-workspace:
caddy-data:
caddy-config:
The web service reads the browser-facing NUXT_PUBLIC_* URLs. CONVEX_SELF_HOSTED_URL / CONVEX_SELF_HOSTED_ADMIN_KEY are used only by the one-shot convex-deploy service to push functions, not by the running web app.
Environment Variables
Owlat splits its configuration between compose-level variables (in .env, copied from .env.selfhost.example) that wire the containers together, and Convex function variables set on the backend via convex env set (or applied for you by owlat quickstart). The tables below cover both — the Scope column tells you where each one lives.
Required
| Variable | Scope | Description |
|---|---|---|
INSTANCE_SECRET | Compose .env | Convex backend instance secret — openssl rand -hex 32 |
CONVEX_ADMIN_KEY | Compose .env | Admin key, generated after first boot via docker compose exec convex ./generate_admin_key.sh |
MTA_API_KEY | Compose .env | Shared secret between Convex and MTA for send requests |
MTA_WEBHOOK_SECRET | Compose .env | HMAC secret for MTA → Convex webhook authentication |
NUXT_PUBLIC_SITE_URL | Compose .env | Public URL of the web app, browser-facing (e.g., https://owlat.example.com) |
NUXT_PUBLIC_CONVEX_URL / NUXT_PUBLIC_CONVEX_SITE_URL | Compose .env | Public Convex backend + site-proxy URLs |
BETTER_AUTH_SECRET | Convex function | Session-signing secret for the auth layer (set with convex env set) |
UNSUBSCRIBE_SECRET | Convex function | Dedicated secret for signing unsubscribe / preference links (set with convex env set) |
The .env.selfhost.example file in the repo root lists every compose-level variable. The Convex-function secrets are applied automatically by owlat quickstart, or set by hand with convex env set once the backend is up.
SITE_URL is a Convex function environment variable (apps/api/convex/lib/env.ts) used by backend functions to build absolute links. The web container uses the browser-facing NUXT_PUBLIC_SITE_URL. They are distinct — set both. (owlat quickstart sets SITE_URL for you.)
The hardened VPS template (infra/templates/.env.vps.template) additionally requires REDIS_PASSWORD.
LLM Configuration (for Agent Pipeline)
These are Convex function variables (set with convex env set). See ADR-007: Pluggable LLM Provider for details.
| Variable | Description | Default |
|---|---|---|
LLM_PROVIDER | Provider type: openai (default), openrouter, or ollama (all OpenAI-compatible). anthropic is not a recognized value — run Claude through openai with an OpenAI-compatible LLM_BASE_URL. | openai |
LLM_BASE_URL | API endpoint override (for Ollama: http://ollama:11434/v1) | Provider default |
LLM_API_KEY | API key — OPENROUTER_API_KEY / OPENAI_API_KEY are also accepted; first one set wins | — (not needed for Ollama) |
LLM_MODEL | Default model identifier (or LLM_MODEL_FAST / LLM_MODEL_CAPABLE per tier) | gpt-4o |
Optional
| Variable | Description | Default |
|---|---|---|
OWLAT_DEPLOYMENT_MODE | Hides hosted-only UI; shows the self-host onboarding banner | selfhost |
EMAIL_PROVIDER | Email provider: mta, ses, resend | mta |
CLAMAV_HOST | ClamAV hostname | clamav |
CLAMAV_PORT | ClamAV port | 3310 |
GITHUB_WEBHOOK_SECRET | Convex function var. HMAC secret for the GitHub PR-merge webhook (POST /webhooks/github, apps/api/convex/webhooks/githubHttp.ts) that flips a code-work task to merged when its PR lands. Unset disables the endpoint (returns 503); set with convex env set. | — |
How the stack evolves
The Docker Compose file grows with each phase of the roadmap:
| Phase | Services Added | Purpose |
|---|---|---|
| Now (Email Platform) | convex, convex-dashboard, web, mta, redis, clamav, updater | Full email marketing platform |
| Next (Inbound & Agents) | — | Agent pipeline, inbound email processing, verification queue (all Convex functions) |
| Then (Communication Intelligence) | — | Knowledge graph, multi-channel, CRM, file system — all run within Convex |
| Later (Complete Vision) | code-worker (--profile inbox-codetasks, also dev) | Coding agent sidecar for feature request → prototype pipeline |
The architecture is designed so that adding AI capabilities does not add required infrastructure. The agent pipeline, knowledge graph, and semantic search all run as Convex functions. The LLM itself is a configuration choice, not a required service — use a hosted OpenAI/OpenRouter/Anthropic key, or run inference locally with the bundled Ollama container (--profile ai, also enabled under --profile dev).
Security & isolation
Self-hosting means running AI agents on your own infrastructure — which requires the same defense-in-depth approach applied to the email pipeline. The security model covers credential isolation, process sandboxing, and environment hygiene.
Credential isolation
Agent pipeline functions never see raw API keys or secrets directly. All sensitive configuration flows through the Convex backend's environment variable system:
- LLM credentials — the API key is read inside
getLLMProvider()inapps/api/convex/lib/llmProvider.ts; the credential resolverresolveApiKey()there acceptsLLM_API_KEY,OPENROUTER_API_KEY, orOPENAI_API_KEY(first one set wins). The key never appears in agent context, LLM prompts, or log output. - Channel provider credentials — channel API keys (SMS/WhatsApp/generic) are encrypted at rest:
updateChannelConfigschedulesencryptAndPersistConfig(apps/api/convex/channels/outbound.ts), which wraps the config in an AES-256-GCM envelope vialib/credentialCryptobefore it lands in thechannelConfigsrow. They are decrypted only when an outbound channel function needs them to make a provider call. - Self-hosted Ollama — if you run a local Ollama container, keep it on the Docker internal network (
ollama:11434) with no published host port. No API key is required for Ollama, and the backend resolves its base URL automatically whenLLM_PROVIDER=ollama.
# If you supply an Ollama container, leave it unpublished —
# reachable only from other Docker services
ollama:
image: ollama/ollama
# No 'ports:' mapping — only accessible via the Docker network
Process sandboxing
Agent-generated content runs in isolated execution environments:
- Visualization agent output — rendered in
<iframe sandbox="allow-scripts">with no access to the parent DOM, Convex client, cookies, or navigation. See Visualization Agent. - Coding agent sidecar — the
code-workercontainer (run under--profile inbox-codetasks, also enabled by--profile dev) gets its owncode-workspacevolume, separate from the backend'sconvex-datavolume, and runs withno-new-privileges. It talks to the backend only through the Convex API URL.
code-worker:
build:
context: .
dockerfile: apps/code-worker/Dockerfile
volumes:
- code-workspace:/workspace # Isolated workspace, not convex-data
environment:
CONVEX_URL: http://convex:3210
CONVEX_ADMIN_KEY: ${CONVEX_ADMIN_KEY} # authenticates internal-function calls
security_opt:
- no-new-privileges:true
profiles:
- inbox-codetasks
- dev
Environment variable hygiene
Sensitive variables follow strict scoping rules:
| Variable Scope | Who can read | Example |
|---|---|---|
| Convex backend | Convex functions only | INSTANCE_SECRET, LLM_API_KEY, BETTER_AUTH_SECRET, UNSUBSCRIBE_SECRET, GITHUB_WEBHOOK_SECRET |
| MTA | MTA process only | MTA_API_KEY, MTA_WEBHOOK_SECRET, DKIM keys |
| Web | Browser-safe only | NUXT_PUBLIC_CONVEX_URL, NUXT_PUBLIC_SITE_URL (no secrets) |
| Code worker | Task-specific only | CONVEX_URL (API endpoint), CONVEX_ADMIN_KEY (to drive internal functions), LLM_* (for direct LLM access) |
Convex environment variables are never passed to agent-generated code. The code-worker sidecar receives only the Convex client URL, the deployment admin key, and LLM configuration. It polls an internalQuery and drives internalMutations, which an anonymous client cannot reach, so — exactly like apps/imap and apps/mail-sync — it authenticates with the deployment admin key (CONVEX_ADMIN_KEY) via setAdminAuth on the ConvexHttpClient (apps/code-worker/src/convexClient.ts). Compose passes the key into the service (docker-compose.yml). Keep the worker on the internal Docker network and don't expose its endpoints.
For step-by-step setup instructions, see the Self-Hosting Guide. This page covers the architecture and design philosophy.
Getting started
# 1. Clone the repository
git clone https://github.com/wolvesdotink/owlat.git
cd owlat
# 2. Configure secrets
cp .env.selfhost.example .env # then fill in INSTANCE_SECRET, MTA_*, URLs, ...
# 3. Start the stack
docker compose up -d
# 4. Generate the admin key and put it in .env as CONVEX_ADMIN_KEY
docker compose exec convex ./generate_admin_key.sh
# 5. Deploy the tenant functions (one-shot job, reads CONVEX_ADMIN_KEY)
docker compose --profile deploy run --rm convex-deploy
# 6. Open the dashboard
open http://localhost:3000
The owlat quickstart command in the setup CLI runs this whole flow for you — it mints the admin key from the backend and applies the Convex function env vars (convex env set) automatically. See the Setup CLI and Self-Hosting Guide.