HandoffRail Production Deployment Guide¶
Deploy HandoffRail to production with Docker Compose, PostgreSQL, Redis, TLS, monitoring, and automated backups.
Estimated time: 20–30 minutes for a fresh VM.
Scope: Single-server Docker Compose. For Kubernetes, adapt the compose services to manifests using the same images and env vars.
Table of Contents¶
- Prerequisites
- Configuration & Secrets
- Database Migrations
- Docker Compose Production Startup
- Reverse Proxy & TLS
- Health Checks
- Observability & Grafana
- Backups & Restore
- Scaling Considerations
- Rollback & Troubleshooting
- Security Checklist
Prerequisites¶
| Requirement | Version | Notes |
|---|---|---|
| Docker Engine | 24.0+ | docker --version |
| Docker Compose | 2.20+ | docker compose version |
| PostgreSQL client tools | 16+ | For manual pg_dump / pg_restore |
| TLS certificate | — | Let’s Encrypt, Cloudflare Origin CA, or purchased cert |
| (Optional) Redis CLI | 7+ | For ad-hoc inspection |
Host sizing (minimum / recommended)¶
| Workload | CPU | RAM | Disk |
|---|---|---|---|
| Light (< 1k handoffs/day) | 1 core | 2 GB | 20 GB SSD |
| Medium (1–10k handoffs/day) | 2 cores | 4 GB | 40 GB SSD |
| Heavy (> 10k handoffs/day) | 4+ cores | 8 GB | 100 GB SSD |
Configuration & Secrets¶
1. Clone the repository¶
2. Create your environment file¶
Edit .env with production values. Do not commit this file.
# ── Core ───────────────────────────────────────
HR_ENVIRONMENT=prod
HR_LOG_LEVEL=warning
HR_PORT=8080
# ── Database ───────────────────────────────────
# Use a strong password. If the DB runs inside Compose, host is 'db'.
HR_DATABASE_URL=postgresql+asyncpg://handoffrail:CHANGE_ME_STRONG_PASSWORD@db:5432/handoffrail
POSTGRES_USER=handoffrail
POSTGRES_PASSWORD=CHANGE_ME_STRONG_PASSWORD
POSTGRES_DB=handoffrail
# ── Redis ──────────────────────────────────────
HR_REDIS_URL=redis://redis:6379/0
# ── CORS ───────────────────────────────────────
# Restrict to your actual front-end domains in production.
HR_CORS_ORIGINS=["https://app.yourdomain.com","https://admin.yourdomain.com"]
Environment variable reference¶
| Variable | Default | Required in prod? | Description |
|---|---|---|---|
HR_ENVIRONMENT |
dev |
✅ | Must be prod |
HR_DATABASE_URL |
SQLite local | ✅ | PostgreSQL asyncpg URL |
HR_REDIS_URL |
redis://localhost:6379/0 |
✅ | Redis URL (Compose service name redis) |
HR_LOG_LEVEL |
info |
Recommended | debug, info, warning, error |
HR_PORT |
8080 |
Optional | Container port (map via Compose) |
HR_CORS_ORIGINS |
["*"] |
✅ | JSON list of allowed origins |
HR_TIER_DEFAULT |
free |
Optional | Default tier for new API keys |
HR_TIER_QUOTAS |
(built-in) | Optional | Override tier quotas as JSON |
POSTGRES_USER |
handoffrail |
✅ | DB superuser name |
POSTGRES_PASSWORD |
— | ✅ | DB superuser password |
POSTGRES_DB |
handoffrail |
✅ | Database name |
Security note: HandoffRail logs a warning if
HR_CORS_ORIGINS=["*"]is used in production. The API key hash is never logged, but webhook payloads and packet summaries may appear in debug logs — usewarningorerrorin prod.
Database Migrations¶
HandoffRail uses Alembic (SQLAlchemy migrations). The production compose includes a one-shot migration service that runs automatically before the API starts.
How migrations work in production¶
# Inside docker-compose.prod.yml
migration:
build:
context: .
dockerfile: Dockerfile
command: ["alembic", "upgrade", "head"]
environment:
- HR_DATABASE_URL=...
depends_on:
db:
condition: service_healthy
restart: "no"
The api service waits for:
1. db healthy (PostgreSQL ready)
2. redis healthy
3. migration completed successfully
Manual migration (if needed)¶
# Run from the repo root
docker compose -f docker-compose.prod.yml run --rm migration alembic upgrade head
Check migration history¶
Current migration files¶
| Revision | Description |
|---|---|
0001_initial.py |
Base tables: packets, events, webhooks, API keys |
0002_tenants.py |
Multi-tenant support |
0003_schemas.py |
Schema validation & metadata |
0004_rbac.py |
Role-based access control |
Tip: Always back up the database before applying new migrations on a live system.
Docker Compose Production Startup¶
1. Pull and build¶
2. Start the stack¶
3. Verify all services are healthy¶
Expected output:
NAME STATUS
handoffrail-api-1 Up 30s (healthy)
handoffrail-migration-1 Exited (0)
handoffrail-db-1 Up 30s (healthy)
handoffrail-redis-1 Up 30s (healthy)
4. Smoke test¶
# Liveness
curl http://localhost:8080/health
# → {"status":"ok","service":"handoffrail"}
# Readiness (checks DB)
curl http://localhost:8080/ready
# → {"status":"ready","service":"handoffrail","db":true}
# Metrics
curl http://localhost:8080/metrics | head
What the compose stack includes¶
| Service | Image | Purpose | Persistence |
|---|---|---|---|
api |
Built from Dockerfile |
FastAPI application | None (stateless) |
db |
postgres:16-alpine |
Primary database | pg-data volume |
redis |
redis:7-alpine |
Caching, pub/sub | redis-data volume |
migration |
Built from Dockerfile |
One-shot Alembic run | None |
Stopping and restarting¶
# Graceful stop
docker compose -f docker-compose.prod.yml down
# Stop and remove volumes (⚠️ destroys data)
docker compose -f docker-compose.prod.yml down -v
# Restart after code update
docker compose -f docker-compose.prod.yml up -d --build
Reverse Proxy & TLS¶
Never expose the FastAPI container directly to the internet in production. Use a reverse proxy for TLS termination, rate limiting, and static file serving.
Option A: Caddy (recommended — automatic TLS)¶
Caddyfile:
api.handoffrail.yourdomain.com {
reverse_proxy localhost:8080
# Security headers
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
}
# Optional: restrict /metrics to internal IPs
@metrics {
path /metrics
not remote_ip 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 127.0.0.1
}
respond @metrics 403
}
Option B: nginx¶
server {
listen 443 ssl http2;
server_name api.handoffrail.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/api.handoffrail.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.handoffrail.yourdomain.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# Optional: block /metrics from public
location /metrics {
allow 10.0.0.0/8;
allow 172.16.0.0/12;
allow 192.168.0.0/16;
allow 127.0.0.1;
deny all;
proxy_pass http://127.0.0.1:8080;
}
location / {
proxy_pass http://127.0.0.1:8080;
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;
# Timeouts for long-running HITL operations
proxy_read_timeout 60s;
proxy_connect_timeout 10s;
}
}
# Redirect HTTP → HTTPS
server {
listen 80;
server_name api.handoffrail.yourdomain.com;
return 301 https://$server_name$request_uri;
}
Option C: Cloudflare Tunnel (zero-config TLS)¶
Point the tunnel to http://localhost:8080. No cert management required.
Health Checks¶
Endpoint summary¶
| Endpoint | Method | Auth | Purpose |
|---|---|---|---|
/health |
GET |
No | Liveness — process is running |
/ready |
GET |
No | Readiness — DB connection OK |
/metrics |
GET |
No | Prometheus metrics |
Docker Compose health checks¶
Already configured in docker-compose.prod.yml:
healthcheck:
test: ["CMD", "python", "-c",
"import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"]
interval: 30s
timeout: 5s
start_period: 15s
retries: 3
Kubernetes probes (if migrating to K8s)¶
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
Uptime monitoring (external)¶
Configure your uptime checker (UptimeRobot, Pingdom, Grafana OnCall) to hit:
GET https://api.yourdomain.com/health— expect 200, < 2s responseGET https://api.yourdomain.com/ready— expect 200 (alerts if DB is down)
Observability & Grafana¶
HandoffRail exposes Prometheus metrics at /metrics. For a full monitoring stack, see docs/monitoring.md for detailed Prometheus + Grafana setup, dashboard import, and alert rules.
Quick-start monitoring stack¶
Add to a new docker-compose.monitoring.yml:
services:
prometheus:
image: prom/prometheus:latest
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus_data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--web.enable-lifecycle'
grafana:
image: grafana/grafana:latest
ports:
- "3000:3000"
volumes:
- grafana_data:/var/lib/grafana
environment:
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-changeme}
volumes:
prometheus_data:
grafana_data:
prometheus.yml:
global:
scrape_interval: 15s
scrape_configs:
- job_name: 'handoffrail'
static_configs:
- targets: ['api:8080']
metrics_path: /metrics
Key metrics to watch¶
| Metric | Type | Alert if |
|---|---|---|
handoffrail_requests_total |
Counter | Rate drops to zero unexpectedly |
handoffrail_request_latency_seconds |
Histogram | p95 > 500ms sustained |
handoffrail_active_packets |
Gauge | Growing unbounded (leak indicator) |
handoffrail_webhook_dlq_size |
Gauge | > 100 (dead letter queue backlog) |
handoffrail_webhook_deliveries_total{result="failed"} |
Counter | Failure rate > 10% |
Grafana dashboard¶
Import docs/grafana-dashboard.json into Grafana. It includes:
- Overview row (active packets, handoffs, error rate)
- Packet throughput panels
- Webhook delivery health
- API latency percentiles (p50 / p95 / p99)
- HITL & API key operations
- Built-in alert rules for error rate, webhook failures, and queue depth
Security: Restrict
/metricsaccess in production. It exposestenant_idlabels and operational counters that could aid reconnaissance.
Backups & Restore¶
Automated PostgreSQL backups¶
Run this from a cron job on the host or a backup container:
#!/bin/bash
# /opt/handoffrail/backup.sh
set -e
BACKUP_DIR="/backups/handoffrail"
DATE=$(date +%Y%m%d_%H%M%S)
CONTAINER="handoffrail-db-1"
mkdir -p "$BACKUP_DIR"
docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$CONTAINER" \
pg_dump -U handoffrail -d handoffrail -F c -f "/tmp/backup_$DATE.dump"
docker cp "$CONTAINER:/tmp/backup_$DATE.dump" "$BACKUP_DIR/"
docker exec "$CONTAINER" rm "/tmp/backup_$DATE.dump"
# Keep last 14 days
find "$BACKUP_DIR" -name "backup_*.dump" -mtime +14 -delete
echo "Backup complete: $BACKUP_DIR/backup_$DATE.dump"
Add to crontab (daily at 2 AM):
Restore from backup¶
# 1. Stop the API (keep DB running)
docker compose -f docker-compose.prod.yml stop api
# 2. Restore
docker cp backup_20260709_020000.dump handoffrail-db-1:/tmp/restore.dump
docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" -it handoffrail-db-1 \
pg_restore -U handoffrail -d handoffrail --clean --if-exists /tmp/restore.dump
# 3. Restart
docker compose -f docker-compose.prod.yml start api
Redis backup (RDB)¶
Redis persists to /data/dump.rdb inside the redis-data volume by default. Back up the volume:
docker run --rm -v handoffrail_redis-data:/data -v /backups:/backups alpine \
tar czf /backups/redis_$(date +%Y%m%d).tar.gz -C /data .
Note: Redis is used for caching and pub/sub. Losing Redis data is inconvenient but not catastrophic — no persistent application state lives there.
Volume-level snapshot (nuclear option)¶
# Stop everything first
docker compose -f docker-compose.prod.yml down
# Snapshot volumes
docker run --rm \
-v handoffrail_pg-data:/pg -v handoffrail_redis-data:/redis \
-v /backups:/backups alpine tar czf /backups/handoffrail_full_$(date +%Y%m%d).tar.gz -C /pg . -C /redis .
# Restart
docker compose -f docker-compose.prod.yml up -d
Scaling Considerations¶
Vertical scaling (single server)¶
- Increase container CPU / memory limits in
docker-compose.prod.yml - Tune PostgreSQL:
shared_buffers,work_mem,max_connections - Tune Uvicorn workers: set
UVICORN_WORKERSor override the CMD
Horizontal scaling (multi-instance)¶
For load-balanced deployments:
- External PostgreSQL & Redis — Run DB and cache on dedicated hosts or managed services (AWS RDS, GCP Cloud SQL, Upstash Redis).
- Stateless API containers — Run multiple
apireplicas behind a load balancer. Each replica connects to the same DB and Redis. - Sticky sessions not required — WebSocket and SSE connections use Redis pub/sub for cross-instance broadcast.
- Session affinity — Not needed for REST API calls. WebSocket clients will reconnect to any healthy instance.
# Example: 3 API replicas with external DB
services:
api:
deploy:
replicas: 3
environment:
- HR_DATABASE_URL=postgresql+asyncpg://handoffrail:***@db.internal:5432/handoffrail
- HR_REDIS_URL=redis://redis.internal:6379/0
Connection pooling¶
SQLAlchemy async pool is built-in. For high-throughput workloads, tune via environment or config.py:
Large packet storage¶
Keep packet artifacts under the tier limit (1 MB for Business). For larger files:
- Store references (S3 / GCS URLs) in
artifactsinstead of inline base64 - Use pre-signed URLs for upload/download
- Keep HandoffRail as the metadata layer, not the blob store
Rollback & Troubleshooting¶
Rolling back a bad deployment¶
# 1. Revert to previous image tag or Git commit
git checkout <previous-commit>
docker compose -f docker-compose.prod.yml up -d --build
# 2. If database schema changed, downgrade migration
docker compose -f docker-compose.prod.yml run --rm migration alembic downgrade -1
Always back up the database before deploying schema changes.
Common issues¶
API container exits immediately¶
Causes:
- DB not ready → check db health: docker compose -f docker-compose.prod.yml logs db
- Migration failed → run manually and inspect error
- Missing env var → verify .env is readable and all required vars are set
/ready returns 503¶
- PostgreSQL is down or unreachable
- Wrong
HR_DATABASE_URLhost (usedbinside Compose, notlocalhost) - Network partition between
apianddbcontainers
/metrics empty or missing data¶
- Server just started — generate some traffic first
- Prometheus can't reach the endpoint — verify scrape target and firewall
- Wrong port/path — must be
http://<host>:8080/metrics
High memory usage¶
- Check for memory leaks in custom adapters or webhook handlers
- Reduce
UVICORN_WORKERSif over-provisioned - Monitor
handoffrail_active_packets— unbounded growth indicates packets not being completed/expired
Webhook delivery failures¶
- Verify webhook target SSL certificates
- Check target endpoint availability and timeout settings
- Review webhook logs for HMAC signature mismatches
Database connection errors under load¶
- Increase PostgreSQL
max_connections - Tune SQLAlchemy pool size and overflow
- Consider PgBouncer for connection pooling at the DB layer
Getting help¶
- Check logs:
docker compose -f docker-compose.prod.yml logs -f api - Check metrics:
curl http://localhost:8080/metrics - Review
docs/monitoring.mdfor alert troubleshooting - Open an issue with: logs,
.env(redact passwords), anddocker compose psoutput
Security Checklist¶
- [ ]
.envfile haschmod 600and is in.gitignore - [ ]
HR_ENVIRONMENT=prodis set - [ ]
HR_CORS_ORIGINSrestricted to actual domains (not["*"]) - [ ]
HR_LOG_LEVELiswarningorerrorin production - [ ] PostgreSQL password is strong and unique
- [ ] Redis is not exposed to the public internet (no host port mapping, or firewall restricted)
- [ ]
/metricsis blocked from public access (firewall or reverse proxy) - [ ] TLS 1.2+ enabled on reverse proxy
- [ ] Security headers set (HSTS, X-Frame-Options, etc.)
- [ ] Automated backups are configured and tested
- [ ] API keys are hashed at rest (handled by application)
- [ ] Webhook secrets are ≥ 16 characters
- [ ] Webhook receivers verify HMAC-SHA256 signatures
- [ ] Health endpoints do not expose sensitive data
- [ ] Container runs as non-root user (
handoffrail) - [ ] Docker images are scanned for CVEs before deployment
Last updated: 2026-07-09