Production Deployment

Secure your self-hosted Owlat instance with TLS, firewall rules, backups, and monitoring.

This guide covers hardening your self-hosted Owlat instance for production use. For initial setup, see Self-Hosting.

Reverse Proxy

In production, place a reverse proxy in front of the Docker stack to handle TLS termination and route traffic.

Owlat ships a ready-made Caddy reverse proxy as a caddy service inside docker-compose.yml, gated behind the tls Compose profile. It automatically provisions and renews TLS certificates via Let's Encrypt and proxies the other containers by their Docker service names — no separate proxy host to run.

Copy the example config

cp Caddyfile.example Caddyfile

Set your domains

Edit Caddyfile and replace every example.com with your real domain, and set the email in the global options block (used for Let's Encrypt registration). The bundled file proxies three subdomains to the in-network service names:

owlat.example.com {
    reverse_proxy web:3000
}

api.example.com {
    reverse_proxy convex:3210
}

rest.api.example.com {
    reverse_proxy convex:3211
}

convex (3210, browser-facing WebSocket/HTTP) and convex-site (3211, HTTP actions such as BetterAuth and webhooks) are served by the same convex container — they are two ports on one service, not two services.

Point DNS and open ports

Add an A record for each subdomain pointing at this server, and make sure ports 80 and 443 are open. Caddy needs them to complete the ACME HTTP challenge.

Bring it up

docker compose --profile tls up -d

The caddy service binds 80/443 and obtains certificates on first boot.

After the proxy is live, update your .env to use the HTTPS URLs:

NUXT_PUBLIC_CONVEX_URL=https://api.example.com
NUXT_PUBLIC_CONVEX_SITE_URL=https://rest.api.example.com
NUXT_PUBLIC_SITE_URL=https://owlat.example.com

Then update the matching Convex environment variables:

npx convex env set SITE_URL "https://owlat.example.com" --url http://localhost:3210 --admin-key <key>
# CONVEX_SITE_URL is a Convex BUILT-IN — do not set it via `convex env set`
   # (the CLI rejects it). It derives from CONVEX_SITE_ORIGIN on the convex
   # container, which docker-compose interpolates from NUXT_PUBLIC_CONVEX_SITE_URL.
Staging certificates

To test the proxy without burning Let's Encrypt rate limits, uncomment the acme_ca https://acme-staging-v02.api.letsencrypt.org/directory line in the global options block of Caddyfile, bring the stack up, then switch back to production once DNS and routing check out.

External reverse proxy (alternative)

If you'd rather run the proxy on the host outside Docker — or front the stack with an existing Nginx/Caddy — do not use the bundled tls profile. Instead, proxy to the host-published container ports. By default docker-compose.yml publishes web (3000) on all interfaces, while convex (3210) and convex-site (3211) are bound to 127.0.0.1 only — so a host-local proxy targeting localhost:* works as written. If the proxy runs on another host, set CONVEX_BIND to expose 3210/3211 beyond loopback (and firewall them). Because the proxy runs outside the Docker network here, it must target localhost:*, not the service names.

server {
    listen 80;
    server_name owlat.example.com;
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    server_name owlat.example.com;

    ssl_certificate /etc/letsencrypt/live/owlat.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/owlat.example.com/privkey.pem;

    location / {
        proxy_pass http://localhost:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

# Repeat similar blocks for api.example.com (localhost:3210)
# and rest.api.example.com (localhost:3211).

Install certificates with Certbot:

sudo certbot --nginx -d owlat.example.com -d api.example.com -d rest.api.example.com

Firewall

Use UFW (or your distribution's firewall) to restrict access to only the necessary ports.

# Allow SSH
sudo ufw allow 22/tcp

# Allow HTTP/HTTPS (reverse proxy)
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp

# Allow SMTP (bounce processing)
sudo ufw allow 25/tcp

# Enable the firewall
sudo ufw enable
Do not expose internal ports

Ports 3210, 3211, 3000, 3100, 6379, and 3310 should not be reachable from the internet. The reverse proxy handles external traffic on ports 80/443 and forwards to internal services.

Note that web (3000) is published on all interfaces by default (so the SPA is reachable for the local-quickstart path), whereas convex (3210/3211) is loopback-bound. If you only want the reverse proxy to reach the web UI, set WEB_PORT with a host-IP prefix (e.g. 127.0.0.1:3000) or firewall it.

Two more deserve attention:

  • 3200 — the updater sidecar. It is internal-only by design (no published port in docker-compose.yml, reached only as http://updater:3200 over the Docker network). Leave it that way.
  • 6791 — the Convex dashboard. It is loopback-bound (127.0.0.1:6791) and profile-gated, so it is off by default and only reachable via an SSH tunnel after you start it with --profile dashboard. See Secure the Dashboard below.

Secure the Dashboard

The Convex dashboard on port 6791 provides full read/write access to your database, and it has no built-in authentication. The root docker-compose.yml already binds it to the loopback interface only (127.0.0.1:${DASHBOARD_PORT:-6791}:6791) and gates it behind the dashboard Compose profile, so on a fresh stack it is neither published publicly nor even running. Keep it that way before going to production.

Option 1: Use the shipped default (recommended)

No override is needed — the dashboard is already loopback-bound and only starts under --profile dashboard. Start it when you need it and verify the binding:

docker compose --profile dashboard up -d convex-dashboard
docker compose --profile dashboard port convex-dashboard 6791
# Should show: 127.0.0.1:6791

Option 2: SSH tunnel (for remote access after binding to localhost)

ssh -L 6791:localhost:6791 user@your-server
# Then open http://localhost:6791 in your browser

Option 3: Firewall it — if you can't change the binding, block port 6791 at the host firewall (it is omitted from the UFW rules above on purpose) so only the loopback or a VPN can reach it.

Never proxy the dashboard openly

If you expose the dashboard through the bundled Caddy proxy, gate it behind basic auth or an IP allowlist — it ships with no login. The commented convex-dashboard.example.com block in Caddyfile.example shows an IP-allowlist example.

Redis Authentication

For production, add a password to Redis. Create a docker-compose.override.yml:

services:
  redis:
    command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}

  mta:
    environment:
      REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379

Add REDIS_PASSWORD to your .env:

# Generate a Redis password
echo "REDIS_PASSWORD=$(openssl rand -base64 32)" >> .env

Restart the stack:

docker compose up -d

Backups

Use the bundled script — it is the only producer of archives that scripts/restore.sh accepts, and it does the consistency work for you (briefly pausing convex + redis around the copy, discovering every project volume, capturing .env, the compose override, and the Caddyfile, and writing a SHA256 sidecar):

bash scripts/backup.sh                 # → ./backups/owlat-<timestamp>.tar.gz
owlat backup                           # same, via the CLI wrapper

The convex-data volume is the critical one — it contains all application data (contacts, campaigns, templates, file uploads, settings). The Convex backend stores state in SQLite, so raw hot copies of the volume are not crash-consistent; the script pauses the container for the seconds the copy takes. The same applies to Redis 7's multi-part AOF (appendonlydir/).

Provider volume snapshots (Hetzner, AWS, …) are a fine complement, with the same caveat: snapshot while the stack is stopped, or accept that a snapshot taken mid-write may need AOF/SQLite recovery on restore.

ClamAV signatures and Ollama models need no backup — both re-download.

Disaster recovery (new VPS / dead disk)

  1. Provision a fresh VPS, install Docker + Compose v2.
  2. git clone the Owlat repo (same version as the backup — the MANIFEST records when it was taken) and cd into it.
  3. Copy your owlat-<timestamp>.tar.gz and its .sha256 sidecar over.
  4. bash scripts/restore.sh owlat-<timestamp>.tar.gz — this verifies the checksum, restores every volume plus .env/override/Caddyfile, and starts the stack with your feature profiles.
  5. Re-point DNS at the new server; TLS certificates re-issue automatically once DNS resolves.

Keep at least one backup off the VPS (object storage, your laptop) — a backup on the dead disk is not a backup.

Monitoring

Health Checks

Docker healthchecks are already configured for the critical services (Convex, Redis, ClamAV). Check their status:

docker compose ps

For external monitoring, create a health check script:

#!/bin/bash
# health-check.sh

CONVEX_OK=$(curl -sf http://localhost:3210/version && echo "ok" || echo "fail")
WEB_OK=$(curl -sf http://localhost:3000 && echo "ok" || echo "fail")
MTA_OK=$(curl -sf http://localhost:3100/health && echo "ok" || echo "fail")

echo "Convex: $CONVEX_OK | Web: $WEB_OK | MTA: $MTA_OK"

if [[ "$CONVEX_OK" != "ok" || "$WEB_OK" != "ok" || "$MTA_OK" != "ok" ]]; then
  exit 1  # Unhealthy — trigger your alerting system
fi

MTA Metrics

The MTA exposes Prometheus-compatible metrics:

curl http://localhost:3100/metrics

Connect this to Prometheus + Grafana for dashboards covering send rates, bounce rates, and queue depth.

Resource Requirements

These are informal sizing buckets, not billing plans — the OSS self-host build has no tiers or billing.

SizevCPURAMDiskSuitable For
Small24 GB40 GBUp to 10,000 contacts, light sending
Medium48 GB80 GBUp to 100,000 contacts, regular campaigns
Large816 GB160 GB100,000+ contacts, high-volume sending

ClamAV uses approximately 1 GB of RAM for virus definitions. If memory is tight, you can disable ClamAV by removing it from the Docker Compose file and skipping the CLAMAV_HOST/CLAMAV_PORT configuration.