I built a fully automated daily points newsletter and podcast that runs on a Mac Mini for about $0/month
Every morning at 7:00, an email goes out. At 7:30, a podcast episode publishes. I did nothing between the two. A Mac Mini in my house scraped Reddit and a handful of points blogs twice the day before, a small local model threw out almost everything, Claude wrote up what was left, two AI voices recorded a script about it, and Cloudflare served the result to whoever showed up.
This is Points Wire: a daily digest and a 6-8 minute podcast about credit card points and travel deals, specifically tilted toward the Chase Sapphire Reserve since that's the card I actually carry. It has been running since mid-July. Here are the numbers as of today, September 2nd: 13 newsletter subscribers, a 68% average open rate, 46 podcast episodes, and roughly 650 real unique podcast listeners once I subtract the bot traffic (more on that in a minute). The whole thing runs on one Mac Mini I already owned, and the only recurring bill is $22/month for text-to-speech.
I want to be upfront about what this post is. It's not a growth story. Thirteen subscribers is a small number and I'm not going to dress it up. This is a post about the machine: how it's built, what it costs, what broke, and what I learned building something that has to run correctly every single day with nobody watching it.
Why I built it
I hold a Chase Sapphire Reserve, and the good stuff in the points world (transfer bonuses, award space openings, fine print changes) dies within 48 hours. It's scattered across Reddit's r/churning and r/awardtravel, Doctor of Credit, Frequent Miler, The Points Guy, and DansDeals. Reading all of that every day to find the five things that mattered to me was not a good use of time.
I didn't want to read 200 posts a day. I wanted the five that mattered, with the math already done. Everything else in this system followed from that one requirement.
The pipeline
The system runs entirely on a Mac Mini via launchd, in five stages.
Ingest. Twice a day, the pipeline pulls from Reddit (the official API when I have credentials configured, falling back automatically to public RSS feeds otherwise) and a set of points blogs. Everything lands in Postgres with pgvector for embeddings.
Triage, for free, locally. This is the part I'm proudest of. A local llama3.1:8b model running through Ollama scores every incoming story for relevance and urgency, 0-100. Embeddings from nomic-embed-text dedupe stories across sources at a 0.90 cosine similarity threshold and recognize when something is an update to a story already covered at 0.80. Anything scoring under a relevance floor of 30 never leaves the local model. The point of this stage is that the cheap model does all the high-volume, low-value filtering so the expensive model only ever sees survivors.
Synthesis. Claude writes the digest as structured JSON: a tl;dr, sections, action items, and a jargon glossary for terms like "MSR" or "SUB." It gets the last seven issue summaries as context so it doesn't repeat itself day over day. There's a link allowlist enforced in code: the model can only emit URLs that actually came in from a real source. It cannot invent a link.
Podcast. The same curated stories go to Claude a second time, which writes a two-host script: one host (Alex) presents the story, the other (Sam) does the value math and reads the fine print. That script goes to ElevenLabs for text-to-speech, gets assembled with ffmpeg, uploads to Cloudflare R2, and the RSS feed gets rendered fresh from Postgres. Target length is about 950 words, which lands the episode at 6-8 minutes. max_script_chars is capped at 12,000 as a hard ceiling, partly for pacing and partly because ElevenLabs characters are the one thing in this system that actually costs money.
Site and delivery. Cloudflare Pages and Functions serve the site. R2 holds the audio, the RSS feed, and a pre-rendered archive of every past issue. Resend sends the newsletter as a broadcast. D1 (Cloudflare's SQLite) handles signup abuse control and podcast download counting. Every issue and every episode auto-publishes an archive page after it sends, at zero marginal cost, since the content already exists in the database before any of this happens.
Ops. Self-hosted Grafana, Prometheus, and Loki run on the same Mini, alongside Umami for site analytics. Healthchecks.io is the external dead-man's switch, because a system that only reports to itself can't tell you when it's silently stopped running. UptimeRobot does external HTTP probes from outside my network. ntfy pushes alerts straight to my phone.
What it costs
The honest bill: ElevenLabs Creator plan, $22/month plus tax, Auto Top Up deliberately switched off so it fails closed instead of surprise-billing me. That's the only real recurring cost. Everything else runs on free tiers I've deliberately kept under: Resend (free up to 100 emails/day, which is a wall around 90 subscribers at current sending patterns), Cloudflare Pages/Functions/D1/R2 (free tier, with a Grafana alert wired at 70 subscribers to warn me before I hit the Resend wall), Postgres and Ollama self-hosted on hardware I already own, Claude through my existing subscription with metered overage disabled, and healthchecks.io/UptimeRobot/ntfy all on their free tiers. The domain itself is about $10/year. Every wall in this system is designed to fail closed and alert, never to silently rack up a bill. Growth past a free tier is a decision I make on purpose, not something that happens to me.
The traffic spike that wasn't
The clearest lesson from building the analytics side of this came from a number that looked great and turned out to be mostly fake. The week of August 24th, podcast downloads jumped to 580 from a baseline of 60-135 a week. My first read was that something had found the show.
It hadn't, not really. I pulled the raw user-agent data from the download logs and found three separate sources accounting for 497 of those 580 rows. 214 were an Apple Podcasts catalog crawl that had escalated from one request a day to roughly one request per active episode per day, tracking the show's own episode count almost exactly. 213 were a scraper rotating through 87 different fake user agents, hitting random back-catalog episodes 10-30 seconds apart, present in zero days before that week and gone within a week after. 70 were a single-day burst from something calling itself InworldTTS-DataPipeline, which self-identifies in its user agent string as an AI training dataset scraper.
Once I subtracted the fake traffic, the real number of unique podcast listeners across the show's life comes out to roughly 650, not the 1,054 the raw total suggested. That's still about 50 times the newsletter's subscriber count, and it's the strongest evidence I have that the content itself works even when the audience doesn't yet exist for the newsletter. But I'm not quoting the bigger number anywhere again. A number that came from AI scraper traffic is not a number I get to claim.
War stories
The last_name cooldown bug. The first version of signup rate limiting stashed a timestamp in the Resend contact's last_name field, formatted as pw_ts_<epoch-ms>, to enforce a 10-minute cooldown between confirmation email resends. It was a check-then-act race condition against a field that was never designed to hold state, and it failed open under concurrent requests instead of closed. I replaced it with an atomic check-and-set in Cloudflare D1 that fails closed by design. The lesson: don't build a state machine out of a CRM vendor's spare text field, even when it's tempting because the field is just sitting there unused.
The cert.pem zone trap. cloudflared tunnel login issues a certificate scoped to exactly one Cloudflare zone. The Mini already had a tunnel running for an unrelated domain, and its cert was scoped to that domain. When I set up DNS routing for the new points-wire.com tunnel, cloudflared tunnel route dns didn't error. It silently created records on the wrong zone and reported success. I ended up with three junk DNS records I had to find and delete by hand. Cost about an hour. The fix, and the lesson: always verify the actual record name that comes back from a DNS command, not just that the command exited zero, and confirm independently with dig.
Pages Functions can't fetch your own domain. Any fetch from a Cloudflare Pages Function to a same-account, proxied hostname returns a 522. I hit this trying to have one Function call another on the same site. The fix was routing everything through R2 bindings instead of HTTP: the archive and latest-episode features all read storage directly through a binding rather than making an HTTP request to themselves. On Cloudflare, bindings are the API. HTTP is for other people's servers.
The 29-commit silent drift. This is the one that actually broke production for the longest. The blog pipeline (a separate SEO-content arm of the system that drafts and publishes articles) went silent for 11 days, from August 17th to August 28th. The proximate cause was boring: the topic backlog ran out, and a fix for that had already been written and committed on my laptop six days earlier. It just never reached the Mac Mini. The deeper cause was the deploy path itself: the Mini's git checkout pulls from a bare mirror repo that only my laptop pushes to, and there was no step anywhere that alerted when the Mini's checkout fell behind that mirror. It drifted 29 commits behind silently, with nobody and nothing noticing, because nothing was watching for exactly that failure mode. Fixing it meant reconciling the Mini's own uncommitted local edits against the incoming commits by hand, file by file, verifying checksums before discarding anything. I now have a script that checks and reports Mini/mirror/laptop drift before every deploy, and a deploy script that verifies all three are in sync before it declares success. The lesson: a two-machine system without a real deploy pipeline doesn't fail loudly, it just quietly stops being the code you think it is.
The em dash war. This one is small and a little funny. The text-to-speech voices read em dashes strangely out loud, and Claude, left to its own devices, writes em dashes constantly. So both the digest generator and the podcast script generator carry an explicit prompt instruction never to use them, plus a mechanical regex strip as a backstop in case the prompt fails: every em dash in generated text gets replaced with a comma before it ships. Yes, this is an AI-written system with a no-em-dash linter, built for the same reason I don't use them in this post: they read as filler when you notice you're relying on them.
What hasn't worked
Social autoposting is the honest low point. The autoposter itself works mechanically without a flaw: 40 out of 40 posts to Bluesky and 40 out of 40 to X over about six weeks, zero failures, zero missed days. The results are brutal. The Bluesky account has 3 followers and 4 total engagement actions (3 likes, one repost) across all 40 posts; 37 of the 40 got nothing. X engagement is unverified since reading it requires an authenticated API call I haven't made, but there's no reason to expect it looks different. Zero pageviews on the site arrived carrying a social UTM tag in that entire window. The automation isn't the problem. There's no audience on the other end yet, and posting a link once a day to an account with three followers does not build one.
Being upfront that it's AI
The hosts on the podcast are AI voices and the site says so. The digest is written by Claude and the site says so. Every factual claim in both the digest and the show notes carries a source link back to where it came from. I built it this way on purpose: a human curating this daily, at this scale, for this small an audience, would cost more than the newsletter could ever earn. The honest version of this product only exists because it's automated, and the automation is the actual product, not a shortcut to one.
The risk that matters is not being AI. It is getting caught pretending otherwise, and the disclosure everywhere is how I avoid ever being in that position.
What I'd do differently, and what's next
I built the delivery infrastructure (the site, the archive, the observability stack) well before I did any of the growth work that would give it traffic to observe. The signup hardening work, in particular, took longer than I expected: double opt-in, Turnstile verification, rate limiting, an HMAC-signed single-use confirmation link that expires after an hour. All of it was worth building before pointing any traffic at the site, but I underestimated how much of the total build time it would eat relative to the pipeline itself.
What's next is mostly "put the machine's output in more places," which is the whole growth thesis here: the content already exists in the database before any marketing touches it, so the cheapest lever is always distribution, not production. Submitting the podcast feed to a few more directories, and pointing the podcast audience at the newsletter, since it is already about 50 times the size of the email list: as of this week the hosts mention the free email version in every outro and the show notes lead with a signup link. I also fixed a couple of small tracking gaps I found while pulling these numbers. I'm not promising new features. The system does what it does every morning, and my job right now is mostly getting more people to notice.
Where to find it
Points Wire is free, at points-wire.com. The podcast is in Spotify, Apple Podcasts, and most other apps. The codebase is personal and not public right now, but I'll be in the comments on this post, and happy to answer anything about the architecture in more depth than this post covers.