~$ klumbsyd / tech / posts / self-hosting / discord-webhook-alerts.md
post.config
1# post metadata
2title="Discord webhooks for server alerts without Grafana"
3author="dustin"
4date="2026-02-28"
5tags=["discord", "monitoring", "self-hosting", "bash"]
6read_time="4 min"
7word_count="719"
8status="published"

Discord webhooks for server alerts without Grafana

Every homelab guide tells you to set up Grafana, Prometheus, and a dozen exporters just to know when something breaks. That’s overkill for a single server. Discord webhooks give you instant alerts with zero infrastructure overhead — no database, no dashboards, no containers to babysit.

Here’s how I wired them up on my UnRAID server for the DoVi conversion pipeline and beets review queue.

Store the webhook URL in /etc/environment

The first mistake people make is hardcoding the webhook URL in their scripts. If you ever commit that script or share it, the URL leaks and anyone can post to your Discord channel.

The right place for it is /etc/environment:

DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/your/url"

Scripts that run as root or via cron will pick this up automatically. For user scripts, add source /etc/environment at the top. The URL never touches your script files.

The base pattern

Every alert script follows the same skeleton: check a condition, build a payload, POST it. The key is using jq to build the JSON rather than interpolating variables directly into a string. A filename with a quote or backslash will silently break string interpolation and drop your alert entirely.

source /etc/environment

send_alert() {
  local color="$1" title="$2" description="$3"
  jq -n \
    --arg title "$title" \
    --arg description "$description" \
    --argjson color "$color" \
    '{embeds: [{title: $title, description: $description, color: $color}]}' \
  | curl -s -H "Content-Type: application/json" -d @- "$DISCORD_WEBHOOK_URL"
}

Colors are decimal integers: green is 3066993, yellow is 16776960, red is 15158332.

Use embed fields, timestamps, and footers

Plain title/description embeds work, but Discord embeds support a lot more. Fields let you display key/value pairs in a compact grid. Timestamps show up formatted in the reader’s local timezone. Footers let you label which script or host sent the alert.

Here’s the full DoVi pipeline alert function I use:

send_dovi_alert() {
  local status="$1" filename="$2" filesize="$3" profile="$4"
  local color title

  case "$status" in
    started)  color=16776960; title="conversion started"  ;;
    success)  color=3066993;  title="conversion complete" ;;
    failed)   color=15158332; title="conversion failed"   ;;
  esac

  jq -n \
    --arg title    "$title" \
    --arg filename "$filename" \
    --arg filesize "$filesize" \
    --arg profile  "$profile" \
    --argjson color "$color" \
    --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
    '{
      username: "dovi-pipeline",
      embeds: [{
        title: $title,
        color: $color,
        fields: [
          {name: "file",    value: $filename, inline: false},
          {name: "size",    value: $filesize, inline: true},
          {name: "profile", value: $profile,  inline: true}
        ],
        footer: {text: "unraid · dovi-pipeline"},
        timestamp: $ts
      }]
    }' \
  | curl -s -H "Content-Type: application/json" -d @- "$DISCORD_WEBHOOK_URL"
}

Called like:

send_dovi_alert "success" "The.Batman.2022.mkv" "42.1 GB" "Profile 7 FEL"

The username field overrides the webhook’s display name per-message, so DoVi alerts show up as “dovi-pipeline” and beets alerts show up as “beets”. Makes it easy to scan the channel at a glance.

A real example: the beets review digest

The beets script runs at 9AM daily via cron. If albums are sitting in the review folder waiting for manual tagging, it fires a yellow embed listing them. If the folder is empty, it does nothing.

#!/usr/bin/env bash
source /etc/environment

REVIEW_DIR="/mnt/user/music/review"

albums=$(ls -1 "$REVIEW_DIR" 2>/dev/null)
[[ -z "$albums" ]] && exit 0

count=$(echo "$albums" | wc -l | tr -d ' ')
album_list=$(echo "$albums" | sed 's/^/- /')

jq -n \
  --arg description "$album_list" \
  --arg title "review queue: ${count} album(s) waiting" \
  --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  '{
    username: "beets",
    embeds: [{
      title: $title,
      description: $description,
      color: 16776960,
      footer: {text: "unraid · beets digest"},
      timestamp: $ts
    }]
  }' \
| curl -s -H "Content-Type: application/json" -d @- "$DISCORD_WEBHOOK_URL"

No “queue is empty” message, no daily all-clear. It only fires when there’s something to act on.

Rate limit caveat

Discord enforces 5 requests per 2 seconds per webhook URL. For most alert scripts this is a non-issue, but the DoVi pipeline can queue up multiple conversions overnight. If jobs complete back to back, rapid-fire alerts will start getting dropped with 429 responses. I add a sleep 1 between consecutive sends when running a batch to stay well under the limit.

The “silent when healthy” principle

Every script checks whether there’s something to report before sending anything. No heartbeats, no “all clear” messages, no daily summaries that just say everything is fine.

Your Discord channel stays quiet until something needs your attention. When a notification shows up, you know it matters. That’s the only kind of monitoring worth having.