rhorizon up and running in 5 minutes.
- Docker + Compose v2
- VPN access (Tailscale, OpenVPN, ...) - rhorizon must never be exposed on the internet
git clone https://github.com/JR-Shdw/Horizon.git rhorizon
cd rhorizon
# Copy the env template and generate a Postgres password
cp env.example .env
sed -i "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(openssl rand -hex 32)|" .env
# Or run the helper:
# make secretsPick the right compose file - the repository ships two.
# Recommended: generates the TLS certificate, enables it, prints the URLs
sh tools/install.shThat is the only path that sets TLS up for you. Driving compose directly works
too, but ships without a certificate (TLS_ENABLED defaults to false),
so the stack is plaintext until you supply one in ./certs:
docker compose -f tools/docker-compose.quickstart.yml up -d| File | Publishes on | Use it for |
|---|---|---|
tools/docker-compose.quickstart.yml |
127.0.0.1 - API :8200, UI :8080 (HTTP) and :8443 (TLS) |
Laptops, single hosts, evaluation |
docker-compose.yml (root) |
${WG_IP:-10.0.0.20} and 10.1.0.1 - API :8200, UI :8201 |
The operator/VPN stack |
The root file hardcodes VPN addresses, so on a host without them Docker
refuses to start the stack ("Couldn't listen on requested ports"). Override
the quickstart binds with RH_API_BIND, RH_FRONTEND_BIND, RH_API_PORT,
RH_FRONTEND_PORT and RH_FRONTEND_HTTP_PORT; the root file uses WG_IP and
WG1_IP instead.
sh tools/install.sh picks the quickstart file for you and prints the URL.
Three containers start: PostgreSQL, API, Frontend. Schema is applied automatically on first boot.
# Health
curl --cacert ~/rhorizon/certs/cert.pem https://127.0.0.1:8443/health
# {"status": "ok"}
# Status (sealed by default)
curl --cacert ~/rhorizon/certs/cert.pem https://127.0.0.1:8443/api/v1/vault/status
# {"sealed": true, "version": "0.9.5-beta", ...}The installer leaves the vault sealed and does not invent a master password. The first unseal creates the master key from your password. Choose a strong password - it protects everything.
Unattended installs.
tools/install.sh --master-password VALUE(orRH_MASTER_PASSWORD) unseals for you instead. That path is the only one that writes credentials to disk, and it writes both:~/rhorizon/secrets/master-password ~/rhorizon/secrets/root-tokenMode 0400, and together they are enough to take full control of the instance. Move them into a password manager and delete the files once the backup is verified. Note the value also lands in your shell history and, for the life of the process, in
/proc/<pid>/cmdline.The native installer (
tools/install-native.sh) uses the same layout under its own config directory,~/.config/rhorizon/secrets/in user mode, and/etc/rhorizon/secrets/in Linux system mode (/usr/local/etc/rhorizonon FreeBSD,/usr/pkg/etc/rhorizonon NetBSD). It warns you about it at the end of the run.A native system install runs the API as the dedicated, non-login
rhorizonaccount while keeping recovery files owned by root and unreadable by that service. Account creation failure stops the install; running as root requires the explicitRH_ACCOUNT_FALLBACK_ROOT=1override. OpenBSD is the documented exception for now: its stockdaemonlogin class allows only 87381 KiB of locked memory, below the 622592 KiB home-tier budget, so the service remains root-run rather than silently losingmlock.
cd cli
python -m venv .venv
. .venv/bin/activate
pip install -e .
export RH_ADDR=https://127.0.0.1:8443
export RH_CA_FILE=~/rhorizon/certs/cert.pem
rhorizon unseal
# Master password: ********
# Status: unsealedThe CLI reads the password without echoing it or placing it in shell history. Store the one-time root token it returns in your password manager.
The stack comes up on TLS.
tools/install.shgenerates a self-signed certificate (SANlocalhost+127.0.0.1, 825 days) and setsTLS_ENABLED=true, so the UI and API are onhttps://127.0.0.1:8443. It prints two lines to add to your shell profile:export RH_ADDR=https://127.0.0.1:8443 export RH_CA_FILE=~/rhorizon/certs/cert.pem
RH_CA_FILEis what makes the self-signed certificate trusted by the CLI and therh-*agents - without it they correctly refuse to connect. There is no skip-verify switch.A browser does not read
RH_CA_FILE, so it warns on the first visit. This is not an encryption failure: HTTPS is already active, but the certificate is self-signed and the browser does not know it yet. Open the certificate details and compare its SHA-256 fingerprint with the value printed by the installer before accepting it.To remove the warning, import this verified certificate into the browser or operating-system trust store. Alternatively, for a shared instance, use a domain you control and a free public certificate such as Let's Encrypt. Public CAs cannot certify
localhostor a private IP; a private-network Horizon can use DNS-01 and split DNS. Platform-specific browser, OS, WSL, Docker, Podman and public certificate instructions are in TLS.Plain HTTP is still listening on
:8080and:8200for debugging, but the vault logs aPLAINTEXT TRANSPORTwarning for every call that uses it, loopback included - same-host traffic is still readable by any process withCAP_NET_RAW, and in a pod "same host" means a sibling container.
Open the UI at https://127.0.0.1:8443, go to Core (settings icon),
paste your root token. You'll need it for all operations.
Or use the CLI:
rhorizon login 127.0.0.1:8443 # bare host defaults to https
# Enter your token when prompted
rhorizon status
# Status: UNSEALED
# Version: 0.9.5-betarhorizon set prod/db-password "s3cure-p4ssw0rd" -n prod
rhorizon get prod/db-password
# s3cure-p4ssw0rd
rhorizon list
# prod/db-password v1 [prod]rhorizon token create ops-reader '{"secrets":"r"}'
# Token: rh_xxxxxxxxxxxx
# (shown once - save it now)curl --cacert ~/rhorizon/certs/cert.pem \
-X POST https://127.0.0.1:8443/api/v1/vault/totp/setup \
-H "Authorization: Bearer $TOKEN"
# {"secret": "BASE32SECRET", "uri": "otpauth://..."}
# Scan the URI as QR code in your auth app, then:
curl --cacert ~/rhorizon/certs/cert.pem \
-X POST https://127.0.0.1:8443/api/v1/vault/totp/enable \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"code": "123456"}'
curl --cacert ~/rhorizon/certs/cert.pem \
-X PUT "https://127.0.0.1:8443/api/v1/vault/2fa?mode=totp" \
-H "Authorization: Bearer $TOKEN"Register a security key directly from the browser - no CLI needed.
- Open the UI, go to Core (settings)
- In the Two-Factor Authentication section, click + Register Security Key
- Enter a name and click Touch to register
- Touch your security key when the browser prompts
- Set 2FA mode to
yubikey(accepts both WebAuthn and HMAC-SHA1)
Unseal with WebAuthn:
On the Horizon (dashboard) page, click Touch Security Key and touch your key. The browser handles the entire flow - no CLI commands needed.
Note: WebAuthn requires HTTPS or
localhost. For CLI/automation, use HMAC-SHA1 below.
# 1. Program slot 2 with a HMAC-SHA1 secret
ykman otp chalresp --generate 2
# Save the 40-char hex secret displayed
# 2. Get the serial number
ykman info
# Serial: 12345678
# 3. In Core > 2FA, register the serial and HMAC secret, then select YubiKey.
# The UI keeps the HMAC secret out of shell history and process arguments.Unseal with YubiKey:
Use the Core view in the UI and click Touch YubiKey to authenticate. The browser handles the challenge and submits the password without placing it in shell history.
Advanced, multi-worker only. See multiworker.md.
# Logical encrypted backup via API (partial migration artifact):
rhorizon backup export ./rhorizon-backup.ageUse pg_dump | age for full-fidelity disaster recovery.
rhorizon runs unchanged on Podman and Docker rootless. The compose file
uses only standard primitives (cap_drop, no-new-privileges,
read_only, tmpfs, pids_limit, memory limits) supported by both
runtimes.
Pass -f tools/docker-compose.quickstart.podman.yml. Without -f, compose
picks up docker-compose.yml from the current directory -- the cluster file
that binds VPN addresses, which this guide told you not to use on a laptop.
The Podman variant targets localhost and uses the portable tmpfs and plain
depends_on forms that rootless Podman needs.
# Either invoke podman-compose directly:
podman-compose -f tools/docker-compose.quickstart.podman.yml up -d
# Or generate Quadlet units for systemd (recommended on EL/Fedora):
podman compose -f tools/docker-compose.quickstart.podman.yml --in-pod=true up -dsh tools/install.sh selects that file for you when it detects Podman; the
commands above are for driving compose by hand.
Pass -f here for the same reason as Podman: a bare docker compose up -d
from the repository root picks docker-compose.yml, the cluster file that
binds VPN addresses.
dockerd-rootless-setuptool.sh install
# uses the rootless socket automatically
docker compose -f tools/docker-compose.quickstart.yml up -dThe portable default does not request IPC_LOCK or an unlimited memlock
ulimit. The API reports Rust buffer locking in memory_protection and
whole-process locking in process_memory_protection. The wipe implemented by
zeroize still runs on drop, so keys are cleared from heap when released. A
warning is needed only when swap is unencrypted or cannot be verified;
encrypted swap, zram, and no swap already prevent this persistent-swap
exposure.
The quickstart detects this on the host and writes RH_SWAP_PROTECTION to its
.env. A manually managed Compose deployment defaults to unknown until the
operator records protected or unencrypted there.
On a host with unencrypted swap, enforce memory locking on a runtime that permits it:
cd ~/rhorizon
docker compose -f docker-compose.yml \
-f docker-compose.memory-lock.yml --env-file .env up -dThis override requests IPC_LOCK, sets an unlimited memlock ulimit, and changes
the application policy to required. If the runtime cannot provide them, the
explicitly hardened start fails; remove the override to return to best effort.
- Bind to ports < 1024 - bind to
127.0.0.1:8200and front with a rootful reverse proxy if you need :443. - AppArmor / SELinux profile names differ - the bundled profiles assume rootful Docker. Use the runtime's defaults until you write rootless equivalents.
docker execfrom another user - only the running user can attach.
For a sovereign / single-tenant on-prem deployment, rootless + Podman is the recommended path.
flowchart LR
C[Client] --> N[Nginx :8200] --> A[FastAPI :8200] --> P[PostgreSQL :5432]
N -.->|Security headers<br/>HSTS, CSP, gzip| C
A -.->|Encryption<br/>Argon2id, HKDF<br/>XChaCha20, AES-GCM, HMAC-SHA512| P
All containers on internal Docker network. Only nginx binds to host (default: 127.0.0.1 only). rhorizon is meant for a restricted local network: reach it over a VPN or a private network, never the public internet.
- Full API reference
- Roadmap - dynamic secrets, LDAP, container injection
- Security policy
AGPL-3.0 - Free to use, modify, and deploy. Modifications must be published under AGPL-3.0.
A commercial license is available for managed service providers, organizations with AGPL restrictions, or guaranteed support needs.