Deployment
Production deployment with pm2, nginx reverse proxy, Cloudflare SSL, and firewall configuration.
Production deployment with pm2, nginx, Cloudflare, and firewall. This guide assumes you have completed the Installation and Configuration steps.
pm2 process manager
Create the pm2 ecosystem file at /opt/owlmetry/ecosystem.config.cjs:
module.exports = {
apps: [
{
name: "owlmetry-api",
script: "apps/server/dist/index.js",
cwd: "/opt/owlmetry",
max_memory_restart: "300M",
env: {
NODE_ENV: "production",
},
},
{
name: "owlmetry-web",
script: "node_modules/.bin/next",
args: "start",
cwd: "/opt/owlmetry/apps/web",
max_memory_restart: "300M",
env: {
NODE_ENV: "production",
PORT: 3000,
},
},
],
};The max_memory_restart: "300M" cap auto-restarts a service if it balloons — useful on a 1–2 GB VPS where a runaway process can starve PostgreSQL.
Start the services, persist them across reboots, and enable log rotation:
cd /opt/owlmetry
pm2 start ecosystem.config.cjs
pm2 save
# Rotate pm2 logs at 5 MB, keep 7 files, gzip compressed
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 5M
pm2 set pm2-logrotate:retain 7
pm2 set pm2-logrotate:compress trueVerify both services are running:
pm2 status
# owlmetry-api online
# owlmetry-web onlineUseful pm2 commands
pm2 logs owlmetry-api --lines 50 # View recent API server logs
pm2 logs owlmetry-web --lines 50 # View recent web dashboard logs
pm2 restart all # Restart both services
pm2 reload all # Zero-downtime reloadnginx reverse proxy
Create the nginx config at /etc/nginx/sites-available/owlmetry:
# API server (agent keys, dashboard API, auth, attachment uploads/downloads)
server {
listen 80;
server_name api.yourdomain.com;
# Event attachments can be up to 250 MB per upload (per-user quota default)
client_max_body_size 260m;
location / {
proxy_pass http://127.0.0.1:4000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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;
proxy_cache_bypass $http_upgrade;
}
# Internal location for attachment downloads served via X-Accel-Redirect.
# Full snippet: deploy/attachments-nginx-snippet.conf
location /_attachments/ {
internal;
alias /opt/owlmetry-attachments/;
types { }
default_type application/octet-stream;
add_header X-Content-Type-Options nosniff always;
add_header Cache-Control "private, no-store" always;
send_timeout 5m;
}
}
# Ingest (SDK client keys)
server {
listen 80;
server_name ingest.yourdomain.com;
client_max_body_size 2m;
location / {
proxy_pass http://127.0.0.1:4000;
proxy_http_version 1.1;
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;
}
}
# Web dashboard
server {
listen 80;
server_name yourdomain.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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;
proxy_cache_bypass $http_upgrade;
}
}Enable and test:
ln -sf /etc/nginx/sites-available/owlmetry /etc/nginx/sites-enabled/owlmetry
rm -f /etc/nginx/sites-enabled/default
nginx -t
systemctl reload nginxThe ingest server block sets client_max_body_size 2m to allow compressed event batches. The API and web server blocks include WebSocket upgrade headers for potential future use.
Attachments
The config above already includes the /_attachments/ internal location and a 260 MB client_max_body_size to allow SDK attachment uploads. Downloads are served via nginx X-Accel-Redirect, which maps the internal URI prefix (OWLMETRY_ATTACHMENTS_INTERNAL_URI=/_attachments/) to the on-disk directory (OWLMETRY_ATTACHMENTS_PATH=/opt/owlmetry-attachments). The full reference snippet with comments lives at deploy/attachments-nginx-snippet.conf in the repo.
The directory must be writable by the user running pm2 (root in the default setup):
mkdir -p /opt/owlmetry-attachments
chown -R root:root /opt/owlmetry-attachmentsCloudflare setup (recommended)
Using Cloudflare provides SSL termination, DDoS protection, and CDN caching.
DNS records
Create three A records in Cloudflare, all pointing to your VPS public IP with Proxy enabled (orange cloud):
| Type | Name | Content | Proxy |
|---|---|---|---|
| A | @ | VPS IP | Proxied |
| A | api | VPS IP | Proxied |
| A | ingest | VPS IP | Proxied |
SSL settings
Set the SSL/TLS encryption mode to Full (not "Full (Strict)"). This tells Cloudflare to connect to your origin over HTTPS but does not require a valid certificate on the origin. Since nginx listens on port 80 behind Cloudflare, "Full" mode works with the default setup.
If you prefer, you can generate a Cloudflare Origin Certificate (10-year validity) and configure nginx to listen on port 443 with that certificate. In that case, you can use "Full (Strict)" mode.
SSL without Cloudflare
If you are not using Cloudflare, use Let's Encrypt for free SSL certificates:
apt install -y certbot python3-certbot-nginx
certbot --nginx -d yourdomain.com -d api.yourdomain.com -d ingest.yourdomain.comCertbot automatically modifies the nginx config to listen on port 443 and sets up auto-renewal.
Firewall (UFW)
Lock down the VPS to accept SSH only from a VPN (Tailscale) and HTTP/HTTPS only from Cloudflare IP ranges:
ufw default deny incoming
ufw default allow outgoing
# SSH via Tailscale only
ufw allow in on tailscale0 to any port 22 proto tcp
# HTTP/HTTPS from Cloudflare IPs
for cidr in $(curl -s https://www.cloudflare.com/ips-v4/); do
ufw allow from "$cidr" to any port 80,443 proto tcp
done
for cidr in $(curl -s https://www.cloudflare.com/ips-v6/); do
ufw allow from "$cidr" to any port 80,443 proto tcp
done
ufw --force enableIf you are not using Cloudflare, replace the Cloudflare IP rules with:
ufw allow 80/tcp
ufw allow 443/tcpDeploying updates
Pull the latest code, rebuild, run migrations, and restart:
cd /opt/owlmetry
git pull
pnpm install
pnpm build
DATABASE_URL='postgresql://owlmetry:yourpassword@localhost:5432/owlmetry' pnpm db:migrate
pm2 restart allThe DATABASE_URL prefix is required for pnpm db:migrate because migrations run from packages/db and do not auto-load the root .env file.
Verification
After deployment, verify that everything is working:
# Health check (local)
curl -s http://localhost:4000/health
# {"status":"ok"}
# Health check (external, via Cloudflare)
curl -s https://api.yourdomain.com/health
curl -s https://ingest.yourdomain.com/healthOpen https://yourdomain.com in a browser to access the dashboard.
Monitoring
Service status
pm2 statusLogs
pm2 logs owlmetry-api --lines 50 --nostream # Last 50 lines, no follow
pm2 logs owlmetry-web --lines 50 --nostreamDisk and memory
df -h / # Disk usage
free -h # Memory usageDatabase size
PGPASSWORD='yourpassword' psql -U owlmetry -h 127.0.0.1 -d owlmetry \
-c "SELECT pg_size_pretty(pg_database_size('owlmetry'));"Database maintenance
- Partitions are auto-created on server startup (current month plus 2 months ahead). No manual intervention required.
- Auto-pruning runs hourly when
MAX_DATABASE_SIZE_GBis set. It drops the oldest monthly partitions first. - Per-project retention is enforced by the
retention_cleanupbackground job (daily at 2am UTC) using theretention_days_events,retention_days_metrics, andretention_days_funnelscolumns onprojects. - Size check:
psql -c "SELECT pg_size_pretty(pg_database_size('owlmetry'));"
Backups
Dump the Postgres database only — attachment bytes live on disk under OWLMETRY_ATTACHMENTS_PATH and must not be included in pg_dump:
pg_dump --no-owner owlmetry | gzip > /opt/owlmetry-backups/owlmetry-$(date +%F).sql.gzIf you need to back up attachment files too, sync them separately (e.g. with rsync or an object-storage mirror) — do not try to tar them into the SQL dump.
A simple daily backup cron that runs at 3am UTC with 7-day rotation:
# /etc/cron.d/owlmetry-backup
0 3 * * * root PGPASSWORD='yourpassword' pg_dump -U owlmetry -h 127.0.0.1 --no-owner owlmetry | gzip > /opt/owlmetry-backups/owlmetry-$(date +\%F).sql.gz && find /opt/owlmetry-backups -name 'owlmetry-*.sql.gz' -mtime +7 -deleteHealth checks
A simple health-check cron auto-restarts a service if it stops responding. Runs every 5 minutes against the API /health endpoint and the web dashboard root:
# /etc/cron.d/owlmetry-healthcheck
*/5 * * * * root (curl -sf http://127.0.0.1:4000/health > /dev/null || pm2 restart owlmetry-api) >> /opt/owlmetry-backups/healthcheck.log 2>&1
*/5 * * * * root (curl -sf http://127.0.0.1:3000/ > /dev/null || pm2 restart owlmetry-web) >> /opt/owlmetry-backups/healthcheck.log 2>&1The log file stays empty when both services respond. Entries appear only when a restart was triggered.
Troubleshooting
# Check if services are running
pm2 status
# Check service logs for errors
pm2 logs owlmetry-api --lines 100
# Validate nginx config
nginx -t
# Test database connectivity
psql $DATABASE_URL -c "SELECT 1;"
# Check disk space
df -h
# Restart everything
pm2 restart all
systemctl reload nginx