A self-hosted AI/ML news monitoring pipeline. Triggered on demand by an @mention of a Discord bot, the pipeline collects RSS feeds, deduplicates against PostgreSQL, scores and summarises articles with Gemini, and posts grouped embeds to up to four Discord webhooks.
Discord @mention
-> n8n workflow (single execution, Redis-locked)
-> Load rss_sources.json
-> Read 25+ RSS feeds (parallel)
-> Date filter (2 days), categorize, dedup against PostgreSQL
-> Gemini call (relevance + summary in one round-trip)
-> Group by source_type, top-N per group
-> Format Discord embeds, POST to per-group webhooks
-> UPDATE rss_articles.sent_at = NOW()
-> Release lock
The lock uses Redis (via Webdis) with the n8n $execution.id as a fencing token; release is a Lua compare-and-delete so a stale execution cannot wipe a newer lock. Lock TTL is 15 minutes.
Articles are inserted only after passing the Gemini relevance gate (confidence >= 85). sent_at is set after the Discord POST succeeds, so the database can distinguish queued, evaluated, and actually-sent rows.
| Service | Image | Purpose | Host port |
|---|---|---|---|
| postgres | postgres:16-alpine |
Article store, dedup, stats, retention | 127.0.0.1:5433 |
| n8n | n8nio/n8n:latest |
Workflow runtime | 127.0.0.1:5678 |
| rsshub | diygod/rsshub:latest |
Custom RSS feed generator (optional) | 127.0.0.1:1200 |
| redis | redis:7-alpine |
Lock backend (no auth, internal network only) | internal |
| webdis | nicolas/webdis:latest |
HTTP wrapper around Redis (used by n8n HTTP nodes) | internal |
All published ports bind to 127.0.0.1. To expose any service to the network, put it behind a reverse proxy with TLS and authentication.
.
├── docker-compose.yml # Service orchestration
├── env.template # Configuration template
├── rss_sources.json # RSS sources (single source of truth)
├── workflow-simplified.json # Current n8n workflow
├── scripts/
│ ├── setup.sh # First-run setup (.env + dirs)
│ ├── start.sh, stop.sh, restart.sh, status.sh, logs.sh
│ ├── backup.sh, restore.sh
│ └── init.sql # PostgreSQL schema, functions, indexes
├── data/ # Persistent volumes (gitignored)
└── docs/
├── INSTALL.md
├── MAINTENANCE.md
└── TROUBLESHOOTING.md
- Linux host (tested on Ubuntu 22.04 / Debian 12, WSL2)
- Docker 24+ and Docker Compose v2
- 2 GB RAM minimum, 4 GB recommended
- A Discord bot token with the
MESSAGE CONTENTintent - Up to four Discord webhook URLs (one per category)
- A Gemini API key (https://aistudio.google.com/apikey)
git clone <repo-url> veille-ia
cd veille-ia
chmod +x scripts/*.sh
./scripts/setup.shsetup.sh generates .env from env.template and writes random passwords for PostgreSQL and the n8n encryption key. Edit .env and fill in the Discord and Gemini values, then start the stack:
./scripts/start.sh
./scripts/status.shOpen n8n at http://localhost:5678, create the owner account, then import workflow-simplified.json. Wire the Postgres credential and the Discord Bot Trigger credential. See QUICKSTART.md for the full first-run procedure.
All runtime settings live in .env. Key variables:
| Variable | Required | Notes |
|---|---|---|
POSTGRES_PASSWORD |
yes | Auto-generated by setup.sh. |
N8N_ENCRYPTION_KEY |
yes | Auto-generated. Do not rotate without re-encrypting credentials. |
DISCORD_BOT_TOKEN |
yes | From the Discord developer portal. |
DISCORD_WEBHOOK_TECH_NEWS |
yes | Per-category webhook. |
DISCORD_WEBHOOK_COMPANIES |
yes | |
DISCORD_WEBHOOK_REDDIT |
yes | |
DISCORD_WEBHOOK_OTHERS |
yes | |
DISCORD_ALLOWED_AUTHORS |
no | Comma-separated user IDs allowed to trigger. Empty = anyone in the configured channels. |
GEMINI_API_KEY |
yes | |
GEMINI_MODEL |
no | Defaults to gemini-pro. gemini-2.0-flash is a good cost/latency trade-off. |
POSTGRES_PORT |
no | Host-side port. Defaults to 5433 to avoid colliding with a local Postgres. |
TZ |
no | Defaults to Europe/Paris. |
The list of n8n-visible env vars is whitelisted via N8N_ENV_VARS in docker-compose.yml. Add new variables there as well as in the env section of the n8n service.
rss_sources.json is mounted read-only into the n8n container at /home/node/rss_sources.json. The workflow reloads it on every execution, so changes take effect immediately without restarting n8n. Each entry has url, name, category, priority, and active. Set "active": false to disable a source.
Schema is in scripts/init.sql and is applied automatically on the first PostgreSQL start. Notable details:
rss_articles.urlis unique. INSERTs useON CONFLICT (url) DO NOTHING.sent_atisNULLat insert and set toNOW()only after a successful Discord post.purge_old_articles()deletes byCOALESCE(sent_at, created_at) < NOW() - INTERVAL '90 days'.article_statsview exposes total / sent / 24h / 7d counts.get_source_stats()returns per-source counts.filter_new_urls(text[])andcheck_url_duplicates_json(jsonb)are helpers for n8n dedup queries.
Connect from the host:
docker compose exec postgres psql -U n8n veille_ia| Task | Command |
|---|---|
| Start | ./scripts/start.sh |
| Stop | ./scripts/stop.sh |
| Restart | ./scripts/restart.sh |
| Status + stats | ./scripts/status.sh |
| Tail logs | ./scripts/logs.sh {n8n,postgres,rsshub,all} |
| Backup DB+workflows | ./scripts/backup.sh |
| Restore | ./scripts/restore.sh |
| Manual purge | SELECT purge_old_articles(); in psql |
A daily backup can be scheduled with cron:
0 4 * * * cd /path/to/veille-ia && ./scripts/backup.sh >> logs/backup.log 2>&1- All host port bindings are restricted to
127.0.0.1. Webdis is internal-only and unauthenticated; do not publish it. - n8n is owner-on-first-setup. Create the owner account before exposing the host.
N8N_BLOCK_ENV_ACCESS_IN_NODE=falseis required because Code nodes need$env. The exposure is bounded by theN8N_ENV_VARSwhitelist; review it before adding new env vars.- Set
DISCORD_ALLOWED_AUTHORSto the user IDs of operators if the trigger channels are not fully trusted. - The
.envfile is git-ignored. Keep it that way.
The most common failure modes and their fixes are documented in docs/TROUBLESHOOTING.md. A quick checklist:
- Bot does not react to mention: verify the
MESSAGE CONTENTintent and the channel IDs in theDiscord Bot mention1node. - "Already running" loop: a lock is stuck.
docker compose exec redis redis-cli DEL veille_lock. - Gemini
MAX_TOKENSor429: switchGEMINI_MODELtogemini-2.0-flashorgemini-2.5-flash. relation "rss_articles" does not exist:init.sqlwas not applied.docker compose down -v && ./scripts/start.sh(this destroys data).
MIT.