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 (Recommended)
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
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
| Tier | vCPU | RAM | Disk | Suitable For |
|---|---|---|---|---|
| Starter | 2 | 4 GB | 40 GB | Up to 10,000 contacts, light sending |
| Growth | 4 | 8 GB | 80 GB | Up to 100,000 contacts, regular campaigns |
| Enterprise | 8 | 16 GB | 160 GB | 100,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.