DEV Community

Cover image for Free DevOps Notifications with Discord and GitHub Actions
Aaron Ross
Aaron Ross

Posted on

Free DevOps Notifications with Discord and GitHub Actions

I run a few open source projects, and I don't have a team. It's just me, which means there's nobody else to notice when the 6am cron job fails. I'm certainly not going to sit there refreshing the GitHub Actions page to find out.

Slack would work for this, but paying for Slack so that one person can send notifications to themselves feels a little sad when you think about it. Discord servers are free, GitHub Actions are free for public repos, and Discord webhooks are just HTTP endpoints that accept JSON. You probably see where this is going.

The Pitch

I now get push notifications on my phone when my scheduled jobs fail, and I didn't pay anyone for the privilege. The whole setup took an evening, and most of that was figuring out how I wanted to format the messages.

Getting a Webhook URL

Right-click a channel in Discord, go to Edit Channel, then Integrations, then Webhooks. Create one, copy the URL, and add it as a secret in your GitHub repo (I called mine DISCORD_WEBHOOK_URL).

That's genuinely it. No OAuth dance, no bot tokens, no registering an application with Discord's developer portal. Just a URL that accepts POST requests with JSON bodies. If you've ever set up a Slack app you're probably crying right now, and honestly, it's okay. Let it out.

The Script

After a few iterations of inline bash in my workflow files (which worked but was getting repetitive), I pulled everything into a reusable script. Here's what it looks like:

#!/usr/bin/env bash
set -euo pipefail

TITLE=""
COLOR=""
DESCRIPTION=""
BUTTONS=()

while [[ $# -gt 0 ]]; do
  case $1 in
    --title) TITLE="$2"; shift 2 ;;
    --color) COLOR="$2"; shift 2 ;;
    --description) DESCRIPTION="$2"; shift 2 ;;
    --button) BUTTONS+=("$2"); shift 2 ;;
    *) echo "Unknown arg: $1" >&2; exit 1 ;;
  esac
done

if [ -z "${DISCORD_WEBHOOK:-}" ]; then
  echo "DISCORD_WEBHOOK not set, skipping notification"
  exit 0
fi

TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
WEBHOOK_URL="$DISCORD_WEBHOOK"

# Convert buttons to Discord component format
BUTTONS_JSON="[]"
if [ ${#BUTTONS[@]} -gt 0 ]; then
  BUTTONS_JSON=$(printf '%s\n' "${BUTTONS[@]}" | \
    jq -R 'split("|") | {type: 2, style: 5, label: .[0], url: (.[1:] | join("|"))}' | \
    jq -s '.')
  WEBHOOK_URL="${WEBHOOK_URL}?with_components=true"
fi

PAYLOAD=$(jq -n \
  --arg title "$TITLE" \
  --argjson color "$COLOR" \
  --arg description "$DESCRIPTION" \
  --arg timestamp "$TIMESTAMP" \
  --argjson buttons "$BUTTONS_JSON" \
  '{
    username: "DD Notification Bot",
    avatar_url: "https://democracy-direct.com/logo-square.png",
    embeds: [{
      title: $title,
      color: $color,
      description: $description,
      footer: { text: "Democracy Direct CI" },
      timestamp: $timestamp
    }]
  } + if ($buttons | length) > 0 then {
    components: [{ type: 1, components: $buttons }]
  } else {} end')

curl -fsS -H "Content-Type: application/json" -d "$PAYLOAD" "$WEBHOOK_URL"
Enter fullscreen mode Exit fullscreen mode

Save that to .github/scripts/discord-notify.sh, make it executable, and now you can call it from any workflow with a simple interface:

.github/scripts/discord-notify.sh \
  --title "🚨 Something Broke" \
  --color 15158332 \
  --description "The thing that was supposed to work did not work." \
  --button "View Run|https://github.com/..." \
  --button "Production|https://your-site.com"
Enter fullscreen mode Exit fullscreen mode

The --button format is just "Label|URL". You can have multiple buttons and they'll show up as actual clickable Discord buttons, not just markdown links. The script also gracefully skips sending if the webhook secret isn't set, which is nice for forks or local testing.

What I Actually Use It For

Democracy Direct has scheduled jobs that sync data from the Congress API every morning (legislators, votes, bills, that sort of thing). For these, "it passed" isn't enough information. I want to know whether anything actually changed, how much changed, and if something failed, which specific part failed.

The notifications look like this when things go well:

Discord notification showing data legislation backfill results

And like this when they don't:

Discord notification showing failed smoke tests

The workflow builds up a description string based on what each sync step reported, picks a color based on the overall status, and calls the script:

- name: Send Discord notification
  if: always()
  env:
    DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }}
    LEGISLATORS_RESULT: ${{ steps.legislators.outputs.result }}
    LEGISLATORS_FAILED: ${{ steps.legislators.outputs.failed }}
    # ... etc for other steps
  run: |
    # Parse the JSON output from each step
    LEG_CHANGED=$(echo "$LEGISLATORS_RESULT" | jq -r '.changed // false')
    LEG_COUNT=$(echo "$LEGISLATORS_RESULT" | jq -r '.recordsUpserted // 0')

    # Determine overall status and pick a color
    if [ "$LEGISLATORS_FAILED" = "true" ] || [ "$VOTES_FAILED" = "true" ]; then
      TITLE="🚨 Data Refresh Failed"
      COLOR=15158332  # red
    elif [ "$LEG_CHANGED" = "true" ] || [ "$VOTES_CHANGED" = "true" ]; then
      TITLE="📊 Data Refresh Complete"
      COLOR=3066993  # green
    else
      TITLE="✅ Data Refresh - No Changes"
      COLOR=9807270  # gray
    fi

    # Build the description
    if [ "$LEGISLATORS_FAILED" = "true" ]; then
      LEG_STATUS="❌ **Legislators**: Failed"
    elif [ "$LEG_CHANGED" = "true" ]; then
      LEG_STATUS="✅ **Legislators**: ${LEG_COUNT} upserted"
    else
      LEG_STATUS="⏭️ **Legislators**: Unchanged"
    fi

    # ... similar for votes, bills, etc

    DESC="${LEG_STATUS}"$'\n'"${VOTES_STATUS}"$'\n'"${BILLS_STATUS}"
    GH_RUN="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"

    .github/scripts/discord-notify.sh \
      --title "$TITLE" \
      --color "$COLOR" \
      --description "$DESC" \
      --button "View Run|$GH_RUN"
