Skip to content

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

  1. Local development
  2. Environment variable reference
  3. Production deployment
  4. Redis and rate limiting
  5. Health check
  6. Security headers
  7. Scaling constraints

Local development

The project ships a docker-compose.yml at the repo root. It starts three services:

ServicePortDescription
backend8000FastAPI app with hot-reload via Uvicorn
frontend5173Vite dev server (proxies API calls to the backend)
mailpit1025 (SMTP), 8025 (web UI)Local email catcher; no real mail is sent

Start everything

bash
# Copy the example env file and fill in the required values (see the reference below)
cp .env.example .env

docker compose up

The backend mounts ./backend into the container, so code changes reload automatically without rebuilding the image.

Useful shortcuts

bash
# 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 up

What 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)

VariableRequiredDescriptionExample
SUPABASE_URLYesYour Supabase project URLhttps://abcdefgh.supabase.co
SUPABASE_ANON_KEYYesPublic anon key (safe to expose to the browser)eyJhbGciOiJIUzI1NiIs...
SUPABASE_SERVICE_ROLE_KEYYesService role key (server-side only, never sent to the browser)eyJhbGciOiJIUzI1NiIs...

LLM provider

VariableRequiredDescriptionExample
LLM_BASE_URLNoOpenAI-compatible API base URLhttps://openrouter.ai/api/v1
LLM_API_KEYNoAPI key for the LLM providersk-or-v1-...
LLM_DEFAULT_MODELNoModel string used by all agents unless overriddenanthropic/claude-opus-4-5
LLM_TIMEOUT_SECONDSNoPer-request timeout in seconds (default: 60.0)60.0
LLM_MAX_RETRIESNoRetry count for transient LLM errors (default: 2)2
LLM_ENABLEDNoSet to false for fully deterministic, no-cloud mode (default: true)false

Per-agent model overrides (all optional; fall back to LLM_DEFAULT_MODEL):

VariableAgent
LLM_ANALYST_MODELDomain analyst
LLM_THREAT_INTEL_MODELThreat intel
LLM_VALIDATION_MODELRed/blue debate validator
LLM_REMEDIATION_MODELRemediation drafter
LLM_RISK_MANAGER_MODELRisk manager
LLM_REPORTER_MODELReport generator
LLM_RED_MODELRed adversarial agent
LLM_BLUE_MODELBlue adversarial agent
LLM_PHISHING_MODELPhishing simulation agent
LLM_IRIS_TRIAGE_MODELIris AI triage

Application

VariableRequiredDescriptionExample
ENVIRONMENTNodevelopment or production; controls HSTS and other headers (default: development)production
SECRET_KEYYes (prod)Secret used for signing; change from the default changeme before deployinga-long-random-string

Rate limiting

VariableRequiredDescriptionExample
RATE_LIMIT_ENABLEDNoToggle all rate limiting (default: true)true
RATE_LIMIT_PER_MINUTENoPer-IP request budget for all /api routes (default: 120)120
RATE_LIMIT_SENSITIVE_PER_MINUTENoTighter budget for write-heavy endpoints like POST /api/scans (default: 10)10
TRUST_PROXY_HEADERSNoHonor X-Forwarded-For when behind a trusted reverse proxy (default: false)true
REDIS_URLNoRedis connection string; enables shared rate-limit state across workers. Falls back to per-process in-memory when unset.redis://localhost:6379/0

Scan pipeline

VariableRequiredDescriptionExample
PIPELINE_MAX_CONCURRENCYNoMaximum number of scans running in parallel (default: 2)2
SCAN_MAX_RETRIESNoAuto-retry count for failed scheduled scans (default: 1)1
SCAN_BLACKOUT_WINDOWSNoComma-separated time ranges when scheduled scans are skippedMon-Fri 09:00-18:00
SCAN_BLACKOUT_TIMEZONENoIANA timezone for blackout windows (default: server local time)Europe/Madrid

CVE and vulnerability intelligence

VariableRequiredDescriptionExample
CVE_SYNC_ENABLEDNoEnable daily CISA KEV and EPSS sync (default: true)true
CVE_SYNC_CRONNoCron schedule for CVE sync (default: 0 5 * * *)0 5 * * *
CVE_SYNC_INCLUDE_EPSSNoInclude EPSS scores (~250k rows); disable in dev to save time (default: true)false
NVD_API_KEYNoNVD API key; raises rate limit from 5 to 50 req/30s. Get one free at nvd.nist.gov.abc123-...

Notifications

VariableRequiredDescriptionExample
SMTP_HOSTNoSMTP server hostname for email notificationssmtp.resend.com
SMTP_PORTNoSMTP port (default: 587)587
SMTP_USERNoSMTP usernameapikey
SMTP_PASSWORDNoSMTP password or API keyre_...
SMTP_FROMNoSender addressalerts@yourdomain.com
SMTP_USE_TLSNoEnable STARTTLS (default: true)true
NOTIFY_DEFAULT_MIN_SEVERITYNoMinimum severity to trigger a notification (default: high)medium

Optional integrations

VariableRequiredDescriptionExample
SHODAN_API_KEYNoShodan API key for enriched asset dataabc123...
HIBP_API_KEYNoHaveIBeenPwned Domain Search API key; HIBP checks are disabled without itabc123...
TAVILY_API_KEYNoTavily web search key used by the adversarial agentstvly-...
GITHUB_TOKENNoGitHub personal access token for exploit/PoC searches (rate-limit is 10 req/min without one)ghp_...

Privacy and data controls

VariableRequiredDescriptionExample
REDACTION_ENABLEDNoPseudonymize 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:

bash
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_KEY

Verify the deploy

bash
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.).

bash
cd frontend
npm ci
npm run build      # outputs to frontend/dist

Build-time environment variables (Vite bakes these into the bundle, so they must be set when you run npm run build, not at serve time):

VariableValue
VITE_SUPABASE_URLYour Supabase project URL
VITE_SUPABASE_ANON_KEYYour Supabase anon key
VITE_API_URLFull 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_at on 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):

ScopeDefault
All /api routes, per IP, per minute120 requests
POST /api/scans and POST /api/team/invite, per IP, per minute10 requests

A rate-limited request receives HTTP 429 with a Retry-After header.


Health check

GET /health

Returns {"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.

bash
curl https://<your-backend-host>/health

Security 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.

HeaderValueNotes
X-Content-Type-OptionsnosniffPrevents MIME sniffing
X-Frame-OptionsDENYBlocks the API from being embedded in a frame
Referrer-Policyno-referrerPrevents leaking API URLs (which may carry IDs) to other origins
Content-Security-Policydefault-src 'none'; frame-ancestors 'none'Locks down responses to JSON only; nothing can be loaded or framed
Cross-Origin-Opener-Policysame-originIsolates the browsing context group
Permissions-Policygeolocation=(), camera=(), microphone=(), payment=()Revokes access to powerful browser features
Strict-Transport-Securitymax-age=31536000; includeSubDomainsProduction 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).

Released under the MIT License.