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.

Caddy automatically provisions and renews TLS certificates via Let's Encrypt.

# Caddyfile
owlat.example.com {
    # Web application
    reverse_proxy localhost:3000
}

convex.example.com {
    # Convex backend API
    reverse_proxy localhost:3210
}

convex-site.example.com {
    # Convex HTTP actions (tracking pixels, webhooks, auth)
    reverse_proxy localhost:3211
}

After setting up Caddy, update your .env to use the HTTPS URLs:

NUXT_PUBLIC_CONVEX_URL=https://convex.example.com
NUXT_PUBLIC_CONVEX_SITE_URL=https://convex-site.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>
npx convex env set CONVEX_SITE_URL "https://convex-site.example.com" --url http://localhost:3210 --admin-key <key>

Nginx + Certbot

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 convex.example.com (:3210) and convex-site.example.com (:3211)

Install certificates with Certbot:

sudo certbot --nginx -d owlat.example.com -d convex.example.com -d convex-site.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 exposed to the internet. The reverse proxy handles external traffic on ports 80/443 and forwards to internal services.

Secure the Dashboard

The Convex dashboard on port 6791 provides full read/write access to your database. Never expose it publicly.

Option 1: Bind to localhost only (default in docker-compose.yml)

The dashboard is already bound to 127.0.0.1:6791 by default. Verify with:

docker compose port convex-dashboard 6791
# Should show: 0.0.0.0:6791 or 127.0.0.1:6791

Option 2: SSH tunnel (for remote access)

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

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

Convex Data (Critical)

The convex-data volume contains all application data — contacts, campaigns, templates, file uploads, and settings.

Volume snapshot (if your hosting provider supports it):

# Hetzner, AWS, etc. — create a volume/disk snapshot via their API or console

Manual backup (requires brief downtime):

# Stop Convex to ensure consistency
docker compose stop convex

# Back up the volume
docker run --rm -v owlat_convex-data:/data -v $(pwd)/backups:/backup \
  alpine tar czf /backup/convex-data-$(date +%Y%m%d).tar.gz -C /data .

# Start Convex again
docker compose start convex

Redis Data (Medium Priority)

Redis uses AOF (Append Only File) persistence by default. The data is automatically persisted to the redis-data volume.

# Backup without stopping Redis
docker run --rm -v owlat_redis-data:/data -v $(pwd)/backups:/backup \
  alpine cp /data/appendonly.aof /backup/redis-aof-$(date +%Y%m%d).aof

ClamAV Data (Low Priority)

Virus definition signatures are downloaded automatically by freshclamd. No backup needed — they re-download on startup.

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

TiervCPURAMDiskSuitable For
Starter24 GB40 GBUp to 10,000 contacts, light sending
Growth48 GB80 GBUp to 100,000 contacts, regular campaigns
Enterprise816 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.