ADR-0021 (Accepted): - ntfy as central notification hub - Alertmanager integration for critical/warning alerts - Service readiness notifications via Flux notification-controller - Standardized topic naming ADR-0022 (Proposed): - ntfy-discord-bridge Python service design - SSE subscription with reconnection logic - Message transformation to Discord embeds - Priority/tag to color/emoji mapping - Kubernetes deployment with ExternalSecret for webhook
7.2 KiB
ADR-0022: ntfy-Discord Bridge Service
Status
Proposed
Context
Per ADR-0021, ntfy serves as the central notification hub for the homelab. However, Discord is used for team collaboration and visibility, requiring notifications to be forwarded there as well.
ntfy does not natively support Discord webhook format. Discord expects a specific JSON structure with embeds, while ntfy uses its own message format. A bridge service is needed to:
- Subscribe to ntfy topics
- Transform messages to Discord embed format
- Forward to Discord webhooks
Decision
Architecture
A dedicated Python microservice (ntfy-discord-bridge) will bridge ntfy to Discord:
┌─────────────────┐ ┌──────────────────┐ ┌─────────────┐
│ CI/Alertmanager │────▶│ ntfy │────▶│ ntfy App │
│ Gatus/Flux │ │ (notification │ │ (mobile) │
└─────────────────┘ │ hub) │ └─────────────┘
└────────┬─────────┘
│ SSE/JSON stream
▼
┌──────────────────┐ ┌─────────────┐
│ ntfy-discord- │────▶│ Discord │
│ bridge │ │ Webhook │
└──────────────────┘ └─────────────┘
Service Design
Repository: ntfy-discord-bridge
Technology Stack:
- Python 3.12+
httpxfor async HTTP (SSE subscription + Discord POST)pydanticfor configuration validationstructlogfor structured logging- Poetry/uv for dependency management
Core Features:
- SSE Subscription: Connect to ntfy's JSON stream endpoint for real-time messages
- Automatic Reconnection: Exponential backoff on connection failures
- Message Transformation: Convert ntfy format to Discord embed format
- Priority Mapping: Map ntfy priorities to Discord embed colors
- Topic Routing: Configure which topics go to which Discord channels/webhooks
- Health Endpoint:
/healthfor Kubernetes probes
Configuration
Environment variables or ConfigMap:
NTFY_URL: "http://ntfy-svc.observability.svc.cluster.local"
DISCORD_WEBHOOK_URL: "${DISCORD_WEBHOOK_URL}" # From Vault via ExternalSecret
# Topic routing (optional - defaults to single webhook)
TOPIC_WEBHOOKS: |
gitea-ci: ${DISCORD_CI_WEBHOOK}
alertmanager-alerts: ${DISCORD_ALERTS_WEBHOOK}
deployments: ${DISCORD_DEPLOYMENTS_WEBHOOK}
# Topics to subscribe to (comma-separated)
NTFY_TOPICS: "gitea-ci,alertmanager-alerts,deployments,gatus"
Message Transformation
ntfy message:
{
"id": "abc123",
"topic": "gitea-ci",
"title": "Build succeeded",
"message": "ray-serve-apps published to PyPI",
"priority": 3,
"tags": ["package", "white_check_mark"],
"time": 1770050091
}
Discord embed:
{
"embeds": [{
"title": "✅ Build succeeded",
"description": "ray-serve-apps published to PyPI",
"color": 3066993,
"fields": [
{"name": "Topic", "value": "gitea-ci", "inline": true},
{"name": "Tags", "value": "package", "inline": true}
],
"timestamp": "2026-02-02T11:34:51Z",
"footer": {"text": "ntfy"}
}]
}
Priority → Color Mapping:
| Priority | Name | Discord Color |
|---|---|---|
| 5 | Max/Urgent | 🔴 Red (15158332) |
| 4 | High | 🟠 Orange (15105570) |
| 3 | Default | 🔵 Blue (3066993) |
| 2 | Low | ⚪ Gray (9807270) |
| 1 | Min | ⚪ Light Gray (12370112) |
Tag → Emoji Mapping: Common ntfy tags are converted to Discord-friendly emojis in the title:
white_check_mark/heavy_check_mark→ ✅x/skull→ ❌warning→ ⚠️rotating_light→ 🚨rocket→ 🚀package→ 📦
Kubernetes Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: ntfy-discord-bridge
namespace: observability
spec:
replicas: 1
selector:
matchLabels:
app: ntfy-discord-bridge
template:
spec:
containers:
- name: bridge
image: registry.daviestechlabs.io/ntfy-discord-bridge:latest
env:
- name: NTFY_URL
value: "http://ntfy-svc.observability.svc.cluster.local"
- name: NTFY_TOPICS
value: "gitea-ci,alertmanager-alerts,deployments"
- name: DISCORD_WEBHOOK_URL
valueFrom:
secretKeyRef:
name: discord-webhook-secret
key: webhook-url
ports:
- containerPort: 8080
name: health
livenessProbe:
httpGet:
path: /health
port: health
initialDelaySeconds: 5
periodSeconds: 30
resources:
limits:
cpu: 100m
memory: 128Mi
requests:
cpu: 10m
memory: 64Mi
Secret Management
Discord webhook URL stored in Vault at kv/data/discord:
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: discord-webhook-secret
namespace: observability
spec:
refreshInterval: 1h
secretStoreRef:
name: vault
kind: ClusterSecretStore
target:
name: discord-webhook-secret
data:
- secretKey: webhook-url
remoteRef:
key: kv/data/discord
property: webhook_url
Error Handling
- Connection Loss: Exponential backoff (1s, 2s, 4s, ... max 60s)
- Discord Rate Limits: Respect
Retry-Afterheader, queue messages - Invalid Messages: Log and skip, don't crash
- Webhook Errors: Log error, continue processing other messages
Consequences
Positive
- Robust: Proper reconnection and error handling vs shell script
- Testable: Python service with unit tests
- Observable: Structured logging, health endpoints
- Flexible: Easy to add topic routing, filtering, rate limiting
- Consistent: Follows same patterns as other Python services (handler-base, etc.)
Negative
- Operational Overhead: Another service to maintain
- Build Pipeline: Requires CI/CD for container image
- Latency: Adds ~100ms to notification delivery
Neutral
- Webhook URL must be maintained in Vault
- Service logs should be monitored for errors
Implementation Checklist
- Create
ntfy-discord-bridgerepository - Implement core bridge logic with httpx
- Add reconnection with exponential backoff
- Implement message transformation
- Add health endpoint
- Write unit tests
- Create Dockerfile
- Set up CI/CD pipeline (Gitea Actions)
- Add ExternalSecret for Discord webhook
- Create Kubernetes manifests
- Deploy to observability namespace
- Verify notifications flowing to Discord
Related
- ADR-0021: Notification Architecture
- ADR-0015: CI Notifications and Semantic Versioning