Enter fullscreen mode Exit fullscreen mode

The key is having your sync scripts output structured JSON ({"changed": true, "recordsUpserted": 42}). Once you have that, you can format it however you want.

The "Oh No Production Is Broken" Notification

I also run smoke tests after Cloudflare finishes deploying to production. These only send a notification on failure, because I don't need a pat on the head every time something works correctly. I'm not a golden retriever.

- name: Send Discord notification on failure
  if: failure()
  env:
    DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }}
  run: |
    GH_RUN="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"

    .github/scripts/discord-notify.sh \
      --title "🚨 Smoke Test Failed" \
      --color 15158332 \
      --description "One or more smoke tests failed against production." \
      --button "View Run|$GH_RUN" \
      --button "Production|https://democracy-direct.com" \
      --button "Cloudflare|https://dash.cloudflare.com"
Enter fullscreen mode Exit fullscreen mode

Three buttons: one to the GitHub Action run, one to production so I can see if it's actually broken, and one to Cloudflare in case I need to roll back. Everything I need to start debugging, right there in the notification.

"But There Are GitHub Actions For This Already"

There are, and they're fine for simple cases. I didn't use them because:

First, there's no official Discord action. These are all third-party, which means you're trusting some random developer's JavaScript (or Docker container) to run in your CI pipeline with access to your secrets. The most popular one even recommends pinning to a specific commit SHA for "stability purposes," which is really security advice dressed up in polite language. I'd rather just use curl.

Second, it's literally just curl and jq, both of which are already installed on GitHub runners. No dependencies to install, no Docker images to pull, no supply chain to worry about.

Third, I can actually read it. The whole notification logic is right there in my repo where I can see it. Want to change something? Just change it. Want to copy it to another project? Copy and paste.

Why Discord Instead of Slack

If your company is already paying for Slack and you have SSO requirements and compliance needs and all 2,600 of those integrations, then by all means, use Slack.

But if you're a solo dev working on open source projects, or a small team that doesn't want to pay per-seat pricing for a chat app:

  • Discord is actually free, not "free but we delete your message history after 90 days" free
  • No per-seat pricing, which matters less when you're one person but matters a lot if your project grows and you want to bring on contributors
  • Mobile notifications work great, which is really all I wanted in the first place

The tradeoff is that Discord doesn't have deep integrations with Jira or Salesforce or whatever enterprise tools you might use at a day job. For a project that lives entirely in GitHub and deploys to Cloudflare, I genuinely do not care about that.

Go Look at the Actual Files

The complete workflow files and the notification script are in the Democracy Direct repo if you want to see how everything fits together:

They're not pristine examples of software engineering. The jq pipelines are kind of dense if you're not familiar with the syntax. But they work, and you're welcome to steal whatever parts are useful to you.


Democracy Direct is an open source civic engagement platform. Check it out on GitHub.

Top comments (0)