Deployment
Production deployment guide for Server Monitoring. Choose Docker Compose for quick deploys or bare metal with systemd for full control.
[toc]
Prerequisites
- Python 3.10+
uv- Nginx (reverse proxy, optional but recommended)
The pipeline runs broker-free — no message broker to install or run. Durable ingest records each alert and manage.py process_inbox drains it (see below).
Environment Variables
Create /etc/server-monitoring/env (systemd) or .env (Docker) with these values:
| Variable | Default | Required | Purpose |
|---|---|---|---|
DJANGO_SECRET_KEY | — | Yes | Cryptographic signing key |
DJANGO_DEBUG | 1 | Yes (set 0) | Disable debug mode in production |
DJANGO_ALLOWED_HOSTS | — | Yes | Comma-separated hostnames (e.g. monitoring.example.com) |
INBOX_DEPTH_WARN | 500 | No | doctor warns once the PENDING drain backlog exceeds this |
API_KEY_AUTH_ENABLED | 1 | No | API key auth (enabled by default; set 0 to disable for dev) |
RATE_LIMIT_ENABLED | 0 | No | Enable rate limiting middleware |
HUB_API_KEY | — | Agent only | Bearer token an agent uses to authenticate push_to_hub to the hub |
Minimal production .env:
DJANGO_SECRET_KEY=your-random-secret-key-here
DJANGO_DEBUG=0
DJANGO_ALLOWED_HOSTS=monitoring.example.com
Generate a secret key:
python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"
Option 1: Docker Compose
The fastest way to get a production stack running. Includes Django (gunicorn) and the broker-free inbox drain.
Quick start: Run
./bin/install.shand select docker mode to automate the steps below (.envsetup, build, start, and health verification).
1.1 Clone and configure
git clone git@github.com:ikidnapmyself/server-monitoring.git
cd server-monitoring
cp .env.sample .env
Edit .env with the production values from the table above.
1.2 Start the stack
docker compose -f deploy/docker/docker-compose.yml up -d
This starts two services:
| Service | What it does |
|---|---|
web | Django app served by gunicorn on port 8000 |
inbox | Drain that processes recorded pipeline runs (process_inbox --loop) |
1.3 Verify
# Check all services are running
docker compose -f deploy/docker/docker-compose.yml ps
# Check logs
docker compose -f deploy/docker/docker-compose.yml logs web
docker compose -f deploy/docker/docker-compose.yml logs inbox
# Test health endpoint
curl http://localhost:8000/alerts/webhook/
1.4 Run migrations manually (if needed)
Migrations run automatically on container start. To run them manually:
docker compose -f deploy/docker/docker-compose.yml exec web python manage.py migrate
1.5 Create an API key
docker compose -f deploy/docker/docker-compose.yml exec web python manage.py shell -c "
from config.models import APIKey
key = APIKey.objects.create(name='my-service')
print(f'API Key: {key._raw_key}')
print('Save this key — it cannot be retrieved again.')
"
Option 2: Bare Metal / VPS with systemd
For full control on a Linux server.
2.1 Clone and install
sudo mkdir -p /opt/server-monitoring
sudo chown www-data:www-data /opt/server-monitoring
sudo -u www-data git clone git@github.com:ikidnapmyself/server-monitoring.git /opt/server-monitoring
cd /opt/server-monitoring
# Install uv and dependencies as www-data
sudo -u www-data sh -c 'curl -LsSf https://astral.sh/uv/install.sh | sh'
sudo -u www-data uv sync --frozen --no-dev --extra prod
2.2 Configure environment
sudo mkdir -p /etc/server-monitoring
sudo tee /etc/server-monitoring/env << 'EOF'
DJANGO_SECRET_KEY=your-random-secret-key-here
DJANGO_DEBUG=0
DJANGO_ALLOWED_HOSTS=monitoring.example.com
EOF
sudo chown root:www-data /etc/server-monitoring/env
sudo chmod 640 /etc/server-monitoring/env
2.3 Run migrations and collect static files
cd /opt/server-monitoring
set -a; source /etc/server-monitoring/env; set +a
uv run python manage.py migrate --noinput
uv run python manage.py collectstatic --noinput
2.4 Install systemd units
sudo cp deploy/systemd/server-monitoring.service /etc/systemd/system/
sudo cp deploy/systemd/server-monitoring-inbox.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now server-monitoring server-monitoring-inbox
server-monitoring-inboxis required, not optional. The webhook writes the alerts inline and records onePENDINGrun per materially changed incident (durable ingest); this drain is what runs those incidents through their lane. See Durable ingest & the inbox drain below.
Automated: Run
sudo ./bin/install.sh deployto automate steps 2.3-2.5 (migrations, static files, unit installation, and service startup with health verification). Or usesudo ./bin/install.shin prod mode when selecting the systemd deployment option.Security note: Running the installer with
sudoexecutes all shell code as root. Review the deploy module (bin/install/deploy.sh) before running and ensure the repository has not been tampered with. Prefer running onlyinstall.sh deploywithsudorather than the full installer to minimize the root-privileged surface.
2.5 Verify
sudo systemctl status server-monitoring
sudo systemctl status server-monitoring-inbox
# Test via unix socket
curl --unix-socket /run/server-monitoring/gunicorn.sock http://localhost/alerts/webhook/
Durable ingest & the inbox drain
The alert webhook does not process pipelines inline. It writes the payload’s alerts (a bounded, size-capped write), lets incidents form, then durably records one PENDING pipeline run per materially changed incident and returns 202 {status: accepted, trace_id, incidents} immediately. A drain then processes the queue at a controlled rate. The slow stages — checkers, AI analysis, delivery — stay queued, so the web workers remain responsive and a flood grows a bounded database queue instead of OOM-ing the node — and it needs no message broker.
⚠️ A drain must be running. With neither the systemd service nor a cron entry below, alerts are recorded but never processed — they pile up as
PENDINGruns. Check the backlog any time withmanage.py doctor(Inbox: N pending);doctoralso emits a warning once the backlog passesINBOX_DEPTH_WARN(default 500).
Option A — supervised loop (recommended, near-real-time). The server-monitoring-inbox unit installed above runs:
manage.py process_inbox --loop --interval 5 --limit 100
It polls every few seconds, restarts on crash, and needs no broker.
Option B — cron one-shot (no systemd). Drain on a schedule instead:
*/1 * * * * cd /opt/server-monitoring && .venv/bin/python manage.py process_inbox --limit 100
Manual “process now”. Force a specific recorded run through immediately:
uv run python manage.py process_inbox --id <run_id>
Crash recovery. A run claimed by a drain that dies mid-flight is reclaimed after --stale-minutes (default 15) and retried.
Trade-off. Processing is now eventually-consistent: under load there is a short, visible queue delay (bounded by the drain interval) rather than synchronous handling.
No-drain deployments (planned). A future opt-in synchronous mode will let a gunicorn-only host process alerts inline without a drain (trading back the flood protection). Until then, run a drain.
Nginx Reverse Proxy
A sample config is provided at deploy/docker/nginx.conf. Two values must be adjusted per deployment:
| Setting | Docker | systemd |
|---|---|---|
upstream | server web:8000; | server unix:/run/server-monitoring/gunicorn.sock; |
location /static/ alias | /app/staticfiles/ (shared volume) | /opt/server-monitoring/staticfiles/ |
Static file names
With DJANGO_DEBUG off, collectstatic writes every asset under a content-hashed name and templates link to that name. The sample configs serve /static/ as immutable for 30 days, so the hash is what lets a deploy reach browsers straight away.
collectstatic must run before the app serves a page. Without its manifest, any page that links a static file returns a 500. The installer, bin/update.sh and the Docker entrypoint already run it.
Docker setup
Nginx runs on the host (or as another container) and proxies to the web service. If Nginx runs as a separate container, it needs access to the same staticfiles volume or network.
systemd setup
Change both the upstream and the static files path:
upstream django {
server unix:/run/server-monitoring/gunicorn.sock;
}
location /static/ {
alias /opt/server-monitoring/staticfiles/;
}
Install on the host
sudo apt install nginx
sudo cp deploy/docker/nginx.conf /etc/nginx/sites-available/server-monitoring
sudo ln -s /etc/nginx/sites-available/server-monitoring /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
SSL with Let’s Encrypt
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d monitoring.example.com
Certbot will modify the Nginx config to add SSL. The commented SSL block in deploy/docker/nginx.conf shows the manual configuration if you prefer.
Webhook Ingestion
External monitoring tools (Grafana, AlertManager, PagerDuty, etc.) send alerts via webhook:
POST /alerts/webhook/ # Auto-detect driver from payload
POST /alerts/webhook/<driver>/ # Driver-specific endpoint
Durable ingest response
The webhook writes the alerts, queues the incident work and returns immediately — it never runs the pipeline inline:
| Behavior | Response |
|---|---|
Alerts written; one PENDING run queued per changed incident | 202 Accepted with {status: accepted, trace_id, incidents} |
| Payload carried no alerts (misconfigured sender) | 202 Accepted with incidents: [], logged as a warning |
| No driver matched the payload — nothing was understood | 400 Bad Request (do not retry) |
| A driver matched but ingest failed and wrote nothing | 500 Internal Server Error (retry) |
| Body larger than 1 MiB | 413 Payload Too Large |
The inbox drain then processes the queued runs. No broker is involved, and no incident is lost if processing lags — it stays queued.
Webhook authentication
All non-GET webhook requests are authenticated by the API-key middleware (API_KEY_AUTH_ENABLED=1). Callers send Authorization: Bearer <token> (or X-API-Key), resolved against the APIKey model. Mint tokens with manage.py create_api_key --name "<label>".
Requests with a missing or invalid key receive 401 Unauthorized. There is no per-driver HMAC scheme — one credential type gates every entrypoint.
Monitoring the Deployment
System preflight
uv run python manage.py preflight # All system checks, grouped
uv run python manage.py preflight --json # JSON output for CI
The --json output lists routing lanes under definitions[], each with name, active, priority, channel (the channel’s name, or null when the lane targets none) and channel_routes (whether that channel is active enough to actually deliver). A lane wired to a deactivated channel reports its name with channel_routes: false — reporting the name alone would claim a route that does not exist.
Health checks
uv run python manage.py check_health # CPU, memory, disk, network, process
uv run python manage.py check_health --list
uv run python manage.py check_health --no-alert # print only, record nothing
uv run python manage.py check_health --no-notify # run the lane, page nobody
check_health is the local entrypoint. It records alerts and incidents for this machine by default (and registers it in the Node registry), enqueues one run per materially changed incident, and drains those runs before returning. It needs no daemon: a single machine with no hub, no cron and nobody draining the inbox still gets its alerts, its analysis and its notifications. Use --no-alert when you only want to look and write nothing — no alert, so no incident, so no run. Use --no-notify to run the matched lane and read the analysis without paging anyone; the flag travels in the enqueued run’s payload, because NOTIFY runs there.
Pipeline history
uv run python manage.py monitor_pipeline --limit 10
Inbox drain health
uv run python manage.py doctor # Shows "Inbox: N pending, M processing"
systemctl status server-monitoring-inbox # Is the drain running?
For Docker:
docker compose -f deploy/docker/docker-compose.yml logs inbox
Upgrading an existing install to row-based routing
Routing now lives entirely in PipelineDefinition rows: the three run_* booleans became one ordered stages list, the channels M2M became a single channel FK, and the orchestrator’s implicit “run everything” fallback is gone. Migrations 0010–0014 carry an existing install across.
Migrate first, then restart — the order is not symmetric
uv run python manage.py migrate # 1. rows first
sudo systemctl restart server-monitoring server-monitoring-inbox # 2. code second
Migrating before the restart is safe. The old code’s fallback is simply never reached, because the seeded catch-all lane reproduces exactly the default order that fallback used to hard-code.
Deploying the code first is not. Until the rows exist, anything no lane claims fails non-retryably as no_route. Those runs are not in the inbox, nothing auto-retries them, and each one needs a manual resume once routing is configured.
Back up before migrating
Both destructive steps are reversible, but one of them cannot restore what it discards:
0010backfillsstagesfrom the three booleans, then drops the columns.0011keeps one channel per lane — the same one delivery already picked, the alphabetically first active one — and discards the rest when the join table is dropped. No behaviour changes, since the discarded channels were never consulted, but the rows are gone:backwards()restores only the survivor.forwards()logs a warning naming every lane and every channel it drops, so check themigrateoutput and re-create anything you still want.
Re-check severity and instance conditions
Routing facts now come from one alert rather than a merge across the incident, so two fields are computed differently: severity is the subject alert’s own (no longer the incident’s maximum across alerts), and instance falls through instance_id → instance → hostname (no longer instance_id alone).
This is a superset of the value, but not of the match outcome. For is-not and not-in, a fact moving from "" to populated flips a previously-matching lane to not-matching. Audit any lane that conditions on either field:
uv run python manage.py shell -c "
from apps.orchestration.models import PipelineDefinition
for d in PipelineDefinition.objects.all():
for c in (d.match or []):
if c.get('field') in ('severity', 'instance'):
print(d.name, c)"
After the restart
uv run python manage.py preflight --json # definitions[]: stages, channel, channel_routes
Confirm every lane you rely on still has the stages you expect and a channel_routes: true where it is meant to deliver.
Multi-Instance (Cluster)
Deploy multiple instances across servers: agents monitor locally and push alerts to a hub that runs the full pipeline (intelligence + notifications).
Architecture
Agent (server-1) ──POST──┐
Agent (server-2) ──POST──┤──▶ Hub ──▶ intelligence ──▶ notify
Agent (server-3) ──POST──┘ (receives cluster alerts)
All instances run the same codebase. Role is determined by environment variables.
Guided setup (recommended)
One command wires a node and proves it works — use this instead of editing .env by hand:
# On the hub: enable auth, mint an agent key (shown once), wire a notification
# channel (so the hub actually notifies), and confirm it's accepting.
uv run python manage.py setup_cluster --role hub --name "web-03" \
--notify-driver slack --notify-webhook https://hooks.slack.com/services/XXX
# Omit the --notify-* flags to be prompted; --no-notify to skip (the hub then
# receives pushes but sends nothing until you add a channel in admin).
# On the agent: write HUB_URL/INSTANCE_ID/HUB_API_KEY and verify with a live push.
uv run python manage.py setup_cluster --role agent \
--hub-url https://monitoring-hub.example.com --instance-id web-03 --hub-api-key <token>
Run with no flags for an interactive prompt, or from the CLI: bin/cli.sh cluster → “Set up this node as a hub/agent (guided)”. The agent step names the failure if the push is rejected — 401 (bad key), 403 (WAF blocks the agent User-Agent, or the key’s scope excludes /alerts/webhook/cluster/), or connection errors — each with the fix. Add --no-verify to skip the live push.
The sections below describe the equivalent manual .env setup.
How the hub routes an incident to a channel
Guided hub setup does not leave the notification channel bare — it binds it to the catch-all PipelineDefinition that actually wins routing (on a fresh install that is the seeded catch-all lane; if no catch-all exists at all it creates default-catch-all). Notification routing is pipeline-driven:
- Each active
PipelineDefinitionhas amatch(a list of{field, op, value}conditions;fieldissource,severity,instance,origin, orlabel:<key>;opisis/is-not/in/not-in) and apriority.originis where the run started —incoming_webhook,checker_generated, ormanual. - Facts come from the run’s subject alert — one alert, not a merge across the incident.
severityis that alert’s own severity, andinstancefalls throughinstance_id→instance→hostname. - Pipelines are evaluated by ascending
priority(ties broken byid) and the first match wins. An emptymatchmatches everything, so the catch-all is the backstop. The matched pipeline is stamped on theIncident, and the notify stage sends to that pipeline’schannel. - The pipeline’s
stagescolumn is one ordered list naming the downstream stages to run — a subset of["check", "analyze", "notify"], always in that order. The entry stage is not listed: the lane is resolved from the alert the entry stage produced, so it has already run. For example,["analyze", "notify"]produces an AI-analysed notify without re-running checks;["check", "analyze"]records and analyses without notifying;[]records the alert and stops. - To route specific traffic elsewhere (e.g. send
severity: criticalfrom a given node to a dedicated channel, or silence a noisy source), add a higher-priority pipeline in Orchestration → Pipeline definitions with the narrowermatch, its own channel, and thestagesyou want. Lowerprioritynumbers win, so an exception rule sits above the general one. Convention: below 100 for system lanes that must pre-empt operator rules, 100 (the default) for your own lanes, 1000 for a catch-all that should only fire when nothing else claimed the alert.
Lanes seeded out of the box:
| Lane | Priority | Match | Stages |
|---|---|---|---|
cluster-nodes | 50 | source is cluster | analyze, notify |
catch-all | 1000 | (empty) | check, analyze, notify |
None of these are special-cased in code — they are ordinary rows, editable and deletable like any lane you create.
Notes:
- There is no implicit fallback. An alert that no active lane matches fails non-retryably as
no_route: nothing is checked, analysed or notified, and no retry can conjure a lane. The seededcatch-allrow is what keeps a fresh install routing everything; deleting or deactivating it is a supported choice, and it means unmatched traffic fails loudly instead of silently taking a route nobody configured.preflightwarns when no active pipeline definitions exist. - A lane with an empty
matchis a catch-all, and lanes are evaluated by(priority, id). Several catch-alls at the same priority means the lowestidwins and the rest never route — check thestagesandchannelcolumns on the Pipeline definitions changelist if a lane you expect never fires. - A lane targets exactly one channel. Delivery has never fanned out; the
channelFK simply makes the field match the behaviour. An inactive channel routes nowhere — the lane delivers nothing through it and notify falls back to payload-driven selection — so the changelist marks such a channel(inactive). - A pipeline run is an incident, and producing an alert is not a stage of it.
check_health(and the deprecatedrun_pipeline --checks-only, which does the same work behind a stderr notice) writes the alerts, lets incidents form, and enqueues one run per materially changed incident; the lane is resolved in that run, from its incident’s subject alert. Adding--no-incidentsmakes it a silent diagnostic without any flag being consulted downstream: no incident forms, so nothing is material, so no run is enqueued and nothing is routed, analysed or notified. Alerts are still recorded. Adding--no-notifyinstead keeps the lane and its ANALYZE — SSH in, look at the machine in real time, read the local provider’s suggestions — and pages nobody; the flag travels in the enqueued run’s payload, because NOTIFY runs there. - The hub has no lane of its own — it is a node. Checker-origin alerts carry
source: cluster, so the hub’s own checks matchcluster-nodesand are analysed and notified exactly like any agent’s: a hub can page about its own full disk. The old record-onlyhub-self-checklane (origin is checker_generated, emptystages) existed only to stop a five-minute cron re-reporting a still-firing alert ~288 times a day; the incident change gate closed that — a repeat that says nothing new enqueues no downstream run at all. Migration0018deactivates that row rather than deleting it (Incident.pipelineisSET_NULL, so a delete would blank which lane handled every incident it ever routed), and it is no longer seeded on a fresh install. To keep hub checks record-only, add a higher-priority lane matchingorigin is checker_generatedwith emptystages. - Alerts carry the
trace_idof the producer that wrote them (the journey chain), and the runs that producer enqueues inherit it, so one push still reads as one story inmanage.py trace. The webhook mints atrace_idbefore callingAlertOrchestrator.process_webhookand passes the same one toenqueue_for;/orchestration/pipeline/honours a caller-suppliedtrace_idand mints one otherwise. Cluster-ingested alerts also link to their originatingNode(resolved from theinstance_idlabel).trace_idandnodeare both searchable/visible in theAlertadmin.
Agent setup
On each server you want to monitor:
- Install the project (
./bin/install.sh— select “agent” when prompted for cluster role) - Add to
.env:
HUB_URL=https://monitoring-hub.example.com
INSTANCE_ID=web-server-01
HUB_API_KEY=<token created on the hub via create_api_key>
- Schedule the push command via cron:
# Every 5 minutes (push.log gets a concise per-push summary, not the full payload)
*/5 * * * * cd /opt/server-monitoring && uv run python manage.py push_to_hub >> push.log 2>&1
Or run manually:
uv run python manage.py push_to_hub # Push all checker results
uv run python manage.py push_to_hub --dry-run # Preview without sending
uv run python manage.py push_to_hub --checkers cpu,memory # Specific checkers
uv run python manage.py push_to_hub --local # Hub self-monitoring: record on the local inbox
Tip: The installer and
bin/install.sh croncan configure all of the above interactively. Manual.envediting is only needed if you skipped the prompts.
Hub setup
On the central monitoring server:
- Install the project (
./bin/install.sh— select “hub” when prompted for cluster role) - Add to
.env:
API_KEY_AUTH_ENABLED=1
Then mint one API key per agent and paste each token into that agent’s HUB_API_KEY — an active key is what makes this node a receiver (hub):
uv run python manage.py create_api_key --name "web-server-01"
# The raw token is shown once — copy it immediately.
The hub accepts cluster payloads at POST /alerts/webhook/cluster/ (authenticated by the API-key middleware). It writes their alerts inline and enqueues one run per materially changed incident; the lane runs on the process_inbox drain. Each alert carries instance_id and hostname labels for per-server filtering.
Standalone (default)
Existing installs with no HUB_URL and no active API key continue to work as standalone instances with no changes.
Verification
After setting up an agent or hub, verify the configuration:
Agent verification:
# Dry-run: builds payload, shows what would be sent (no network call)
uv run python manage.py push_to_hub --dry-run
# Single push: sends one payload to the hub and reports the result
uv run python manage.py push_to_hub
# Push specific checkers only
uv run python manage.py push_to_hub --checkers cpu,memory --dry-run
Hub verification:
# Confirm the cluster driver is registered
uv run python manage.py shell -c "from apps.alerts.drivers import DRIVER_REGISTRY; print('cluster' in DRIVER_REGISTRY)"
# Expected output: True
# Check Django system checks pass
uv run python manage.py check
Node registry & doctor
The registry holds every machine that reports on itself, this one included: each accepted cluster push upserts a Node (by instance_id, tracking hostname and last-seen) with last_source: cluster, and every local check run that records alerts upserts this machine with last_source: local. So a hub appears in its own registry alongside its agents. Browse them read-only in Django admin under Alerts → Nodes.
manage.py doctor is the single read-only diagnostic — it runs the preflight checks and reports the node’s derived role, whether it is accepting pushes (derived from active API keys + API_KEY_AUTH_ENABLED, the real ingest gate), and how many nodes it knows (its own row included, once it has run a local check):
uv run python manage.py doctor # human-readable
uv run python manage.py doctor --json # machine-readable
If doctor shows Accepting pushes: False, the hub has no active API key (or auth is off) — mint one with create_api_key. Known nodes counts every row, including this machine’s own, so if an agent pushed and the count did not go up, the push isn’t being accepted (check auth / the key scope).
Security
- Always use HTTPS for
HUB_URLin production. Payloads contain server metrics and alert details. HUB_API_KEYis a Bearer token minted on the hub (create_api_key) and set on each agent. The agent sends it asAuthorization: Bearer <HUB_API_KEY>; the hub verifies it with the API-key middleware. KeepAPI_KEY_AUTH_ENABLED=1on the hub.- Each agent can have its own key; revoke a compromised agent by deactivating its
APIKeyon the hub without touching the others. - The token is sent only over the (HTTPS) transport header, never inside the payload body.
Cluster auth migration (from the shared HMAC secret)
Earlier builds authenticated agent pushes with a shared WEBHOOK_SECRET_CLUSTER HMAC and a dead CLUSTER_ROLE knob. Both are removed; agents now use HUB_API_KEY. Cutover (coordinated across nodes):
- On the hub, ensure
API_KEY_AUTH_ENABLED=1, then mint one key per agent:uv run python manage.py create_api_key --name "<agent>". - On each agent, set
HUB_API_KEY=<token>and removeWEBHOOK_SECRET_CLUSTERandCLUSTER_ROLEfrom.env. - Verify with
uv run python manage.py push_to_hub --dry-run, then a live push.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
push_to_hub exits with “HUB_URL not configured” | HUB_URL missing from .env | Add HUB_URL=https://your-hub.example.com to .env |
push_to_hub exits with connection refused | Hub not running or wrong URL | Verify hub is accessible: curl -s $HUB_URL/alerts/webhook/cluster/ |
push_to_hub returns 401 Unauthorized | Missing/invalid HUB_API_KEY | Mint a key on the hub (create_api_key) and set it as HUB_API_KEY on the agent |
push_to_hub exits “HUB_API_KEY is not configured” | Agent has no key set | Set HUB_API_KEY in the agent .env |
push_to_hub returns 403 but the same request via curl succeeds | A WAF/proxy in front of the hub blocks the agent’s User-Agent | The agent sends User-Agent: server-monitoring-agent/<ver>; allowlist it (or the agent IP) at the WAF. Confirm with curl -H "User-Agent: Python-urllib/3.11" ... reproducing the 403 |
push_to_hub returns 403 with body API key not authorized for this endpoint | The APIKey.allowed_endpoints allowlist excludes /alerts/webhook/cluster/ | Clear the key’s scope (empty = all) or add /alerts/; keys minted by create_api_key are unscoped by default |
| Alerts arrive on hub but no notifications fire | No routing pipeline / channel | Run uv run python manage.py setup_cluster on the hub (wires a catch-all pipeline + channel) |
push_to_hub --dry-run shows 0 alerts | No checkers returned results | Run uv run python manage.py check_health --no-alert to verify checkers work — --no-alert keeps a diagnostic from recording alerts and paging anyone |