Self-hosting Guide
Using the hosted product?
If you signed up for hosted Horus, you don't deploy anything: sign in at the URL your provider gave you and skip this page. This guide is for the open-source, self-hosted path.
Horus is three pieces: a FastAPI backend (a container), a static frontend bundle, and Supabase (Postgres + Auth). None of them are tied to a specific cloud. This document covers local development with Docker Compose, the full environment-variable reference, and how to run each piece in production on the platform of your choice.
Table of contents
- Local development
- Environment variable reference
- Production deployment
- Redis and rate limiting
- Health check
- Security headers
- Scaling constraints
Local development
The project ships a docker-compose.yml at the repo root. It starts three services:
| Service | Port | Description |
|---|---|---|
backend | 8000 | FastAPI app with hot-reload via Uvicorn |
frontend | 5173 | Vite dev server (proxies API calls to the backend) |
mailpit | 1025 (SMTP), 8025 (web UI) | Local email catcher; no real mail is sent |
Start everything
# Copy the example env file and fill in the required values (see the reference below)
cp .env.example .env
docker compose upThe backend mounts ./backend into the container, so code changes reload automatically without rebuilding the image.
Useful shortcuts
# Backend only (no frontend, no mail)
docker compose up backend
# Tail logs from all services
docker compose logs -f
# Rebuild after changing requirements.txt or the Dockerfile
docker compose build backend
docker compose upWhat the frontend proxy does
In development, Vite forwards any request that starts with /api to http://backend:8000 via the VITE_API_PROXY_TARGET environment variable. You never need to hard-code the backend URL in the frontend during local development.
Environment variable reference
All variables are loaded from a .env file in the project root (or from real environment variables in production). The backend uses Pydantic Settings; unknown keys are silently ignored.
Supabase (required)
| Variable | Required | Description | Example |
|---|---|---|---|
SUPABASE_URL | Yes | Your Supabase project URL | https://abcdefgh.supabase.co |
SUPABASE_ANON_KEY | Yes | Public anon key (safe to expose to the browser) | eyJhbGciOiJIUzI1NiIs... |
SUPABASE_SERVICE_ROLE_KEY | Yes | Service role key (server-side only, never sent to the browser) | eyJhbGciOiJIUzI1NiIs... |
LLM provider
| Variable | Required | Description | Example |
|---|---|---|---|
LLM_BASE_URL | No | OpenAI-compatible API base URL | https://openrouter.ai/api/v1 |
LLM_API_KEY | No | API key for the LLM provider | sk-or-v1-... |
LLM_DEFAULT_MODEL | No | Model string used by all agents unless overridden | anthropic/claude-opus-4-5 |
LLM_TIMEOUT_SECONDS | No | Per-request timeout in seconds (default: 60.0) | 60.0 |
LLM_MAX_RETRIES | No | Retry count for transient LLM errors (default: 2) | 2 |
LLM_ENABLED | No | Set to false for fully deterministic, no-cloud mode (default: true) | false |
Per-agent model overrides (all optional; fall back to LLM_DEFAULT_MODEL):
| Variable | Agent |
|---|---|
LLM_ANALYST_MODEL | Domain analyst |
LLM_THREAT_INTEL_MODEL | Threat intel |
LLM_VALIDATION_MODEL | Red/blue debate validator |
LLM_REMEDIATION_MODEL | Remediation drafter |
LLM_RISK_MANAGER_MODEL | Risk manager |
LLM_REPORTER_MODEL | Report generator |
LLM_RED_MODEL | Red adversarial agent |
LLM_BLUE_MODEL | Blue adversarial agent |
LLM_PHISHING_MODEL | Phishing simulation agent |
LLM_IRIS_TRIAGE_MODEL | Iris AI triage |
Application
| Variable | Required | Description | Example |
|---|---|---|---|
ENVIRONMENT | No | development or production; controls HSTS and other headers (default: development) | production |
SECRET_KEY | Yes (prod) | Secret used for signing; change from the default changeme before deploying | a-long-random-string |
Rate limiting
| Variable | Required | Description | Example |
|---|---|---|---|
RATE_LIMIT_ENABLED | No | Toggle all rate limiting (default: true) | true |
RATE_LIMIT_PER_MINUTE | No | Per-IP request budget for all /api routes (default: 120) | 120 |
RATE_LIMIT_SENSITIVE_PER_MINUTE | No | Tighter budget for write-heavy endpoints like POST /api/scans (default: 10) | 10 |
TRUST_PROXY_HEADERS | No | Honor X-Forwarded-For when behind a trusted reverse proxy (default: false) | true |
REDIS_URL | No | Redis connection string; enables shared rate-limit state across workers. Falls back to per-process in-memory when unset. | redis://localhost:6379/0 |
Scan pipeline
| Variable | Required | Description | Example |
|---|---|---|---|
PIPELINE_MAX_CONCURRENCY | No | Maximum number of scans running in parallel (default: 2) | 2 |
SCAN_MAX_RETRIES | No | Auto-retry count for failed scheduled scans (default: 1) | 1 |
SCAN_BLACKOUT_WINDOWS | No | Comma-separated time ranges when scheduled scans are skipped | Mon-Fri 09:00-18:00 |
SCAN_BLACKOUT_TIMEZONE | No | IANA timezone for blackout windows (default: server local time) | Europe/Madrid |
CVE and vulnerability intelligence
| Variable | Required | Description | Example |
|---|---|---|---|
CVE_SYNC_ENABLED | No | Enable daily CISA KEV and EPSS sync (default: true) | true |
CVE_SYNC_CRON | No | Cron schedule for CVE sync (default: 0 5 * * *) | 0 5 * * * |
CVE_SYNC_INCLUDE_EPSS | No | Include EPSS scores (~250k rows); disable in dev to save time (default: true) | false |
NVD_API_KEY | No | NVD API key; raises rate limit from 5 to 50 req/30s. Get one free at nvd.nist.gov. | abc123-... |
Notifications
| Variable | Required | Description | Example |
|---|---|---|---|
SMTP_HOST | No | SMTP server hostname for email notifications | smtp.resend.com |
SMTP_PORT | No | SMTP port (default: 587) | 587 |
SMTP_USER | No | SMTP username | apikey |
SMTP_PASSWORD | No | SMTP password or API key | re_... |
SMTP_FROM | No | Sender address | alerts@yourdomain.com |
SMTP_USE_TLS | No | Enable STARTTLS (default: true) | true |
NOTIFY_DEFAULT_MIN_SEVERITY | No | Minimum severity to trigger a notification (default: high) | medium |
Optional integrations
| Variable | Required | Description | Example |
|---|---|---|---|
SHODAN_API_KEY | No | Shodan API key for enriched asset data | abc123... |
HIBP_API_KEY | No | HaveIBeenPwned Domain Search API key; HIBP checks are disabled without it | abc123... |
TAVILY_API_KEY | No | Tavily web search key used by the adversarial agents | tvly-... |
GITHUB_TOKEN | No | GitHub personal access token for exploit/PoC searches (rate-limit is 10 req/min without one) | ghp_... |
Privacy and data controls
| Variable | Required | Description | Example |
|---|---|---|---|
REDACTION_ENABLED | No | Pseudonymize hostnames, IPs, and emails in prompts before they leave the process (default: true) | true |
Production deployment
Horus does not require any particular cloud. The backend is a plain container, the frontend is a static bundle, and the database is Supabase. Pick whatever hosts you already run. The examples below use generic commands; adapt them to your platform.
Backend (container)
The backend ships as backend/Dockerfile, built with the repo root as the build context (matching docker-compose.yml). The image bundles nmap and a pre-fetched copy of Nuclei templates so the first scan does not pay a download cost. It listens on port 8000 and needs no persistent volume; all state lives in Supabase.
Run it anywhere that runs a container and can keep it alive: a VM with Docker or systemd, Fly.io, Render, Railway, ECS/Fargate, a Kubernetes pod, etc.
Run exactly one instance. APScheduler runs inside the process and owns all scheduled jobs (CVE sync, Watchtower, Iris triage, and more). Two instances would fire every job twice. Do not enable autoscaling, and disable any "scale to zero" / sleep behavior: the scheduler fires overnight jobs (HIBP check at 03:00, CVE sync at 05:00, Watchtower at 05:30, posture snapshot at 06:00) and must stay running. See Scaling constraints.
Minimum footprint is modest: 1 shared CPU and 512 MB RAM is enough for a single-instance deployment.
Required environment
Provide the variables from the reference above as real environment variables or platform secrets, never baked into the image:
SUPABASE_URL=https://<your-project>.supabase.co
SUPABASE_ANON_KEY=<anon key>
SUPABASE_SERVICE_ROLE_KEY=<service role key> # server-side only
LLM_API_KEY=<your LLM provider key> # omit for no-cloud mode
SECRET_KEY=<output of: openssl rand -hex 32>
ENVIRONMENT=production
# Recommended: NVD_API_KEY, HIBP_API_KEYVerify the deploy
curl https://<your-backend-host>/health
# {"status": "ok"}Frontend (static bundle)
The frontend is a Vite + React SPA. Build it once and serve the output from any static host or CDN (Cloudflare Pages, Netlify, Nginx, S3 + CloudFront, GitHub Pages, etc.).
cd frontend
npm ci
npm run build # outputs to frontend/distBuild-time environment variables (Vite bakes these into the bundle, so they must be set when you run npm run build, not at serve time):
| Variable | Value |
|---|---|
VITE_SUPABASE_URL | Your Supabase project URL |
VITE_SUPABASE_ANON_KEY | Your Supabase anon key |
VITE_API_URL | Full URL of your backend, no trailing slash. Only needed if the frontend and backend are on different origins. |
Serve dist/ as a single-page app: rewrite unknown paths to /index.html so client-side routing works. If your static host and backend share an origin (via a reverse proxy that forwards /api/* to the backend), you can leave VITE_API_URL unset and the app calls /api same-origin, which avoids CORS entirely.
Supabase
Horus uses Supabase for:
- Postgres: primary database for all application data (assets, scans, findings, orgs, users).
- Auth: handles sign-up, login, sessions, and JWT issuance via GoTrue. GoTrue has its own built-in throttling for auth endpoints.
- Row Level Security (RLS): every table has RLS policies enforced at the database level. All data is scoped to the authenticated user's organization; soft-delete is enforced via
deleted_aton all entities (nothing is hard-deleted).
You can use Supabase Cloud (managed) or run Supabase yourself; Horus only needs a Postgres database and Supabase Auth reachable via the SUPABASE_URL and keys. Apply the SQL schema from supabase/migrations/ to your project (via the SQL editor on Supabase Cloud, or psql/the Supabase CLI against a self-hosted instance).
Redis and rate limiting
Redis is optional. When REDIS_URL is set and Redis is reachable at startup, the backend uses a Redis-backed sliding-window limiter (atomic via a Lua script). This is the correct choice for any deployment where more than one worker process runs, because rate-limit state is shared across all workers.
When REDIS_URL is not set, or when Redis is unreachable at startup, the backend automatically falls back to a per-process in-memory limiter and logs a warning. For a single-instance deployment with one worker process, the in-memory fallback is functionally equivalent, so Redis is only worth adding if you run multiple worker processes behind a load balancer.
Default budgets (all configurable via env vars):
| Scope | Default |
|---|---|
All /api routes, per IP, per minute | 120 requests |
POST /api/scans and POST /api/team/invite, per IP, per minute | 10 requests |
A rate-limited request receives HTTP 429 with a Retry-After header.
Health check
GET /healthReturns {"status": "ok"} with HTTP 200. No authentication required.
Point your platform's health check and any uptime monitor at this endpoint. A reasonable configuration is a poll every 30 seconds with a 5-second timeout and a ~20-second startup grace period; restart the container when it fails.
curl https://<your-backend-host>/healthSecurity headers
The backend attaches security headers to every response. The header set is the same in development and production, with one exception: Strict-Transport-Security is only sent outside development to avoid forcing HTTPS on a plaintext localhost connection.
| Header | Value | Notes |
|---|---|---|
X-Content-Type-Options | nosniff | Prevents MIME sniffing |
X-Frame-Options | DENY | Blocks the API from being embedded in a frame |
Referrer-Policy | no-referrer | Prevents leaking API URLs (which may carry IDs) to other origins |
Content-Security-Policy | default-src 'none'; frame-ancestors 'none' | Locks down responses to JSON only; nothing can be loaded or framed |
Cross-Origin-Opener-Policy | same-origin | Isolates the browsing context group |
Permissions-Policy | geolocation=(), camera=(), microphone=(), payment=() | Revokes access to powerful browser features |
Strict-Transport-Security | max-age=31536000; includeSubDomains | Production only. Forces HTTPS for one year including subdomains. |
Scaling constraints
The backend must run as a single instance.
APScheduler is embedded in the FastAPI process. It holds the state for all scheduled jobs:
- HIBP credential breach check (daily 03:00)
- CVE/KEV sync from CISA and EPSS (daily 05:00)
- Watchtower exposure re-correlation (daily 05:30)
- Posture snapshot (daily 06:00)
- Monthly posture report (1st of month, 07:00)
- Ransomware.live check (daily 06:30)
- Adversarial agent run (daily 02:00)
- Iris AI triage polling (every 15 minutes)
Running two instances would fire every job twice, produce duplicate alerts, and create race conditions in the database. Whatever platform you use, pin the deployment to a single always-on instance: no autoscaling, no scale-to-zero.
If you need to handle more concurrent scan throughput, increase PIPELINE_MAX_CONCURRENCY rather than adding machines. The scan queue will absorb demand up to that concurrency limit within the single process.
If you eventually need true horizontal scaling, APScheduler would need to be replaced with a distributed job scheduler (for example, pg_cron via Supabase, or a dedicated queue backed by Redis).