ntfy notifications¶
The application alerts are moving from Discord to a self-hosted ntfy server on the
Freeze AI VM (https://freeze-ai.tail56046.ts.net, public through Tailscale Funnel,
deny-all: every sender needs a token). During the transition every message goes to
both Discord and ntfy; removing Discord is a separate todo. The VM side (server,
users, tokens, emergency line) is described in the Freeze AI repo,
docs/plans/2026-11-02-ntfy-meldingen.md.
How it works¶
DiscordService (backend/apps/core/services/discord_service.py) forwards every message to
NotifyService (backend/apps/core/services/notify_service.py), so all existing call sites
(send_embed) reach ntfy without changes. restore_database posts its own message to the
backups channel as well.
| Channel | ntfy topic | Priority |
|---|---|---|
errors |
freeze-errors |
red 4, yellow 3, green/blue 2 |
payments |
freeze-payments |
idem |
backups |
freeze-backups |
idem |
admin |
freeze-admin |
idem |
The embed's title becomes the ntfy title (with NTFY_TITLE_PREFIX in front), and the
description, fields and footer become a Markdown message. Messages are published as JSON,
so titles with "—", "€" or emoji are fine. NotifyService never raises; a failed post is
logged as a warning.
Safety nets¶
- ntfy unreachable (timeout, 5xx, network): the title only goes to the emergency topic
on ntfy.sh (
NTFY_FALLBACK_URL), at most once per title per 10 minutes. The emergency topic carries no customer data. - Errors on
paymentsandbackups(red embeds) always also go by e-mail toNOTIFY_FALLBACK_EMAIL, whether or not ntfy is up. They are deduplicated per title for 10 minutes and capped at 20 mails per day.
Configuration (env on the VPS)¶
| Variable | Production | Staging |
|---|---|---|
NTFY_URL |
https://freeze-ai.tail56046.ts.net |
same |
NTFY_TOKEN |
token webshop-prod (1Password: "ntfy Freeze AI — tokens") |
token webshop-staging |
NTFY_TITLE_PREFIX |
empty | [staging] |
NTFY_FALLBACK_URL |
the ntfy.sh emergency topic (1Password: "ntfy.sh noodtopic Freeze AI") | same |
NOTIFY_FALLBACK_EMAIL |
e.g. info@freezedesign.nl |
empty or your own address |
NTFY_TOPIC_<CHANNEL> |
optional override of a topic | — |
The webshop ntfy user may only write to freeze-errors, freeze-payments,
freeze-backups, freeze-admin, freeze-approve, freeze-social and
freeze-ai-heartbeat; it cannot read.
Check¶
# on the VPS, in the backend container
python manage.py shell -c "from apps.core.services.notify_service import NotifyService as N; print(N().send_embed('errors', title='Testmelding ntfy', description='Handmatige test'))"
True means ntfy accepted it; the message appears in the ntfy app on freeze-errors.
Health pings to healthchecks.io¶
Two Celery beat tasks (todo healthchecks-hetzner) let healthchecks.io notice when
something goes silent. They do nothing without their URL.
| Task | Every | Env | What a missing ping means |
|---|---|---|---|
monitoring.healthcheck_ping |
5 min | HEALTHCHECK_PING_URL |
worker, beat or database is down (a database error sends /fail right away) |
monitoring.ntfy_public_heartbeat |
10 min | NTFY_HEARTBEAT_PING_URL (+ NTFY_URL, NTFY_TOKEN) |
ntfy is not reachable from outside the tailnet (Funnel); the task publishes a silent message (priority 1) on freeze-ai-heartbeat first |
Use one healthchecks.io check per environment for HEALTHCHECK_PING_URL
(hetzner-staging, hetzner-prod) and one shared check ntfy-public. healthchecks.io
alerts through the ntfy.sh emergency topic and e-mail, so the alert does not depend on
the ntfy server it watches. Nobody subscribes to freeze-ai-heartbeat.
Scripts and GitHub workflows¶
scripts/notify-ntfy.sh <topic> <priority> <title> <message> [click-url] publishes to ntfy
with NTFY_URL and NTFY_TOKEN; without them it does nothing, and it always exits 0 so a
notification never fails a deploy. It needs only bash and curl.
| Source | Topic | Token |
|---|---|---|
scripts/notify-discord.sh (deploys, CI, backup test, SSL, E2E and k6 gate) |
freeze-deploy, failure = priority 4 |
GitHub secret NTFY_TOKEN_CI (ntfy user ci) |
scripts/restart-unhealthy.sh (cron on the VPS) |
freeze-errors |
NTFY_URL/NTFY_TOKEN from the same backend env file as the webhook |
The workflows pass NTFY_URL and NTFY_TOKEN: ${{ secrets.NTFY_TOKEN_CI }} next to
DISCORD_WEBHOOK_URL. Tests: scripts/tests/test_notify_ntfy.sh,
scripts/tests/test_restart_unhealthy.sh.
Not covered here¶
- The dagplanning messages (
dagbouw-bewaker,dagplanning-waakhond,websitecheck-discord) still post to their own Discord channel; they move when Discord is phased out.