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.

Why self-hosting matters

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

Convex BackendDB + Vectors + Files + Real-time
Web AppNuxt dashboard & email builder
MTASMTP delivery & bounce processing
RedisMTA job queue & rate limiting
ClamAVAttachment antivirus scanning
OllamaOptional self-hosted LLM
optional
Required
Optional
All services run via docker compose up

The self-hosted stack is defined in the repo's root docker-compose.yml:

ServiceRoleImage
Convex BackendDatabase, real-time subscriptions, file storage, vector search, serverless functionsghcr.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
WebNuxt application (dashboard, email builder, settings)ghcr.io/wolvesdotink/web (or built from apps/web/Dockerfile)
MTACustom mail transfer agent — SMTP delivery, bounce processing, IP warmingghcr.io/wolvesdotink/mta (or built from apps/mta/Dockerfile)
RedisJob queue for MTA, distributed coordination, rate limiting stateredis:7.4-alpine
ClamAVAntivirus scanning for email attachmentsclamav/clamav:stable
UpdaterIn-app update sidecar — drives docker compose pull && up -dghcr.io/wolvesdotink/updater (or built from apps/updater/Dockerfile)
Caddy (--profile tls)Reverse proxy with automatic HTTPS for productioncaddy:2.8-alpine
Convex Deploy (--profile deploy)One-shot job that pushes the tenant functions to the backendghcr.io/wolvesdotink/convex-deploy
Code Worker (--profile inbox-codetasks, also dev)AI coding-agent sidecar for the feature-request → prototype pipelineBuilt 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:
Web service env vars

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

VariableScopeDescription
INSTANCE_SECRETCompose .envConvex backend instance secret — openssl rand -hex 32
CONVEX_ADMIN_KEYCompose .envAdmin key, generated after first boot via docker compose exec convex ./generate_admin_key.sh
MTA_API_KEYCompose .envShared secret between Convex and MTA for send requests
MTA_WEBHOOK_SECRETCompose .envHMAC secret for MTA → Convex webhook authentication
NUXT_PUBLIC_SITE_URLCompose .envPublic URL of the web app, browser-facing (e.g., https://owlat.example.com)
NUXT_PUBLIC_CONVEX_URL / NUXT_PUBLIC_CONVEX_SITE_URLCompose .envPublic Convex backend + site-proxy URLs
BETTER_AUTH_SECRETConvex functionSession-signing secret for the auth layer (set with convex env set)
UNSUBSCRIBE_SECRETConvex functionDedicated 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 vs NUXT_PUBLIC_SITE_URL

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.

VariableDescriptionDefault
LLM_PROVIDERProvider 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_URLAPI endpoint override (for Ollama: http://ollama:11434/v1)Provider default
LLM_API_KEYAPI key — OPENROUTER_API_KEY / OPENAI_API_KEY are also accepted; first one set wins— (not needed for Ollama)
LLM_MODELDefault model identifier (or LLM_MODEL_FAST / LLM_MODEL_CAPABLE per tier)gpt-4o

Optional

VariableDescriptionDefault
OWLAT_DEPLOYMENT_MODEHides hosted-only UI; shows the self-host onboarding bannerselfhost
EMAIL_PROVIDEREmail provider: mta, ses, resendmta
CLAMAV_HOSTClamAV hostnameclamav
CLAMAV_PORTClamAV port3310
GITHUB_WEBHOOK_SECRETConvex 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:

PhaseServices AddedPurpose
Now (Email Platform)convex, convex-dashboard, web, mta, redis, clamav, updaterFull 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() in apps/api/convex/lib/llmProvider.ts; the credential resolver resolveApiKey() there accepts LLM_API_KEY, OPENROUTER_API_KEY, or OPENAI_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: updateChannelConfig schedules encryptAndPersistConfig (apps/api/convex/channels/outbound.ts), which wraps the config in an AES-256-GCM envelope via lib/credentialCrypto before it lands in the channelConfigs row. 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 when LLM_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-worker container (run under --profile inbox-codetasks, also enabled by --profile dev) gets its own code-workspace volume, separate from the backend's convex-data volume, and runs with no-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 ScopeWho can readExample
Convex backendConvex functions onlyINSTANCE_SECRET, LLM_API_KEY, BETTER_AUTH_SECRET, UNSUBSCRIBE_SECRET, GITHUB_WEBHOOK_SECRET
MTAMTA process onlyMTA_API_KEY, MTA_WEBHOOK_SECRET, DKIM keys
WebBrowser-safe onlyNUXT_PUBLIC_CONVEX_URL, NUXT_PUBLIC_SITE_URL (no secrets)
Code workerTask-specific onlyCONVEX_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.

Ready to deploy?

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
One-command setup

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.