Maintenance & Updates

Keep your self-hosted Owlat instance up to date, manage backups, scale performance, and troubleshoot common issues.

This guide covers day-to-day maintenance of your self-hosted Owlat instance. For initial setup, see Self-Hosting.

Updating

Owlat supports three ways to update: in-app, CLI, or manual. All three do the same thing under the hood — pull the pinned compose template for a tagged release, apply it, and redeploy Convex functions.

Platform admins see an "Update available" notification at Settings → System & Updates when a new version is released. Click Update now, confirm, and the web app will:

  1. Download the pinned docker-compose-<version>.yml from GitHub Releases
  2. Dispatch it to the updater sidecar
  3. Pull new images, recreate containers, redeploy Convex functions
  4. Verify the new version is live

The page polls container health and auto-reloads once the update is complete. Total time: typically 2–5 minutes.

Option B: owlat upgrade CLI

Installed to /usr/local/bin/owlat by the installer:

owlat upgrade                     # to latest
owlat upgrade --version 1.2.3     # to a specific version (useful for rollback)

The CLI drives the same updater sidecar the in-app flow uses.

Option C: Manual

For air-gapped environments or if you prefer to drive updates yourself:

# 1. Pull the pinned compose template for the target version
curl -fsSL https://github.com/wolvesdotink/owlat/releases/download/v1.2.3/docker-compose-1.2.3.yml \
  -o docker-compose.yml

# 2. Pull the new images
docker compose pull

# 3. Recreate containers
docker compose up -d

# 4. Re-deploy Convex tenant functions
docker compose --profile deploy run --rm convex-deploy
Always re-deploy functions

Pulling new Docker images updates the containers, but the Convex serverless functions are deployed separately. The in-app and CLI paths run this for you; the manual path requires step 4 above.

Recovering from a failed update

If an update fails mid-flight, your stack may be in a mixed state — some containers on the new version, some on the old. The updater never touches the data volumes, and as of the stage-then-promote flow it only replaces docker-compose.yml after images pulled and Convex functions deployed successfully. Still: take a backup before updating (owlat backup) — a release that ships a breaking schema change may reset or migrate data, and the release notes are the authority on that.

Diagnose first

# What's running?
owlat status                       # or: docker compose ps

# What did each container log?
owlat logs web                     # follows the stream (Ctrl-C to exit); for a one-shot snapshot use: docker compose logs --tail=200 web
owlat logs mta
owlat logs convex
owlat logs updater

# Environment health check
owlat doctor                       # checks .env, required env vars, compose override, running services

Rollback to the previous version

Every tagged release attaches its docker-compose-<version>.yml to the GitHub Release page. To roll back:

Via CLI:

# Replace with the last-known-good version
owlat upgrade --version 1.2.2

Manually:

# Download the previous release's compose file
curl -fsSL https://github.com/wolvesdotink/owlat/releases/download/v1.2.2/docker-compose-1.2.2.yml \
  -o docker-compose.yml

# Pull the pinned images and recreate
docker compose pull
docker compose up -d

# Re-deploy functions at the old version
docker compose --profile deploy run --rm convex-deploy

Docker will pull images for the pinned tags (e.g. ghcr.io/wolvesdotink/web:1.2.2) and swap containers in-place.

If the web container won't start

# See why
docker compose logs web

# Rule out a stale image
docker compose pull web
docker compose up -d --force-recreate web

If Convex won't start

Convex holds the primary database; Redis and ClamAV also persist data, plus optional services (code-worker, ollama, IMAP/acme certs) when their feature profiles are enabled. If it's failing to start, check disk space on the convex-data volume first:

docker system df -v | grep convex-data

If disk is full, free space or resize the underlying volume. Never delete the convex-data volume — that's your database.

Nuclear option: restore from backup

If the stack is in an unrecoverable state, restore the most recent backup archive. The owlat CLI wraps scripts/restore.sh:

owlat restore backups/owlat-YYYYMMDD-HHMMSS.tar.gz
# equivalently: bash scripts/restore.sh backups/owlat-YYYYMMDD-HHMMSS.tar.gz

This stops the stack, wipes the volumes, repopulates them from the archive (Convex, Redis, and optionally ClamAV), and brings everything back up. The current .env is preserved at .env.before-restore-<timestamp> first. See Backups for how to produce these archives and set up automated backups. To schedule recurring backups without hand-rolling cron, run owlat backup-schedule enable — it installs a daily systemd timer (cron fallback) that runs scripts/backup.sh; owlat backup-schedule disable removes it.

Reporting an update bug

If an update fails in a way you think is a bug in Owlat itself (rather than your environment), capture and attach to a GitHub issue:

owlat doctor                                   > /tmp/owlat-doctor.txt 2>&1
docker compose ps                              > /tmp/owlat-ps.txt
docker compose logs --tail=500                 > /tmp/owlat-logs.txt 2>&1
cat /opt/owlat/docker-compose.yml              > /tmp/owlat-compose.yml

File at: https://github.com/wolvesdotink/owlat/issues/new

Automating Updates

There is deliberately no unattended-update cron in the box. Two reasons:

  1. Ordering matters: functions must deploy before app containers restart (the in-app updater and owlat upgrade do this for you). A naive pull && up && deploy cron — which earlier versions of this page suggested — restarts containers against an old schema first.
  2. Pre-1.0, a release may ship a breaking schema change. Read the release notes before applying an update; don't let cron apply them blind.

