Self-host quickstart
Horologium ships as one Docker Compose file (docker-compose.yml at the
repository root) — the same artifacts the hosted demo
itself runs, just with HOROLOGIUM_READ_ONLY off and no data pre-loaded. There
is no separate "cloud build." This is the product.
:::info Self-host is by invitation during the Alpha
Per HORO-540, the source repository stays private during this Alpha
phase, and there is no public git clone or anonymous docker pull for
this quickstart yet. Self-host is available to approved Alpha/design
-partner users, who receive access to the artifact through a private
channel (repository collaborator access or a direct image handoff) rather
than a public link on this page. If you've been given access, resume this
quickstart from "What you get" below — every step past that point is
accurate and runnable once you have the artifact. If you're reading this
page without prior arrangement, the pilot/design-partner
form is the way to request access.
:::
What you get
horologium-server— the Rust API and the web dashboard, served from the same origin and port. No separate Node process or reverse proxy needed to see the UI.- PostgreSQL, with a named volume so your data survives container restarts
and
docker compose down. - Optional
horologium-mcp(read-only MCP adapter — see MCP / coding-agent integration) andhorologium-intelligence(semantic proposal generation) services, gated behind Compose profiles so they don't start uninvited.
No external Horonomy cloud dependency of any kind is required to run any of this.
Prerequisites
- Docker with Compose v2 (
docker compose version).
Quick start
# Get the artifact via whichever private channel you were given (repository
# collaborator `git clone`, or the direct image handoff) -- see the callout
# above; there is no public clone/pull for this quickstart.
cd horologium
cp .env.example .env
# Edit .env: set POSTGRES_PASSWORD to something real, e.g.:
# openssl rand -hex 24
# (hex, not base64 -- see .env.example's comment: base64's `/`, `+`, `=`
# characters break the unescaped postgres:// URL docker-compose.yml builds
# from this value)
docker compose up -d
docker compose up builds horologium-server's image (a multi-stage build:
the web dashboard is built first, then embedded into the Rust binary) and
starts postgres and horologium-server. horologium-server runs pending
database migrations on every startup, before it serves any traffic.
Check it's up:
curl -s http://127.0.0.1:8080/healthz # {"status":"ok"}
curl -s http://127.0.0.1:8080/readyz # {"status":"ready","database":"reachable"}
Open http://127.0.0.1:8080/app/ in a browser — that's the same
horologium-server process serving the dashboard, no separate URL or port.
(http://127.0.0.1:8080/ also works — it redirects to /app/.)
First data — seed the golden fixture
There is no operator-facing collection CLI wired up yet in this Public Alpha
slice — registering a real source and running a real collect/
collect_and_reconcile job (POST /v1/sources, POST /v1/jobs, see
Evidence sources) is possible today only for
local-filesystem-backed sources, driven by the separate horologium-runner
binary, not this dashboard. For a populated instance to explore with the
least setup, seed the same golden fixture the
hosted demo runs instead:
docker compose exec \
-e HOROLOGIUM_DATABASE_URL="postgres://horologium:$(grep ^POSTGRES_PASSWORD .env | cut -d= -f2)@postgres:5432/horologium" \
horologium-server /usr/local/bin/seed_golden_fixture
This writes claims, relationships and findings directly — it is dev/demo
tooling, not the real evidence-collection pipeline described in
Evidence sources. Run it once against a
freshly migrated database; re-running it mints duplicate rows. Refresh the
dashboard afterward to see a documentation_conflict finding and a
released_not_live finding, each with its full evidence citation and
timeline.
Confirm the seed worked without relying on the dashboard rendering correctly — query the API directly:
curl -s http://127.0.0.1:8080/v1/entities | jq '.items | length' # > 0
curl -s http://127.0.0.1:8080/v1/findings | jq '.items | length' # > 0
curl -s http://127.0.0.1:8080/v1/findings | jq '[.items[].finding_kind] | unique'
# expect ["documentation_conflict","released_not_live"] among the kinds present
A 0 from either count means the seeder didn't run (or ran against the
wrong database) — see Troubleshooting.
Configuration
All configuration lives in .env (copy .env.example, which documents every
variable and contains no secrets — only names and non-sensitive defaults;
.env itself is gitignored).
The full, canonical environment variable reference — including
horologium-runner's tuning knobs, the egress allowlist, bounded-worker
resource limits, rollback limits and the cost/retention note — lives in
docs/self-host.md's "Configuration" section
in the repository root, not duplicated here.
Security posture — read before exposing the port to anyone but yourself
Capability-scoped credentials are real and enforced (ADR-0021, HORO-1394)
— every /v1/* route resolves a RequestScope from an Authorization: Bearer <token> header. No credential presented resolves to read:truth
only (the same anonymous-read posture the hosted demo runs on); a
presented credential that's malformed, unknown, revoked, expired, or
scoped to a different workspace/product is rejected (401/403), not
silently downgraded to anonymous. Mint one with horologium-credential mint --name <name> --caps <cap1,cap2> — see
crates/horologium-api/README.md's "Operator credentials" section.
HOROLOGIUM_HOST_PORTdefaults to127.0.0.1:8080— reachable from the host machine only. Do not bind it to a non-loopback address without a reverse proxy in front — auth is per-request, not a substitute for network isolation, and the anonymousread:truthdefault means an unauthenticated caller can still read every/v1/*GET route.- PostgreSQL is never published to the host — there is no
ports:entry for it.horologium-serverreaches it only over the internal Compose network. - The container runs as a non-root user in every image.
- Neither
horologium-mcpnorhorologium-intelligenceis ever issued a database credential — visible directly indocker-compose.yml's service definitions.
See Security, privacy and data boundaries for the
full model, and SECURITY.md in the repository for the complete statement.
Optional services
horologium-mcp
Runs over stdio, not as an always-on network daemon — docker compose up
never starts it on its own. Launch it per session:
docker compose run --rm horologium-mcp
See MCP / coding-agent integration for wiring this into a coding agent.
horologium-intelligence
Defined behind the intelligence Compose profile so its topology — in
particular, that it holds no database credential — is visible and
testable. It has no standalone worker entrypoint yet in this Public Alpha
slice; there is nothing to run today beyond building the image.
Stopping, restarting, upgrading
docker compose down # stops and removes containers; your data (the named
# volume) is untouched
docker compose up -d # starts again -- migrations re-run (a no-op against
# an already-migrated schema)
git pull
docker compose build horologium-server
docker compose up -d # runs pending migrations on startup
If a migration is genuinely incompatible with your data, the container exits non-zero with a clear log message and does not start serving traffic — it does not attempt to run against a schema it doesn't recognize.
Backup and restore
PostgreSQL-native tooling only — no custom scripts.
# Backup
docker compose exec postgres pg_dump -U horologium -d horologium -Fc \
> horologium-backup-$(date +%Y%m%d-%H%M%S).dump
# Restore (into a fresh, empty database -- this does not merge)
docker compose down
docker volume rm horologium-selfhost_horologium-pgdata
docker compose up -d postgres
docker compose exec -T postgres pg_restore -U horologium -d horologium --clean --if-exists \
< horologium-backup-20260101-120000.dump
docker compose up -d horologium-server
Troubleshooting
docker compose up fails immediately with a POSTGRES_PASSWORD error.
Expected — copy .env.example to .env and set a real password. There is
deliberately no default.
horologium-server keeps restarting. Check
docker compose logs horologium-server --tail 20. A
"database migration failed at startup" message means the schema in your
volume doesn't match this version's expectations — this is documented
fail-closed behavior, not a bug. Restore from a pre-upgrade backup, or start
from a fresh volume.
The dashboard loads but shows nothing. You haven't seeded any data yet — see First data above. There is no production evidence-collection pipeline wired up in this Public Alpha slice (see Evidence sources and Public Alpha limitations).
Port 8080 is already in use. Set a different HOROLOGIUM_HOST_PORT in
.env and re-run docker compose up -d.
I need to inspect the database directly. It's never published to the
host, by design. Use docker compose exec postgres psql -U horologium -d horologium to get a shell on the internal network instead of exposing the
port.
For the full, unabridged version of this guide — kept in sync as the source of
truth — see docs/self-host.md in your copy of the repository (the
repository is private during this Public Alpha per HORO-540; there is no
public URL for this file — approved partners already have repository
access).