Self-Hosting
Deploy Owlat on your own infrastructure with Docker Compose. Complete guide from first boot to production.
Owlat runs entirely on your own infrastructure via a single Docker Compose file. No cloud services are required — the stack includes its own database and real-time engine, and can run its own mail server (MTA) and antivirus scanner (ClamAV). The mail server and antivirus scanner are gated behind Docker Compose profiles (mta and clamav), so which of them run depends on your COMPOSE_PROFILES — see Stack Overview.
Why Self-Host
- Data sovereignty — your emails, contacts, and analytics never leave your servers
- Compliance — meet GDPR, HIPAA, or industry-specific data residency requirements
- Air-gapped environments — run without any external network dependencies
- Full control — customize, audit, and extend every component
Prerequisites
| Requirement | Minimum |
|---|---|
| Docker | 20.10+ |
| Docker Compose | v2 (the docker compose plugin) |
| RAM | 4 GB (8 GB recommended) |
| Disk | 20 GB free |
| openssl | Any version (for generating secrets) |
| Domain + DNS control | Required for production email delivery |
Supported platforms
Owlat runs on different operating systems depending on which part you mean — the desktop client, the self-hosted server, or a contributor checkout.
| Role | macOS | Linux | Windows |
|---|---|---|---|
| Desktop app | Supported (universal) | Supported | Supported |
| Self-hosted server | Not supported (eval only via Docker Desktop) | Supported | Not supported (eval only via Docker Desktop) |
| Contributor / dev checkout | Supported | Supported | Supported via WSL2 or Git Bash |
Desktop app
The desktop app ships for macOS (universal binary), Linux, and Windows. On Linux, end users need a few system runtime libraries installed for the WebKit-based shell to load:
- Debian / Ubuntu:
libwebkit2gtk-4.1-0,librsvg2-2 - Fedora:
webkit2gtk4.1,librsvg2
macOS and Windows bundle everything they need, so no extra packages are required there.
Self-hosted server
The self-hosted server is Linux only by design. It relies on systemd, Docker Engine, and binds the standard mail and web ports (25, 143, 443, 993). The blessed install path is a fresh Linux VPS.
macOS and Windows are not supported as a server host. For local evaluation on those machines, use Docker Desktop with the manual docker compose flow — that runs the same containers without needing a Linux host.
Contributor / dev checkout
Contributing works natively on macOS and Linux. On Windows, run the repo under WSL2 or Git Bash, because the lint and verification gates are bash scripts. See CONTRIBUTING for the full development setup.
Fastest path
On a fresh Linux VPS with Docker + Docker Compose v2 installed:
curl -fsSL https://get.owlat.app | bash
The one-liner clones the repo, runs preflight checks, installs the owlat CLI to /usr/local/bin, and hands off to the containerized owlat quickstart wizard. That single flow generates secrets, writes your .env, starts the Docker stack, mints the Convex admin key, deploys the functions, sets the Convex runtime environment variables, and bootstraps your admin account — no Bun or Node required on the host. Non-interactive usage:
# Skip prompts, accept all defaults
OWLAT_ASSUME_YES=1 curl -fsSL https://get.owlat.app | bash
# Read answers from a config file (for CI / Ansible)
OWLAT_CONFIG_FILE=/path/to/answers.env curl -fsSL https://get.owlat.app | bash
# Pin a specific release tag for a reproducible install
OWLAT_REF=v0.1.0 curl -fsSL https://get.owlat.app | bash
By default the installer resolves to the latest published release tag (not a moving branch) — curl | bash asks for your trust once, and it should not silently re-extend that trust to every future commit. Set OWLAT_REF to a tag (e.g. v0.1.0) so repeat runs and CI install byte-for-byte the same code you reviewed; use OWLAT_REF=main only if you explicitly want the bleeding edge.
The desktop app can install a fresh server for you over SSH — no terminal — with a live provisioning timeline. It drives this same installer remotely.
Stripe billing, Hetzner VPS provisioning, and tier management are part of the managed-cloud control plane (a separate, private codebase) and are not included in the open-source self-hosted distribution. Self-hosters run the Owlat stack directly with docker compose up -d.
Quick Start
Option A: owlat quickstart (recommended)
The recommended first-run flow is the containerized quickstart wizard. It runs the setup CLI (built from apps/setup-cli/) inside the wolvesdotink/setup Docker image, so the only host requirements are Docker and Docker Compose v2. One command does the full deploy: config wizard → docker compose up -d → admin-key mint → function deploy → Convex env vars → admin bootstrap.
git clone https://github.com/wolvesdotink/owlat.git
cd owlat
scripts/owlat quickstart
If you ran the one-liner above, the owlat CLI is already on your PATH, so you can simply run owlat quickstart. The full flow takes about 10 minutes from an empty VPS to a working install and handles everything in Option B below automatically — including pushing the Convex runtime env vars (BETTER_AUTH_SECRET, SITE_URL, UNSUBSCRIBE_SECRET, EMAIL_PROVIDER, MTA_API_URL, and the sender identity) into the deployment, so auth invites and the first send work without any extra steps.
The older pure-bash wizard still ships and works: bash scripts/setup.sh (select "Self-Hosted (Docker Compose)" when prompted). It is the legacy path, reachable through the CLI with owlat quickstart --legacy or by setting OWLAT_LEGACY_WIZARD=1 before the one-liner. Prefer owlat quickstart for new installs.
Option B: Manual Setup
1. Clone and configure
git clone https://github.com/wolvesdotink/owlat.git
cd owlat
cp .env.selfhost.example .env
2. Generate secrets
# Instance secret (hex)
openssl rand -hex 32
# MTA API key and webhook secret (base64)
openssl rand -base64 32
openssl rand -base64 32
Paste the generated values into .env for INSTANCE_SECRET, MTA_API_KEY, and MTA_WEBHOOK_SECRET.
3. Start the stack
docker compose up -d
4. Wait for Convex to be ready
# Watch logs until you see "ready" or "listening"
docker compose logs -f convex
# Press Ctrl+C once ready
5. Generate the admin key
docker compose exec convex ./generate_admin_key.sh
Copy the output key and add it to your .env:
# In .env, set:
CONVEX_ADMIN_KEY=<paste-key-here>
No restart is needed — the always-on services don't read CONVEX_ADMIN_KEY. Only the one-shot convex-deploy container (next step) and the optional sidecars consume it, and they re-read .env each time they run.
6. Deploy functions
# Deploy the main Convex functions
docker compose --profile deploy run --rm convex-deploy
7. Set Convex environment variables
These are application-level variables read by the serverless functions. Set them using the Convex CLI with your admin key:
export CONVEX_URL=http://localhost:3210
export CONVEX_ADMIN_KEY=<your-admin-key>
npx convex env set BETTER_AUTH_SECRET "$(openssl rand -base64 32)" --url $CONVEX_URL --admin-key $CONVEX_ADMIN_KEY
npx convex env set UNSUBSCRIBE_SECRET "$(openssl rand -base64 32)" --url $CONVEX_URL --admin-key $CONVEX_ADMIN_KEY
npx convex env set SITE_URL "http://localhost:3000" --url $CONVEX_URL --admin-key $CONVEX_ADMIN_KEY
# Do NOT set CONVEX_SITE_URL here — it is a Convex BUILT-IN and `convex env set`
# rejects it (EnvVarNameForbidden). The backend derives it from
# CONVEX_SITE_ORIGIN on the convex container, which docker-compose interpolates
# from NUXT_PUBLIC_CONVEX_SITE_URL in your .env.
npx convex env set EMAIL_PROVIDER "mta" --url $CONVEX_URL --admin-key $CONVEX_ADMIN_KEY
npx convex env set MTA_API_URL "http://mta:3100" --url $CONVEX_URL --admin-key $CONVEX_ADMIN_KEY
npx convex env set MTA_API_KEY "<your-mta-api-key>" --url $CONVEX_URL --admin-key $CONVEX_ADMIN_KEY
npx convex env set MTA_WEBHOOK_SECRET "<your-mta-webhook-secret>" --url $CONVEX_URL --admin-key $CONVEX_ADMIN_KEY
npx convex env set DEFAULT_FROM_EMAIL "noreply@example.com" --url $CONVEX_URL --admin-key $CONVEX_ADMIN_KEY
npx convex env set DEFAULT_FROM_NAME "Owlat" --url $CONVEX_URL --admin-key $CONVEX_ADMIN_KEY
npx convex env set DEFAULT_FROM_DOMAIN "example.com" --url $CONVEX_URL --admin-key $CONVEX_ADMIN_KEY
The host npx convex env set commands above are a manual equivalent. The supported tooling (apps/setup-cli/src/lib/convexDeploy.ts) instead pushes these vars through the convex-deploy container — docker compose --profile deploy run --rm convex-deploy — which already has the pinned Convex CLI and CONVEX_SELF_HOSTED_URL=http://convex:3210. Convex functions read these from the deployment, not from the compose .env.
8. Open the dashboard
open http://localhost:3000
The setup wizard creates an admin account automatically. For manual setup, first start the profile-gated dashboard with docker compose --profile dashboard up -d convex-dashboard (it binds to 127.0.0.1 only — tunnel via ssh -L 6791:127.0.0.1:6791 host when remote), then open http://localhost:6791 to create your first user, or call the seed endpoint documented in the setup script.
Stack Overview
| Service | Port(s) | Role |
|---|---|---|
| Convex | 3210, 3211 | Database, real-time subscriptions, serverless functions, file storage |
| Web | 3000 | Nuxt application — dashboard, email builder, settings |
| MTA | 3100, 25 | Mail transfer agent — SMTP delivery, bounce processing, IP warming. Profile-gated (mta); the default .env.selfhost.example ships COMPOSE_PROFILES=mta, so it runs on a stock self-host but is skipped if you switch to a third-party provider (Resend/SES) |
| Redis | internal only (6379) | Job queue and rate limiting state for the MTA |
| ClamAV | internal only (3310) | Antivirus scanning for email attachments. Profile-gated (clamav) and not in the default COMPOSE_PROFILES — see the note below |
| Updater | internal only (3200) | In-app self-update sidecar — runs docker compose pull && up -d on behalf of the web app. Mounts only OWLAT_INSTALL_DIR; it no longer mounts the Docker socket but drives Docker through the least-privilege docker-socket-proxy (DOCKER_HOST=tcp://docker-socket-proxy:2375), which is the sole container mounting /var/run/docker.sock (read-only). Reached over the Docker network at http://updater:3200 |
| Caddy | 80, 443 | Optional reverse proxy with automatic HTTPS, enabled via --profile tls (see Caddyfile.example) |
docker compose up -d honors COMPOSE_PROFILES from your .env: it starts the always-on base services plus whatever profiles are listed, and skips every profile-gated service that is not. The default .env.selfhost.example ships COMPOSE_PROFILES=mta — that brings up the MTA but not ClamAV. Even though the scan.files feature flag defaults on and attachment scanning is fail-open (mail still delivers if the scanner is unreachable), ClamAV never starts on a stock install, so attachments are delivered unscanned. To actually scan files, add clamav to COMPOSE_PROFILES (e.g. COMPOSE_PROFILES=mta,clamav) and re-run docker compose up -d, or enable the scan.files pack through the wizard, which wires the clamav profile in for you.
A few more services run only on demand, not as part of the always-on stack:
convex-deploy(--profile deploy) — one-shot container that pushesapps/apifunctions, schema, and HTTP routes to the backend, and sets Convex runtime env vars. Run it after first boot and after every image pull:docker compose --profile deploy run --rm convex-deploy.code-worker(--profile inbox-codetasks, also available via--profile dev) — the optional AI coding-agent sidecar; off by default.convex-dashboard(--profile dashboard) — admin UI for inspecting data and debugging functions. Start it withdocker compose --profile dashboard up -d convex-dashboard. It binds to127.0.0.1only, so on a remote VPS open an SSH tunnel (ssh -L 6791:127.0.0.1:6791 host) before browsing tohttp://localhost:6791.
For a deeper dive into the architecture, see Self-Hosting Architecture.
What's Next
- Configuration Reference — all environment variables, service topology, and volumes
- DNS & Email Setup — SPF, DKIM, DMARC, and bounce handling for production email
- Production Deployment — reverse proxy, TLS, firewall, backups, and monitoring
- Maintenance & Updates — keeping your instance up to date and troubleshooting