Self-hosted uptime & status-page tool: scheduled health checks, incident tracking, and Discord alerting.
Project description
pulsewatch
A self-hosted uptime & status-page tool. Pings the services you configure on their own schedule, tracks response history, opens/resolves incidents automatically, and alerts you on Discord the moment something goes down — then again when it recovers.
Built with FastAPI + SQLite + APScheduler. No external database or paid service required to run it.
Why this exists
Every team running more than a couple of services eventually needs to know immediately when one goes down, not when a user complains. Tools like UptimeRobot and Statuspage.io solve this commercially — this is a from-scratch implementation of the same core mechanics: scheduled health checks, failure thresholds (so one blip doesn't page you), incident tracking, and alerting.
Features
- Configurable list of services to monitor (
config.yaml), each with its own check interval and failure threshold - Monitor upstream dependencies as well as your own services: AWS region
health, database connectivity (
SELECT 1), or any third-party status API — the dashboard and alerts make clear which failures are yours vs an upstream's - Automatic incident creation when a service crosses its failure threshold, and automatic resolution on recovery
- Pluggable alert channels (Discord, Slack, email) on both down and recovery events — enable as many as you like
- Live dashboard (
/) showing current status, 24h uptime %, a per-service response-time sparkline (last hour, via Chart.js), and incident history - Check from multiple named regions — independent worker processes that each report their own per-region status for a service
- JSON API (
/api/status,/api/services/{id}/history,/api/services/{id}/response-times) for scripting or integration elsewhere
Installing
pulsewatch is a proper installable package. Install it in editable mode from a clone:
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -e .
This puts a pulsewatch command on your PATH. Run it with no arguments to see
the full help menu:
pulsewatch --help
Then copy the example config and fill in your own values (webhook URLs, etc.):
cp config.example.yaml config.yaml
config.yaml is gitignored, so your real webhook URLs and connection strings
never get committed — keep secrets there, not in config.example.yaml.
Run with Docker
As an alternative to the pip install above, you can run pulsewatch in a container. You only need Docker — no local Python.
cp config.example.yaml config.yaml # create your config first (edit as needed)
docker compose up -d # build the image and start the app
Then open http://localhost:8000. The dashboard and API are served on port 8000.
- Config is bind-mounted from your local
config.yaml(read-only). Edit it on the host anddocker compose restartto pick up changes. - The SQLite database persists across restarts and rebuilds in the
pulsewatch-datanamed volume — your ping history and incidents survivedocker compose restart/up/down. (Usedocker compose down -vto also wipe the data volume.)
docker compose logs -f # follow logs
docker compose down # stop (keeps the data volume)
Prefer plain Docker? The equivalent without Compose:
docker build -t pulsewatch .
docker run -d --name pulsewatch -p 8000:8000 \
-v "$PWD/config.yaml":/data/config.yaml:ro,z \
-v pulsewatch-data:/data \
pulsewatch
config.yamlmust exist before you start the container — otherwise Docker creates an empty directory in its place. The,zon the config mount relabels it for SELinux hosts (Fedora/RHEL) and is a harmless no-op elsewhere.
The CLI
pulsewatch init # write a default config.yaml here
pulsewatch add --name "My API" \
--url https://api.example.com/health \
--interval 30 --threshold 3 # append a service, no hand-editing
pulsewatch serve # start the app + background monitor
pulsewatch initcreates aconfig.yamlin the current directory (use--forceto overwrite an existing one).pulsewatch addappends a service to the activeconfig.yaml(--intervaland--thresholdare optional and default to 30s / 3 failures). If no config exists yet it creates one in the current directory.pulsewatch servereplaces the olduvicorn app.main:appcommand. It accepts--host,--port,--reload, and--region(see Multiple regions).pulsewatch worker --region NAMEruns a checks-only worker for an additional region, no web server.
Then open http://127.0.0.1:8000
Configuring services
Start from the committed template (cp config.example.yaml config.yaml), then
hand-edit it or use pulsewatch add. pulsewatch looks for config in this order:
./config.yaml(the current working directory)~/.pulsewatch/config.yaml(per-user fallback)
services:
- name: "My API"
url: "https://api.example.com/health"
check_interval_seconds: 30
failure_threshold: 3
alerts:
- type: discord
webhook_url: "https://discord.com/api/webhooks/..."
- type: slack
webhook_url: "https://hooks.slack.com/services/..."
- type: email
to: "oncall@example.com"
from: "pulsewatch@example.com" # optional; defaults to the SMTP user
failure_threshold is how many consecutive failed checks are needed before
an incident opens — this avoids firing an alert on a single network blip.
Monitoring upstream dependencies
Every service defaults to check_type: http (a plain URL ping, unchanged). Set
check_type: dependency with a dependency_kind to watch an upstream provider
instead. These are tagged DEPENDENCY on the dashboard and alert as "Upstream
dependency … is degraded" rather than "Your service is down", so you can tell
your own outage from a provider's at a glance.
aws_status — polls the public AWS Health feed
(https://health.aws.amazon.com/public/currentevents) and reports down only
if your region (and optionally specific services) are impacted, not on any AWS
event anywhere. region is required; services is an optional filter.
- name: "AWS us-east-1"
check_type: dependency
dependency_kind: aws_status
region: "us-east-1"
services: ["EC2", "S3"] # optional
failure_threshold: 1
database — runs a lightweight SELECT 1 over a SQLAlchemy connection
string instead of an HTTP request. Install the matching driver for your DB
(e.g. psycopg2-binary for Postgres, PyMySQL for MySQL; SQLite needs none).
Credentials are never shown on the dashboard or API — the target is redacted to
scheme://host/db.
- name: "Primary Postgres"
check_type: dependency
dependency_kind: database
connection_string: "postgresql://user:pass@localhost:5432/mydb"
custom_api — fetches a JSON endpoint and asserts a nested field equals an
expected value (dotted path), not just the HTTP status. Great for a provider's
own status API (e.g. Stripe is healthy when status.indicator == "none").
- name: "Stripe API"
check_type: dependency
dependency_kind: custom_api
url: "https://status.stripe.com/api/v2/status.json"
json_field: "status.indicator"
expected_value: "none"
To add a new dependency kind, write a checker in
checks.py returning a CheckResult and register it
in DEPENDENCY_CHECKERS.
Alert channels
Both down and recovery events are sent to every channel in the alerts: list.
With no channels enabled, alerts are logged to the console — so the project
still runs and is demoable with zero setup.
| Type | Config keys | Notes |
|---|---|---|
discord |
webhook_url |
Server Settings → Integrations → Webhooks → New |
slack |
webhook_url |
A Slack Incoming Webhook URL |
email |
to, from (optional) |
SMTP server/credentials come from env vars (below) |
Email keeps secrets out of config.yaml by reading the SMTP connection from
the environment:
| Variable | Default | Purpose |
|---|---|---|
PULSEWATCH_SMTP_HOST |
— | SMTP server (required to send email) |
PULSEWATCH_SMTP_PORT |
587 |
SMTP port |
PULSEWATCH_SMTP_USER |
— | Username (enables login if set) |
PULSEWATCH_SMTP_PASSWORD |
— | Password (enables login if set) |
PULSEWATCH_SMTP_FROM |
user | Fallback From address |
PULSEWATCH_SMTP_STARTTLS |
true |
Set false to disable STARTTLS |
To add a channel of your own, subclass AlertChannel in
alerts.py, implement send(message), and register
it in _CHANNEL_BUILDERS.
Multiple regions
You can check your services from several named regions — really just independent worker processes (there's no real geographic distribution). Each worker runs its own scheduler, tags every result with its region name, and writes to the one shared database; the dashboard shows a per-region breakdown for any service that more than one region reports on.
Declare the regions in config.yaml:
regions:
- name: "us-east"
- name: "eu-west"
Then run one process per region, all pointed at the same config and database:
pulsewatch serve --region us-east # web dashboard + the us-east worker
pulsewatch worker --region eu-west # an extra worker, no web server
pulsewatch serve --region NAMEruns the dashboard and a built-in worker forNAME(defaultlocal, which is also what you get with noregions:declared — so single-region setups are unchanged).pulsewatch worker --region NAMEruns a checks-only worker forNAME.- Incidents and alerts are per-region (a down alert reads
… is DOWN [region: us-east]), and each region keeps its own uptime %.
Under Docker, run a container per region sharing the config mount and the
pulsewatch-data volume — see the commented worker-eu-west service in
docker-compose.yml. The shared SQLite database uses WAL
mode with a busy timeout so several worker processes can write to it at once.
Demoing the failure/recovery flow
The included config.yaml ships with a "Flaky Demo Service" pointed at
https://httpbin.org/status/200,500, which randomly returns a failing
status — so you'll see real incidents open and resolve within a few minutes
of running it, without needing to break anything yourself.
For a live demo: run the app, wait for a couple of failed checks on the flaky service, and watch the dashboard status flip to DOWN and an incident appear — then watch it self-resolve once a check succeeds again.
Architecture
pyproject.toml # packaging, dependencies, `pulsewatch` entry point
Dockerfile # slim-Python image running `pulsewatch serve`
docker-compose.yml # app + config mount + persistent DB volume on :8000
config.example.yaml # copy to config.yaml and fill in your values
src/pulsewatch/
├── cli.py # click CLI: init / serve / worker / add
├── config.py # config discovery (CWD → ~/.pulsewatch) + regions helper
├── main.py # FastAPI routes, dashboard rendering, startup wiring
├── monitor.py # per-region scheduler: runs checks, manages incident state
├── checks.py # pluggable checks (http / aws_status / database / custom_api)
├── models.py # Service / RegionStatus / PingLog / Incident tables
├── database.py # SQLite engine (WAL, multi-process) + session + migrations
├── alerts.py # pluggable alert channels (Discord / Slack / email)
└── static/
└── dashboard.html
Tests
pip install -e ".[dev]"
pytest # run the suite
pytest --cov=pulsewatch --cov-report=term-missing # with coverage
The suite mocks all external I/O (httpx, SMTP, DB engines), so it runs offline
and deterministically — no real calls to AWS, databases, or the network. It
covers the incident state machine (threshold + recovery), every check type and
alert channel, the API endpoints (via FastAPI's TestClient), and app
startup/shutdown. GitHub Actions runs it on every push and pull request
(test.yml) on Python 3.11 and uploads coverage to
Codecov.
CI badge note: Actions is currently blocked on this account by an unrelated GitHub billing issue (a support ticket is open to resolve it) — the badge will show red until that's cleared. This has no effect on the tool itself: all 84 tests pass locally, which you can verify yourself with the commands above.
The Codecov badge also needs a one-time (free) sign-in at codecov.io with your GitHub account to activate for this repo.
Possible extensions
- SMS / PagerDuty / webhook alert channels (drop-in
AlertChannelsubclasses) - More dependency kinds (drop-in checkers in
DEPENDENCY_CHECKERS) - Real geographic distribution for regions (currently independent processes, not actually run from different locations)
- Public-facing read-only status page (auth-gated admin view)
- Postgres support for multi-instance deployments
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pulsewatch-1.0.0.tar.gz.
File metadata
- Download URL: pulsewatch-1.0.0.tar.gz
- Upload date:
- Size: 43.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2c8199f13c0ac50b440dbe38fb3c6ab424649ace8271a67c20e01735cfae04c3
|
|
| MD5 |
57edd58d18a8948f2512f830294f4797
|
|
| BLAKE2b-256 |
c68ce312cfba728ea8069312f0a52245095b03f92a9f4f97412714a439caa68a
|
File details
Details for the file pulsewatch-1.0.0-py3-none-any.whl.
File metadata
- Download URL: pulsewatch-1.0.0-py3-none-any.whl
- Upload date:
- Size: 30.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
88d4f3efd75a04dadda42b06c817d108a922337ad9866257cd188d2846e64296
|
|
| MD5 |
9618bd48237295b8e098b56b963783d9
|
|
| BLAKE2b-256 |
2dd267df72ee864f8b7bfde5eb74dcc9f0d469f1618150e9db9c9b7d059cf86b
|