n8n is one of the best tools for a self-built AI newsletter because it gives you a real scheduler, real error handling, and full control over the LLM call - without writing a whole application. The workflow in this guide is five nodes: a Schedule Trigger, one or more RSS Read nodes, a Code node that merges and dedupes, an OpenAI node that writes the digest, and a Send Email node that delivers it over SMTP. Expect two to four hours to get it running end to end, most of which goes into prompt tuning and email formatting.
Step 1: Get n8n running (Cloud vs self-hosted)
You have two realistic options. n8n Cloud is the fastest path: sign up, and you have an editor with nothing to maintain. The trade-off is a monthly subscription (the starter tier is roughly 20-24 EUR/month depending on billing) and execution limits on the cheaper plans. Self-hosting is nearly free at this scale - a workflow that runs once a day is trivial load, so the smallest VPS you can rent (around $5/month at Hetzner, DigitalOcean, or similar) is plenty. Self-hosting means you own updates, backups, and TLS.
If you self-host, docker compose is the standard setup. Create a directory on your server, save this as docker-compose.yml, and run docker compose up -d.
services:
n8n:
image: docker.n8n.io/n8nio/n8n
restart: always
ports:
- "5678:5678"
environment:
- N8N_HOST=n8n.yourdomain.com
- N8N_PORT=5678
- N8N_PROTOCOL=https
- WEBHOOK_URL=https://n8n.yourdomain.com/
- GENERIC_TIMEZONE=Europe/Vienna
- TZ=Europe/Vienna
- N8N_ENCRYPTION_KEY=replace-with-a-long-random-string
volumes:
- n8n_data:/home/node/.n8n
volumes:
n8n_data:Set N8N_ENCRYPTION_KEY once and never change it - n8n uses it to encrypt stored credentials, and losing it means re-entering every credential. Put a reverse proxy (Caddy or nginx) with TLS in front of port 5678 before exposing it to the internet.
Step 2: Schedule Trigger
Create a new workflow and add a Schedule Trigger node. For a weekday-morning newsletter, use a cron expression instead of the interval mode so daylight-saving changes behave predictably.
- Trigger Rules: add rule, Trigger Interval = Custom (Cron)
- Cron Expression: 0 7 * * 1-5 (07:00, Monday to Friday)
- Make sure GENERIC_TIMEZONE matches your timezone, otherwise cron fires in UTC
Step 3: Fetch articles (RSS Read or a news API)
Add one RSS Read node per feed. Each node has a single URL field - paste the feed URL and you are done. Connect the Schedule Trigger to every RSS Read node in parallel, then connect all RSS nodes into the Code node from the next step. n8n merges the incoming branches automatically, with each article arriving as one item.
- RSS Read node, URL: https://hnrss.org/frontpage (example - Hacker News front page)
- RSS Read node, URL: https://techcrunch.com/feed/
- RSS Read node, URL: any blog or publisher feed you follow
If a source has no feed, use an HTTP Request node against a news API instead. For NewsAPI: Method GET, URL https://newsapi.org/v2/everything, query parameters q (your topic), from (yesterday's date), sortBy=publishedAt, and an apiKey parameter - or better, put the key in a Header Auth credential so it is not stored in plain text on the node. NewsAPI's free tier is for development only (no commercial use, delayed articles), so treat it as a prototyping option.
Step 4: Merge and dedupe in a Code node
Add a Code node, set Mode to Run Once for All Items, Language JavaScript, and paste this. It normalizes fields across feeds, drops duplicates by link, keeps only the last 24 hours, sorts newest first, and caps the list at 25 items so the LLM prompt stays a reasonable size.
var seen = {};
var out = [];
var cutoff = Date.now() - 24 * 60 * 60 * 1000;
for (var i = 0; i < items.length; i++) {
var a = items[i].json;
var link = a.link || a.url || "";
var title = a.title || "";
if (!link || !title) continue;
if (seen[link]) continue;
seen[link] = true;
var published = a.isoDate || a.pubDate || a.publishedAt || null;
var ts = published ? new Date(published).getTime() : Date.now();
if (isNaN(ts) || ts < cutoff) continue;
var summary = a.contentSnippet || a.description || a.content || "";
summary = summary.replace(/<[^>]*>/g, "").trim();
if (summary.length > 400) summary = summary.slice(0, 400) + "...";
out.push({
json: {
title: title.trim(),
link: link,
source: a.creator || a.author || (a.source && a.source.name) || "",
published: new Date(ts).toISOString(),
summary: summary,
},
});
}
out.sort(function (x, y) {
return y.json.published.localeCompare(x.json.published);
});
out = out.slice(0, 25);
var digestInput = out
.map(function (it, idx) {
return (
(idx + 1) + ". " + it.json.title +
" (" + it.json.link + ")\n" + it.json.summary
);
})
.join("\n\n");
return [{ json: { count: out.length, digestInput: digestInput } }];This 24-hour window is stateless dedupe - it only prevents duplicates within one run. If your feeds re-publish old items, add an n8n static-data check (workflow staticData) or a small database to remember links across runs.
Step 5: Write the digest with the OpenAI node
Add the OpenAI node (operation: Message a Model). Create an OpenAI credential first: in n8n go to Credentials, New, OpenAI, and paste an API key from platform.openai.com. Configure the node like this.
- Resource: Text, Operation: Message a Model
- Model: gpt-4o-mini (cheap and fine for digests; upgrade to gpt-4o if summaries feel flat)
- System message: paste the prompt below
- User message (expression): ={{ $json.digestInput }}
- Options: Temperature 0.4, Max Tokens 1500
You are the editor of a short daily email newsletter. You receive a numbered list of articles, each with a title, URL, and snippet.
Write an HTML email digest:
- Start with a 2-sentence overview of the day's main theme.
- Group the articles into 2-4 topical sections, each with an <h3> heading.
- For each article: an <a> link on the title, then one sentence (max 25 words) saying what happened and why it matters. Use only facts present in the snippet - do not invent details, numbers, or outcomes.
- Skip articles that are duplicates of each other or pure marketing.
- End with a one-line "Worth a click" pick.
Output rules: valid HTML fragment only (no <html> or <body> tags), no markdown, no preamble, no code fences.Step 6: Send Email node (SMTP)
Add a Send Email node. Create an SMTP credential with your provider's settings - a transactional service like Postmark, Brevo, or Amazon SES is more reliable than a personal Gmail account, and Gmail SMTP now requires an app password with 2FA anyway. Typical credential values: Host smtp.your-provider.com, Port 587, SSL/TLS off with STARTTLS on (or port 465 with TLS), plus your username and key.
- From Email: newsletter@yourdomain.com (a domain you control - see the cost section on SPF/DKIM)
- To Email: your address, or a comma-separated list for a handful of readers
- Subject (expression): ={{ 'Daily digest - ' + $now.format('dd LLL yyyy') }}
- Email Format: HTML
- HTML (expression): ={{ $json.message.content }} - check the OpenAI node's output structure in the execution view; on some node versions the text lands under a different key, so map whichever field holds the digest
Step 7: Test with pinned data
Do not tune your prompt by hammering the RSS feeds and the OpenAI API on every run. Execute the workflow once, then open each RSS Read node's output and click the pin icon (Pin Data). n8n will now reuse that captured data on every manual execution, so you can edit the Code node and the prompt and re-run instantly and deterministically. You can also pin the OpenAI node's output while you fiddle with only the email formatting, which saves API spend. Pins apply only to manual executions - production (scheduled) runs always fetch live data.
Error handling and retries
A scheduled workflow fails silently unless you tell it not to. Two things fix that.
- Per-node retries: on the RSS Read, HTTP Request, and OpenAI nodes open Settings and enable Retry On Fail (3 tries, 5000 ms wait). Feed timeouts and 429s from OpenAI are the two most common transient failures.
- On the RSS nodes also set On Error to Continue (using error output) or Continue - one dead feed should not kill the whole issue. The Code node above already tolerates missing fields.
- Error workflow: create a second workflow whose trigger is the Error Trigger node, connected to a notification you will actually see (Telegram, Slack, or a plain Send Email to yourself with {{ $json.workflow.name }} and {{ $json.execution.url }} in the body). Then in your newsletter workflow open Settings and set this as the Error Workflow. Now any failed production run pings you instead of disappearing.
Also check Settings, Save Data on Error is enabled (it is by default) so you can open the failed execution and see exactly which node broke and with what input.
What this really costs
| Factor | Realistic answer |
|---|---|
| Setup time | 2-4 hours for the workflow itself; add 1-2 hours for VPS + docker + TLS if self-hosting, and another 1-2 hours for email domain authentication |
| Monthly cost | n8n Cloud starter tier (roughly 20-24 EUR/mo) or a ~$5/mo VPS; OpenAI usage for one daily gpt-4o-mini digest is typically well under $1/mo; transactional email free tiers cover a personal list |
| Email deliverability | Sending from your own domain requires SPF, DKIM, and DMARC DNS records or you land in spam; personal Gmail SMTP caps out around 500 recipients/day and looks increasingly untrustworthy to filters |
| Ongoing maintenance | Feeds die or change format, OpenAI models get deprecated, n8n updates occasionally break nodes, and prompt output drifts - budget 30-60 min/month of tinkering, more when something breaks the morning you rely on it |
The honest summary: the money is negligible, the time is not. The build is a fun afternoon, but you are signing up to be the operator of a small production system. The failure mode that actually bites is silence - a feed 404s or your OpenAI key hits a quota, and the newsletter just stops arriving until you notice. The error workflow above mitigates this, but only if you set it up and keep the notification channel alive.
The second cost that sneaks up is quality drift. The prompt that produced sharp summaries in week one slowly gets stale as your interests shift and as sources change their snippet formats, and nothing in the system tells you - you just notice the digest getting less useful. Re-reading the output critically once a month and adjusting the prompt is part of the deal.