A Newsletter and Analytics With No Third Parties at All

After fixing this blog's SEO, two things were still missing: a way for readers to hear about new posts, and any idea whether anyone reads them. The usual answer is Mailchimp and Google Analytics. But lans.cloud's whole pitch is that your data stays with you — it would be a strange blog for that project that ships every visitor to Google and every subscriber to a marketing platform.

So both are self-hosted now, on the same VPS as everything else. This is how they work, and the three things that bit me on the way.

One door for all outgoing mail

The VPS already had a small internal service I call the mail gateway: a thin HTTP wrapper around one SMTP mailbox, listening only on the internal Docker network. Apps hold an API key; only the gateway holds the mailbox password. It has no public route at all — from the internet it simply doesn't exist, and the containers reach it by service name.

The blog was actually sending its login emails through a third-party email API before this. Now every email the blog sends — magic links for premium subscribers, and the new newsletter — goes through that one audited door:

await fetch(`${MAIL_SERVICE_URL}/send`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'X-Api-Key': MAIL_API_KEY },
  body: JSON.stringify({ to, subject, html, text, from: 'noreply@lans.cloud' }),
});

One place to rate-limit, one place to log, one credential to rotate.

The newsletter: double opt-in, no token table

The signup form does what you'd expect — but two design choices are worth writing down.

The tokens are stateless. Confirm and unsubscribe links need a secret token so nobody can confirm or unsubscribe someone else. Most implementations store a random token per subscriber. I store nothing: the token is HMAC-SHA256(secret, email). The server can verify any token by recomputing it, the database keeps only the email and two timestamps, and there's no token column to leak, expire, or migrate.

function newsletterToken(email: string): string {
  return crypto.createHmac('sha256', NEWSLETTER_SECRET)
    .update(email.trim().toLowerCase())
    .digest('hex');
}

Sending is a job, not a side effect. Publishing a post here is just writing a markdown folder — there's no "publish" event to hook an email into. Instead, a small authenticated endpoint compares the current posts against a table of already-announced slugs and emails whatever's new, one message per confirmed reader, each with its personal unsubscribe link. It runs from cron or by hand, and it's idempotent — running it twice never mails twice.

My favorite line in that job is the first-run guard. If the "already announced" table is empty — a fresh database — it doesn't email the entire archive to everyone. It records every existing post as announced and sends nothing:

if (sentSlugs.size === 0) {
  posts.forEach(post => markNewsletterSent(post.slug, 0));
  return json({ baselined: posts.length, sent: 0 });
}

Every newsletter horror story I've read starts with "and then it sent 200 emails to everyone". This makes that failure mode structurally impossible.

The rest is the boring-but-important checklist: double opt-in (an address gets content only after clicking a confirmation link), a generic response for every signup so the endpoint can't be used to test whether an address is subscribed, and rate limits per email and per IP.

Analytics: a number, not a profile

For analytics I added Umami to the shared infrastructure stack — one container next to the Postgres and MinIO that were already there, reusing the same Postgres instance with its own database and user. Twenty lines of docker-compose, one Traefik label for the hostname, done.

Umami is cookie-free and doesn't fingerprint, which matches the house privacy rule: I want to know that a post was read, not who read it. The tracking snippet is one script tag, and because the blog runs a strict Content-Security-Policy, going self-hosted had a nice side effect — the CSP now allows scripts from exactly two origins, both of which are mine.

Total additions to the page: one <script defer>. The blog still ships essentially no JavaScript.

Three things that bit me

Astro's CSRF guard ate my cron job. The newsletter send endpoint kept answering 403 Cross-site POST form submissions are forbidden — not from my code, from Astro's built-in checkOrigin protection, which rejects cross-origin POSTs that look like form submissions. A curl POST with no content type looks exactly like one. The fix is embarrassingly small: send Content-Type: application/json.

Internal-only means you can't test from the host. The mail gateway is reachable from containers, not from the host shell — which is the entire point, and also why my first local test "failed". If you build on internal-only services, decide early how you'll verify them: I ended up testing through the deployed container and reading the gateway's logs, which log every send with recipient and status.

Change the default admin password by API, immediately. Umami boots as admin/umami on a public hostname. The container was internet-reachable the moment Traefik picked it up, so the password rotation couldn't be a "later" item. It's one authenticated call to /api/me/password — I scripted it into the setup so the default credentials lived for under a minute.

What it costs to run

Nothing per month, which was the point — but honestly: the mail gateway and Umami together idle at well under 200 MB of RAM on a VPS that was already paid for. The real cost is that I'm now the deliverability department. For a personal blog's volume (one email per post, to people who double-opted-in, from a domain with proper DNS records) that's been fine. If I were sending thousands of emails a day I'd think harder.

The stack, summarized

  • Signup → confirm → unsubscribe: blog endpoints + HMAC tokens, SQLite for two timestamps per subscriber
  • Delivery: internal mail gateway (one SMTP credential on the whole VPS), reachable only on the Docker network
  • Announcing posts: idempotent cron job with a first-run baseline guard
  • Analytics: Umami on shared Postgres, cookie-free, one script tag, CSP-clean

If you run your own blog on your own box, both of these are an afternoon each — and the afternoon is mostly deciding the failure modes, not writing the code.