If you accept those trade-offs, automate owlat upgrade (which wraps the safe ordering) and pair it with a pre-update backup:

# /etc/cron.d/owlat-update — automated upgrades, at your own risk
0 4 * * 0 root cd /opt/owlat && bash scripts/backup.sh && owlat upgrade
Update safety

Always have a fresh backup before an update. Owlat is pre-1.0: a release may include breaking schema changes that reset specific tables — the release notes state when that is the case.

ClamAV Signatures

The ClamAV container runs freshclamd automatically, which downloads updated virus definitions daily. No manual intervention is needed.

To force an immediate signature update:

docker compose exec clamav freshclam

To check the installed signature database version directly:

docker compose exec clamav sigtool --info /var/lib/clamav/daily.cld

(clamscan --version reports the ClamAV engine version rather than the signature DB version.)

Redis Maintenance

Redis is configured with AOF (Append Only File) persistence by default. This ensures the MTA job queue survives container restarts.

  • Compaction — Redis automatically rewrites the AOF file to keep it compact.
  • Memory — monitor Redis memory usage with docker compose exec redis redis-cli info memory.
  • Flushing — if you need to clear the queue (e.g., after a misconfiguration): docker compose exec redis redis-cli FLUSHALL. This discards all in-flight email jobs.
Password-protected Redis

If you secured Redis with a REDIS_PASSWORD (see Redis Authentication), prefix every redis-cli command with -a "$REDIS_PASSWORD" --no-auth-warning, e.g. docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning info memory.

Scaling

MTA Throughput

Increase WORKER_CONCURRENCY in your .env to process more email groups in parallel:

# Default: 50 workers
WORKER_CONCURRENCY=100

Restart the MTA to apply: docker compose restart mta

Server Sizing

LoadvCPURAMDisk
Up to 10K contacts24 GB40 GB
Up to 100K contacts48 GB80 GB
100K+ contacts816 GB160 GB

Convex Backend

Convex is a single-node service that scales vertically. Monitor disk usage on the convex-data volume — this is where all database records, file uploads, and vector indexes are stored.

# Check volume disk usage
docker system df -v | grep convex-data

Troubleshooting

Convex won't start

docker compose logs convex

Common causes:

  • INSTANCE_SECRET not set — check your .env file has a valid hex string
  • Disk full — the convex-data volume needs free space for the database
  • Port conflict — another service is using port 3210 or 3211

MTA can't send emails

docker compose logs mta

Common causes:

  • MTA_API_URL not set in the Convex runtime — the most common cause after a manual install. MTA_API_URL (and MTA_API_KEY) must be pushed into the Convex deployment, not just the compose .env; without it sending hard-throws No system email transport configured (apps/api/convex/systemMail.ts) before the MTA is ever reached. Check with npx convex env list (or the dashboard) and set it to http://mta:3100. The owlat quickstart wizard does this for you.
  • EHLO hostname doesn't match PTR record — receiving servers reject the connection. Verify with dig -x YOUR_IP +short
  • DKIM_KEYS JSON is invalid — validate the JSON: echo $DKIM_KEYS | jq .
  • Port 25 blocked — many cloud providers (AWS, GCP, Azure) block outbound SMTP by default. Request port 25 access or use an email relay
  • MTA_API_KEY mismatch — the key in Docker .env must match the one set in Convex env vars

ClamAV is slow to start

This is normal. ClamAV loads virus definitions into memory on startup, and on first boot it also downloads the full (~300 MB) signature database — this can take several minutes on a small VPS. The Docker healthcheck has a start_period: 600s to account for this.

The MTA waits for ClamAV to be healthy before starting (configured via depends_on in Docker Compose).

Web UI shows connection error

The browser needs to reach the Convex backend directly. If NUXT_PUBLIC_CONVEX_URL uses a Docker-internal hostname (like http://convex:3210), the browser can't connect.

Fix: set NUXT_PUBLIC_CONVEX_URL to a URL reachable from the browser:

  • Local dev: http://localhost:3210
  • Production: https://api.example.com (via reverse proxy)

Function deployment fails

docker compose --profile deploy run --rm convex-deploy 2>&1

Common causes:

  • CONVEX_ADMIN_KEY is wrong or missing — regenerate: docker compose exec convex ./generate_admin_key.sh
  • Convex container not healthy — check with docker compose ps
  • Schema conflict — if you modified Convex schema files, check for validation errors in the deploy output

Migrating to Production

To move from a local development setup to a production deployment:

  1. Update URLs in .env:
    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
    
  2. Set up DNS — configure A records, PTR, SPF, DKIM, and DMARC. See DNS & Email Setup.
  3. Add a reverse proxy — Caddy or Nginx with TLS. See Production Deployment.
  4. Secure Redis — add REDIS_PASSWORD as described in Production Deployment.
  5. Update Convex env vars to match the new URLs:
    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.
    npx convex env set ALLOWED_ORIGINS "https://owlat.example.com" --url http://localhost:3210 --admin-key <key>
    
  6. Restart the stack: docker compose up -d
  7. Verify — send a test email and check delivery with mail-tester.com.