A tiny, self-hosted uptime monitor written in Rust. One small binary probes your services, stores history in SQLite, alerts you when something breaks (or a TLS certificate is about to expire), and serves a server-rendered status page plus a JSON API. The Docker image is a static musl binary on Alpine - about 15 MB.
Named after the Horai, the Greek goddesses of the hours.
- HTTP, TCP, ICMP, push & assertion probes - per-monitor interval, timeout,
expected status and a "degraded if slower than" threshold. HTTP monitors can assert
a keyword in the body or a JSONPath (
json_query/json_expected), route through an HTTP/SOCKS proxy, and send custom headers. ICMP (ping) monitors use an unprivileged datagram socket - noCAP_NET_RAW, rootless-Docker friendly, IPv4 and IPv6. Push (heartbeat) monitors flip to down when a job stops pinging/api/push/{id}. - Dependency-aware topology - cluster monitors into named groups on the status
page, and declare upstreams with
depends_on. When a monitor goes down its alert is annotated with root cause vs. symptom: "caused by X" when an upstream it depends on is also down, or "impacts: A, B, C" (the blast radius) when its upstreams are all healthy and it is the root cause. The dependency graph is validated acyclic at load. - Server-rendered status page (no JavaScript framework): a compact, responsive grid - daily uptime bars, an inline SVG latency chart, p95/p99 latency with an optional latency SLO indicator, plus an incidents/announcements banner.
- JSON API to read status and latency history from anywhere, with a generated
OpenAPI 3.1 document at
/api/openapi.json. - TLS certificate expiry monitoring with advance warnings.
- Pluggable notifications via a
Notifiertrait - Telegram, Discord, Slack, Matrix, a generic JSON webhook, SMTP e-mail and Free Mobile SMS built in. Channels are named, so you can have several of the same type and route each monitor to specific ones (notify = [...]). Delivery retries transient failures, and alerts fire only after N consecutive failures (so flapping never wakes you up) and include a snippet of the failing response body. Optionally alert on degraded too (alert_on_degraded- up, but slower than the monitor'sdegraded_over_ms). - Scheduled maintenance windows that mute alerts (per monitor or global).
- Per-IP API rate limiting on the JSON endpoints, with a configurable trusted
client-IP header (e.g.
cf-connecting-ipbehind Cloudflare). - Live config reload: edit
config.toml(or sendSIGHUP) and monitors, thresholds, retention and notification channels are reconciled in place - existing checks never pause, so there is no blind window. - Per-monitor retention with automatic pruning; the database does not grow forever.
${VAR}interpolation in the config so secrets stay in the environment.- Single self-contained binary: migrations and templates are compiled in.
mkdir -p hora-config && cp config.example.toml hora-config/config.toml
# edit hora-config/config.toml
docker run -d --name hora --restart unless-stopped \
-p 8787:8787 \
-v "$PWD/hora-config:/etc/hora" \
-v hora-data:/data \
ghcr.io/uplg/hora:latestThe status page is at http://localhost:8787/. Put it behind your reverse proxy
on whatever domain you like - Hora is self-contained and assumes nothing about who
consumes it.
ICMP (kind = "icmp") monitors use an unprivileged datagram socket, so they
need no extra capability as long as the container's group id is within the
kernel's net.ipv4.ping_group_range - Docker's default (0 2147483647) already
covers the image's 10001 user, including rootless Docker. If your host
narrows that range, either widen it
(--sysctl net.ipv4.ping_group_range="0 2147483647") or grant --cap-add NET_RAW;
otherwise icmp monitors simply report down with a clear reason.
Secrets are best kept in the environment: any ${VAR} in the config is replaced
from the environment at load. So in the file:
[[channels]]
name = "ops"
type = "telegram"
token = "${HORA_TELEGRAM_TOKEN}"
chat_id = "123456"and on the container: -e HORA_TELEGRAM_TOKEN=123:abc. Only HORA_BIND,
HORA_DATABASE_PATH and HORA_CONFIG are read directly from the environment.
docker pull ghcr.io/uplg/hora:latest
docker stop hora && docker rm hora
docker run -d --name hora --restart unless-stopped \
-p 8787:8787 \
-v "$PWD/hora-config:/etc/hora" \
-v hora-data:/data \
ghcr.io/uplg/hora:latestYour history lives on the hora-data volume and survives upgrades.
Notification config moved from per-type singletons to named channels, so you
can run several of the same type and route monitors to specific ones. The fixed
HORA_* secret variables are replaced by ${VAR} interpolation.
# 0.1.x # 0.2
[telegram] [[channels]]
token = "…" # or HORA_TELEGRAM_… name = "telegram"
chat_id = "…" type = "telegram"
token = "${HORA_TELEGRAM_TOKEN}" # same env var still works
chat_id = "…"HORA_BIND / HORA_DATABASE_PATH are unchanged. If you run the container as the
non-root user for the first time on an existing volume, fix its ownership once:
docker run --rm -v hora-data:/data alpine chown -R 10001:10001 /data.
See config.example.toml for every option. The file is
read from $HORA_CONFIG (default ./config.toml).
To add, remove or change a monitor without downtime, just edit the config:
- Bare metal / mounted directory: Hora watches the file and reloads automatically.
- Anywhere:
kill -HUP <pid>- or in Docker,docker kill -s HUP hora.
On reload, unchanged monitors keep running untouched; only new/removed/changed
ones are started or stopped, and the notification channels are rebuilt - so
adding a Telegram token takes effect live too. Only server.bind and the API
rate-limit settings are read once at startup and still require a restart.
| Endpoint | Description |
|---|---|
GET / |
The HTML status page. |
GET /api/summary |
All monitors: status, 24h uptime (per-mille), p50/p95/p99 latency, cert days left, daily history; plus active incidents. |
GET /api/monitors/{id}/latency?hours=24 |
Latency samples [{ "t", "latency_ms" }] (404 if unknown). |
POST /api/push/{id}?token=… |
Record a heartbeat for a push monitor. The token may instead be sent as an X-Push-Token header, keeping it out of proxy access logs. Optional status=up|down|degraded, msg, ping. 401 on a wrong token, 404 if not a push monitor. |
GET /api/badge/{id}/status |
Embeddable SVG status badge for a monitor. |
GET /api/badge/{id}/uptime |
Embeddable SVG 24h-uptime badge for a monitor. |
GET /api/openapi.json |
The OpenAPI 3.1 spec, generated from the code (utoipa). |
GET /healthz |
Liveness probe. |
The /api/* endpoints (summary, latency, push) are rate-limited per client IP
(configurable; read once at startup) and send x-ratelimit-* / retry-after
headers; the badges and /api/openapi.json are not. The client IP is taken from
X-Forwarded-For / X-Real-IP by default, so run Hora behind a proxy that sets
it - a direct client could otherwise spoof it. Behind Cloudflare, set
server.client_ip_header = "cf-connecting-ip" and lock the origin to Cloudflare.
allowed_origins controls CORS (empty = allow any, since the data is read-only and
public). Responses carry a strict CSP, X-Content-Type-Options: nosniff and
X-Frame-Options: DENY, plus an x-request-id (an inbound one is honoured,
otherwise a fresh id is minted) echoed on the response for log correlation.
Point any client (Bruno, Insomnia, Scalar, Swagger Editor…) at /api/openapi.json.
Embed a monitor's live status and 24h uptime in a README, by its config id:

Flat shields-style SVGs: green when up / uptime is high, amber for minor incidents, red for an outage. A 404 is returned for an unknown id.
A small Cargo workspace:
hora-notify- theNotifiertrait,Eventtype,Dispatcher, and the Telegram / Discord / Slack / webhook / SMTP implementations. Add a channel by implementing the trait.hora-core- configuration, probing, SQLite storage, TLS-expiry checks, the per-monitor scheduler, and the supervisor that owns live config + reconciles monitor tasks on reload.hora-web- the axum router, view model and Askama status page template.hora- the binary that wires it all together.
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all -- --check
cargo deny check
# run locally
cp config.example.toml config.toml # then edit
cargo run -p horaRequires a C toolchain + cmake (for aws-lc-rs, the rustls crypto provider).
MIT - see LICENSE.
The status page embeds the Cal Sans font, used
under the SIL Open Font License - see
crates/hora-web/assets/OFL.txt.
