I Built a GitHub Release Watcher for My Homelab — Here's How n8n and Claude AI Keep My Stack Updated
I run about twelve self-hosted services. Immich for photos, Vaultwarden for passwords, Traefik for reverse proxying, Jellyfin for media, n8n for automation, Uptime Kuma for monitoring, Grafana and Prometheus for dashboards, Nextcloud, Watchtower, a couple of WireGuard tunnels, and Heimdall as a landing page. At any given time, half of these have updates I don't know about.
For a while, my "update strategy" was checking GitHub when I remembered to, or finding out when something broke. Neither approach is great. But the thing that finally pushed me to fix this was Vaultwarden. A security patch dropped, and I didn't hear about it for a week. A full week where my password manager had a known vulnerability and I was just... running it. When I found out, I felt genuinely sick. That's not "I should probably keep things updated" territory. That's "this is unacceptable for the one service that holds every credential I own."
The obvious answer is Watchtower. Set it to auto-update and forget about it. But I've been burned. Immich pushed a version that needed a database migration, and Watchtower happily pulled the new image and restarted the container. The app came up in a broken state because the Postgres schema was wrong. Took me an evening to sort it out. So auto-updating everything is out. I needed something between "ignore all updates" and "let a bot slam every new image into production at 3 AM."
What I Built
An n8n workflow that checks GitHub releases and Docker container registries once a day, feeds each changelog through Claude AI to figure out what actually matters, and delivers color-coded update digests to Discord, Telegram, Slack, or ntfy — your choice. Critical security patches get sent immediately. Minor UI tweaks get batched into a daily summary. Forty-three functional nodes, about an afternoon to build, and roughly two cents a day to run.
If you want to build it yourself step by step, there's a companion tutorial that walks through every node.
Why This Design
I was already running n8n for a bunch of other automations, so spinning up another workflow was the path of least resistance. The visual editor makes it easy to add repos or change routing logic without touching code — just open the node, change a value, save. That matters when you're maintaining something at 11 PM and don't want to think too hard.
For the AI analysis, I went with Claude Haiku. Fast, cheap, and surprisingly good at structured extraction. It costs about a quarter of a cent per changelog analysis. I considered just parsing changelogs with regex, but release notes are wildly inconsistent. Some projects use keepachangelog format. Some dump a raw commit log. Some write three paragraphs of prose. Regex can't reliably extract "is this a security fix" from all of those. An LLM can.
RSS was the other option I dismissed early. Not every project publishes a feed, and even when they do, the feed entry doesn't tell you whether the update is critical or cosmetic. It's just "new version exists." I wanted something that could tell me "Vaultwarden patched a CVE, update now" versus "Grafana changed the icon on the settings page, maybe update next month."
For version tracking, I used n8n's built-in static data (essentially a persistent key-value store scoped to the workflow) for the fast path, and a PostgreSQL release_history table for long-term history. The database turned out to be more useful than I expected — more on that later.
Multi-channel delivery was a requirement from the start. Different urgency levels deserve different treatment. Security patches go to Discord and Telegram and push notifications. Optional updates sit in the daily digest that I check with coffee.
Building the Core Pipeline
The basic flow looks like this: Schedule Trigger fires at 8 AM daily, reads a configuration node, builds the repo watchlist, fetches the latest release for each repo, compares against stored versions, and alerts if anything is new.
I started with ten GitHub repos — the services I actually run. The watchlist is a Code node that outputs one item per repo with the owner, repo name, a human-friendly label, a category, and any alert overrides:
const repos = [
{ owner: 'immich-app', repo: 'immich', label: 'Immich',
category: 'media', source: 'github', dependsOn: ['postgres', 'redis'] },
{ owner: 'dani-garcia', repo: 'vaultwarden', label: 'Vaultwarden',
category: 'security', source: 'github', dependsOn: [] },
{ owner: 'traefik', repo: 'traefik', label: 'Traefik',
category: 'networking', source: 'github', dependsOn: [] },
{ owner: 'louislam', repo: 'uptime-kuma', label: 'Uptime Kuma',
category: 'monitoring', source: 'github', dependsOn: [] },
{ owner: 'n8n-io', repo: 'n8n', label: 'n8n',
category: 'automation', source: 'github', dependsOn: ['postgres', 'redis'] },
// ... more repos
];
Each item also carries a _githubToken field pulled from the config node. This is important. Without a GitHub personal access token, you get 60 API requests per hour. With one, 5,000. For ten repos running once a day, 60 is technically enough. But if you're testing, iterating, running manually — you'll hit the limit fast. I burned through my unauthenticated quota in about twenty minutes of development. (The GitHub token setup takes about thirty seconds. Just do it.)
The HTTP Request node that calls the GitHub API has a small but important detail: onError: "continueRegularOutput". Without that flag, a single 404 (say, from a typo in a repo name) kills the entire workflow. With it, the failed request passes through as an error object, and the downstream extraction code handles it gracefully.
The first-run problem was the thing I spent the most time thinking about before writing any code. When you install this workflow and run it for the first time, it has no record of what versions you're currently running. If I naively compared "nothing stored" against "latest release exists," it would alert for every single repo. Twelve notifications telling you about versions you already have installed. Useless and annoying.
The fix: the Compare Versions node checks a flag in static data. If staticData.initialized is falsy, it's a first run. Seed every current version into storage, set initialized = true, and exit without alerting. The output on first run is a single item: { _firstRun: true, reposSeeded: 12 }. Clean. Quiet. No spam.
The AI Analysis Layer
This was the part I was most skeptical about and ended up being the part I like most.
After the Compare Versions node identifies new releases, each one goes through a Prep Changelog node that truncates the release notes. Some repos have massive changelogs — 5,000+ words of commit messages. Claude Haiku can handle that context-wise, but the signal-to-noise ratio drops and the cost goes up for no reason. I truncate to 1,500 characters with one exception: if there's a "BREAKING CHANGES" section anywhere in the notes, that section is always preserved in full, even if the rest gets cut.
let breakingSection = '';
const bMatch = log.match(
/(?:#+\s*)?(?:BREAKING|Breaking Changes?|\u26a0\ufe0f)[\s\S]*?(?=\n#{1,3}\s|\n\n---|\n\n\n|$)/i
);
if (bMatch) {
breakingSection = '\n\n--- BREAKING CHANGES ---\n' + bMatch[0].trim();
}
const maxLen = 1500 - breakingSection.length;
if (log.length > maxLen) {
log = log.substring(0, maxLen).trim() + '\n\n[...truncated]';
}
The AI prompt is specific and structured. I'm not asking it to "summarize this changelog" — that's too vague. I want JSON with exact fields. The system message spells out every field, the urgency levels (critical, recommended, optional), the security detection rules, and the output format. Here's the core of it:
You are a self-hosted software update analyst. Summarize release notes
for a homelabber.
Return ONLY valid JSON:
{
"summary": "1-2 sentence summary",
"breaking": false,
"breakingDetails": null,
"urgency": "recommended",
"security": false,
"securityDetails": null,
"keyChanges": ["change1", "change2", "change3"],
"migrationNotes": ""
}
Security rules:
- If changelog mentions CVE-XXXX-YYYY, set security:true and include
CVE numbers in securityDetails
- If changelog mentions "security fix", "vulnerability", "patch",
"XSS", "SQL injection", "auth bypass", set security:true
- Security issues automatically make urgency "critical"
Be concise. Homelabbers want facts, not marketing language.
Temperature is set to 0.2. I tested at 0.7 first and the JSON structure was less consistent — sometimes it'd add commentary outside the JSON block, sometimes it'd vary the field names. At 0.2 it's nearly deterministic. The Parse AI Response node still has a regex fallback (aiText.match(/\{[\s\S]*\}/)) to extract JSON even if there's surrounding text, but at 0.2 I almost never need it.
The Anthropic API key setup is required for this — the workflow uses Claude Haiku through n8n's built-in Anthropic node. Cost-wise, it's negligible. Ten repos with ~400 tokens per changelog each works out to roughly $0.01–0.03 per run. At once daily, that's under a dollar a month. I spend more on the electricity to run the server.
(Side note: I briefly considered using a local LLM via Ollama for this. But the quality of structured JSON extraction from a 7B model just isn't there yet. Haiku is fast, cheap, and gets the JSON right every time. Sometimes the right tool costs a couple cents.)
Adding Docker Registry Support
Not everything lives on GitHub Releases. Some services I run are consumed purely as Docker images, and their latest versions are tracked on Docker Hub or GHCR, not in GitHub's release system. I needed the watcher to check container registries too.
Docker Hub and GHCR have different APIs. Docker Hub gives you paginated tag lists at hub.docker.com/v2/repositories/{namespace}/{image}/tags, sorted by last updated, with metadata like push timestamps. GHCR returns a simpler tag list at ghcr.io/v2/{namespace}/{image}/tags/list — just an array of tag strings, no dates.
Both registries required the same filtering logic: skip latest, skip anything with -rc, -beta, or -alpha in the name. For Docker Hub I take the first stable tag from the results (they come back sorted by last_updated). For GHCR I sort the tag array in reverse and take the first stable one.
The workflow handles this with a Source Router switch node. GitHub repos go left to the GitHub Releases API. Registry repos go right to the appropriate container registry API. Results from both paths merge back together before hitting Compare Versions. From that point on, it doesn't matter where the version came from — the comparison, AI analysis, and delivery all work the same way.
The registry entries in the watchlist look different from the GitHub entries. They carry extra fields like registry, namespace, image, and a pre-built fetchUrl. But after extraction, they output the same shape: tagName, label, category, source, htmlUrl. That normalization step was important. Without it, I'd have needed separate paths for every downstream node.
Smart Routing — The Feature That Made It Actually Useful
For the first couple of test runs, I had everything going to Discord. Every update, same channel, same format. It was... fine. But also kind of useless in practice.
The problem: a Vaultwarden security patch and a Grafana minor theme update both show up as "new release detected." They look the same. They feel the same. And when everything feels the same urgency, you start ignoring all of it — which is exactly the problem I was trying to solve.
The fix was per-repo alert rules. In the watchlist, each repo can have an alertRules override that specifies which channels to notify, whether to override the AI's urgency rating, and whether to send an instant alert instead of batching into the digest.
For Vaultwarden, the override is aggressive: { channels: ['discord', 'telegram', 'ntfy'], instantAlert: true }. Any new Vaultwarden release, regardless of what the AI thinks the urgency is, goes to all three channels immediately. I'm not waiting for a daily digest to find out my password manager has a patch.
For most other repos, the defaults apply: batch into the daily digest, send to Discord only.
After the AI analysis, an Apply Alert Rules node merges the per-repo rules with the AI's urgency assessment. Then an Urgency Router switch node splits the flow: instant alerts go one direction and get formatted as individual messages, while everything else goes to Format Digest and gets combined into a single daily summary.
This was the feature that turned the workflow from a toy into something I actually rely on. The daily digest is a calm morning read with my coffee. The instant alerts are the fire alarm. Different tools for different situations.
The digest output looks like this:
📦 Stack Update Digest — Feb 22, 2026
12 sources checked, 3 updates found.
🔴 CRITICAL: Vaultwarden 1.32.5 (was 1.32.4)
Patches CVE-2026-XXXX authentication bypass vulnerability.
🛡️ SECURITY: CVE-2026-XXXX — authentication bypass in admin panel
Key changes: Fixed auth bypass; Updated Rust dependencies; Added rate limiting
💻 `docker pull vaultwarden/server:1.32.5 && docker compose up -d`
🔗 Heads up: nothing depends on vaultwarden
→ https://github.com/dani-garcia/vaultwarden/releases/tag/1.32.5
🟠 RECOMMENDED: n8n 2.9.0 (was 2.8.4)
Adds native MCP server support and improves execution reliability.
Key changes: MCP Server node; Fixed queue mode memory leak; New Filter node
💻 `docker pull n8nio/n8n:2.9.0 && docker compose up -d`
🔗 Heads up: nxsi-workflows depend on n8n
→ https://github.com/n8n-io/n8n/releases/tag/[email protected]
🔵 OPTIONAL: Grafana 11.5.1 (was 11.5.0)
Minor UI improvements to dashboard panel editor.
Key changes: Panel editor grid snap; Fixed tooltip rendering; Updated Go deps
💻 `docker pull grafana/grafana:11.5.1 && docker compose up -d`
→ https://github.com/grafana/grafana/releases/tag/v11.5.1
— 1 critical, 1 recommended, 1 optional.
Each entry includes the ready-to-run Docker command to update that specific service. Copy, paste, done. I also added a dependsOn field to the watchlist so the digest can warn you when an update affects something else — like reminding you that Immich depends on Postgres and Redis, so maybe don't yank those out from under it.
The Bugs
Three bugs found during testing. All of them obvious in hindsight, which is the most annoying kind.
Bug one: delivery failure was a silent killer. If the Discord webhook URL was wrong or Discord was having an outage (which... happens), the HTTP Request node would throw an error, and the entire workflow would stop. No Telegram message. No version update in storage. Nothing. The fix was adding onError: "continueRegularOutput" to every delivery node. Now if Discord is down, the workflow shrugs and continues to Telegram, Slack, ntfy, and storage update. The failed delivery shows up in the execution log so I can see it, but it doesn't block everything else.
Bug two: the null reference cascade. I had a typo in a repo entry — immich-app/immch instead of immich-app/immich. GitHub's API returned a 404. The Extract Release Data node received a response with no tag_name field. The next line tried to access properties on that undefined value and the whole thing crashed. The fix was a simple guard: if (!res.tag_name) continue; — skip any response that doesn't have the expected fields. Also added if (res.prerelease === true) continue; while I was in there, so pre-releases don't trigger alerts.
Bug three: the n8n IF node and the case of the missing key. This one was subtle. I had an IF node checking whether $json.tagName was not empty. Works fine when tagName exists and has a value. Works fine when tagName exists and is an empty string. Breaks when tagName doesn't exist as a key at all — which happens when the Compare Versions node outputs { _noUpdates: true } (no tagName field present). The isNotEmpty operator on a non-existent field doesn't return false — it throws. The fix was switching to the exists operator first. Check that the field exists, then check its value.
I was genuinely annoyed at myself for bug three. I've written enough n8n workflows to know that missing keys and empty strings are different things. But it's the kind of mistake you make at 10 PM when you think you're almost done.
Four Delivery Channels
Each channel has its own formatting requirements, which was more work than I expected.
Discord uses webhook embeds. Each update becomes an embed object with a title, description, URL, color (red 0xFF0000 for critical, orange 0xFFA500 for recommended, blue 0x336BFF for optional), and a footer. The whole payload is a JSON POST to a webhook URL — no bot token needed. If you haven't set one up, the Discord webhook guide takes about two minutes.
Telegram gets a plain text message via the n8n Telegram node. Markdown formatting with bold labels and inline code for the docker commands. Telegram's formatting is the least customizable of the four, but it's also the most reliable — it just works.
Slack uses the n8n Slack node with Block Kit formatting. Bold text, code blocks, links rendered properly. Requires a Slack bot token with chat:write scope. More setup than Discord, but the rendering is nicer.
ntfy is the one I use most for urgent alerts. It's a free push notification service — you pick a topic name, subscribe on your phone, and any HTTP POST to ntfy.sh/{topic} appears as a notification. No account needed. The priority header controls whether the notification makes noise: priority 5 (max) for critical, 4 for recommended. Tags add emoji to the notification badge. Dead simple, zero infrastructure.
All four delivery nodes have onError: "continueRegularOutput" set. If Slack is having a bad day, Discord and ntfy still deliver.
Testing
I ran four test scenarios before turning it on.
First-run seeding. Cleared the workflow's static data, ran it manually. Result: twelve versions stored, zero alerts sent, output message says reposSeeded: 12. Correct.
No-updates run. Ran it again immediately. All versions match what's stored, so Compare Versions outputs { _noUpdates: true }. The Has Updates? IF node routes to the false branch (which just stops — no alert, no error). Clean exit.
New release detection. Manually edited static data to set Grafana's stored version to an old tag. Ran the workflow. It detected the version mismatch, ran the AI analysis on the real changelog, formatted the digest, and delivered to Discord. Verified the embed had the right color, the summary was accurate, and the docker command was correct.
Deduplication. Added Traefik to both the manual watchlist and a docker-compose auto-detect list. The Deduplicate Watchlist node correctly kept only the manual entry (manual entries take priority over auto-detected ones). One Traefik alert, not two.
All four passed. The workflow went live.
One thing worth noting: n8n's static data only persists during active (scheduled/webhook) executions. Manual test runs from the n8n editor don't save static data changes. So that first-run test was actually testing the seeding logic, not the persistence. I verified persistence by checking the static data after a real scheduled execution.
The Result
Forty-three functional nodes. Runs every morning at 8 AM CST. Checks ten GitHub repos and two Docker Hub images. Costs about $0.02 per day in Anthropic API usage. Saves release history to PostgreSQL with full AI analysis, urgency ratings, and security advisories.
In the first week, it caught three updates I would have missed: an n8n patch that fixed a memory leak in queue mode, an Uptime Kuma release with new notification providers, and — most importantly — a Traefik update that patched a header parsing vulnerability. The Traefik one came through as a critical instant alert on my phone at 8:01 AM. I had it updated before I finished my first cup of coffee.
The PostgreSQL history table also turned out to be unexpectedly useful. After a couple weeks, I started querying it to see update patterns: which repos release most often, which ones tend to have breaking changes, how frequently security patches happen. It's a small thing, but it gives you a sense of which services are high-maintenance and which ones are boring (boring is good in infrastructure).
The workflow also includes a docker-compose auto-detection feature I haven't talked about much — you can point it at a raw docker-compose.yml URL and it'll parse out every image: reference, map it to the corresponding GitHub repo or Docker Hub namespace, and add it to the watchlist automatically. It recognizes 25+ common homelab images out of the box. Nice for people who don't want to manually add every repo.
If you want to build this yourself, the companion tutorial covers every node from start to finish. You'll need an Anthropic API key and at least one notification channel — Discord webhook is the quickest to set up, but Telegram and ntfy work just as well.
What Would I Change?
The delivery channels were added one at a time — Discord first, then Telegram, then Slack, then ntfy. Each one has its own formatting function inside the Format Digest and Format Instant Alert code nodes. That means four slightly different versions of "render an update as text" living in two different nodes. If I were starting over, I'd build a channel abstraction from the beginning. One format function that accepts a template and output format (embed JSON, markdown, plain text) and generates all four at once. It would be half the code and way easier to maintain.
The other thing: the PostgreSQL history table was an afterthought. I added it late in the build because I wanted to debug AI response quality and needed somewhere to store raw changelogs alongside the analysis. But once it was there, I kept finding uses for it — tracking update frequency, reviewing past analyses to tune the prompt, building a Grafana dashboard of release activity. It should have been in the design from day one. If you're building this, add it early. You'll thank yourself in a month.
I also should have added the docker-compose auto-detection earlier in the process instead of treating it as a "nice to have" bolt-on. It changed how I think about the watchlist — from a manually curated list to something that reflects your actual stack. That's a meaningfully better UX, and I almost shipped without it.