Self-hosting a newsletter with listmonk

No. 067 · Jun 28, 2026 · 8 min read

Image unavailable

Self-hosting a newsletter with listmonk

I wanted a newsletter for my site. I did not want to use Substack or Buttondown. I wanted it to feel like part of the site, not a separate service with its own branding and its own URL. I also wanted full control over the subscriber list and the emails themselves.

This post covers how I set it up. It goes through the infrastructure, the templates, the site integration, and the campaign automation.

The stack

I used four pieces:

  • listmonk for managing subscribers and sending campaigns. It is a Go application with a Postgres database. It has a REST API and a web dashboard. I run it in Docker on an Oracle Cloud free tier VM.
  • SMTP2GO for email delivery. Their free tier gives you 1,000 emails per month. That is more than enough for a personal newsletter.
  • Cloudflare Tunnel for exposing listmonk to the internet. The OCI VM is behind a firewall with no open ports. The tunnel routes newsletter.yashagarwal.in to the listmonk container without exposing anything directly.
  • Vercel function for handling subscriptions server-side. The function verifies the Turnstile captcha token, checks the honeypot, and forwards to listmonk from the same domain, so there are no CORS issues.

The architecture looks like this:

text
yashagarwal.in (Vercel)
  ├── /newsletter page → subscription form with Turnstile widget
  └── /api/subscribe → Vercel function (Turnstile verification) → listmonk subscription API
 
newsletter.yashagarwal.in (Cloudflare)
  └── Cloudflare Tunnel → OCI VM:9001 → listmonk container:9000
 
OCI VM
  ├── listmonk (Docker): subscriber management and campaign sending
  ├── listmonk-db (PostgreSQL): subscriber data
  └── cloudflared (Docker): Cloudflare Tunnel client

Setting up listmonk

The Docker Compose file is straightforward. The listmonk container needs a Postgres database, SMTP credentials, and a few site settings. All of these are set through environment variables:

yaml
environment:
  LISTMONK_app__address: "0.0.0.0:9000"
  LISTMONK_app__from_email: ${LISTMONK_FROM_EMAIL}
  LISTMONK_app__root_url: ${LISTMONK_ROOT_URL}
  LISTMONK_smtp__0__enabled: ${LISTMONK_SMTP_ENABLED}
  LISTMONK_smtp__0__host: ${LISTMONK_SMTP_HOST}
  LISTMONK_smtp__0__port: ${LISTMONK_SMTP_PORT}
  LISTMONK_smtp__0__username: ${LISTMONK_SMTP_USERNAME}
  LISTMONK_smtp__0__password: ${LISTMONK_SMTP_PASSWORD}

The .env file holds the real values. A .env.example file documents every variable so the setup is reproducible.

Image unavailable

The listmonk admin dashboard showing the subscriber list and campaigns
listmonk admin dashboard

The Cloudflare Tunnel is a separate Docker Compose stack on the same VM. It connects to Cloudflare using a token and routes traffic to the listmonk container through the internal Docker network. The listmonk container only listens on 127.0.0.1:9001. It is never exposed to the public internet.

Customizing the templates

listmonk ships with default email templates that use blue links, Helvetica fonts, and a "Powered by listmonk" footer. I wanted the emails to match the site's design.

listmonk lets you override the built-in templates by mounting a static directory into the container. I downloaded the default templates from the listmonk repository, customized them, and mounted them via Docker Compose.

There are two sets of templates:

System email templates are used for automated emails like the opt-in confirmation. The key one is subscriber-optin.html. This is the email someone gets after they subscribe. I wrote a clean version with the site's system UI font, Georgia headings for the nameplate, and a black confirmation button.

Image unavailable

The opt-in confirmation email styled to match the site
Opt-in confirmation email

Public page templates are used for the pages subscribers see when they click links in emails. That includes the unsubscribe page, the opt-in confirmation page, and the subscription management page. I customized these to match the rest of the site. Each page has a white container on a light grey background, Georgia headings, and black buttons.

I also created a campaign template for the actual newsletter emails. This is uploaded to listmonk through the API. It uses the same design but adds the subscriber's first name as a greeting and includes a tracking pixel.

The subscription form

The form lives on the /newsletter page. I originally put a form in the footer of every page, but that was noise. Someone who just landed on the site does not need to see a subscribe form. The right place is a dedicated page they choose to visit, or a one-line prompt at the bottom of a post they just finished reading.

Image unavailable

The subscription form on the newsletter page with the Turnstile checkbox
Subscription form with Turnstile

The form posts to /api/subscribe, which is a Vercel serverless function. The function checks the honeypot field and verifies the Turnstile token against Cloudflare's siteverify API, using a secret that lives only in the Vercel environment. If the checks pass, it forwards the subscription to listmonk. Because everything stays on the same domain, there is no CORS. Vercel serverless functions are free on the Hobby plan, with a million invocations per month included.

For spam protection, I added Cloudflare Turnstile to the form. Turnstile runs a challenge in the background and produces a token. The submit button stays disabled until the challenge completes. The token is then verified by the Vercel function on the server. No secret touches the browser, so a bot cannot forge a valid token.

The form submission uses AJAX via FormData. This keeps the user on the page and shows a success message without a redirect. The script strips the honeypot and nonce fields before sending because the API endpoint does not need them.

Automating campaigns

Creating a campaign through the listmonk API takes several steps: set the name, subject, body, template, list IDs, content type, and messenger. Doing this by hand each time is tedious.

I wrote a script called create-campaign.sh that handles all of this. It accepts a title, subject, and body (from a file or inline). It can also send a test to specific email addresses and optionally start the campaign immediately.

bash
scripts/create-campaign.sh \
  --title "Issue 1" \
  --subject "Hello from Yash" \
  --body-file body.md \
  --test [email protected] \
  --start

The script reads API credentials from a .env file in the same directory. It uses listmonk's campaign template (ID 7) which has the site styling. I also wrapped the script in an LLM agent skill so I can ask the agent to send a campaign without remembering the exact flags.

Making it reproducible

I wanted the entire setup to be reproducible from committed files. If the OCI VM disappeared tomorrow, I should be able to run just deploy-stack oci listmonk and have everything back.

Here is what is committed to git:

FilePurpose
docker-compose.ymlListmonk and Postgres containers with all env vars
.env.exampleTemplate for secrets with every variable documented
static/email-templates/Custom system email templates
static/public/templates/Custom public page templates
campaign-template.htmlCampaign email template
scripts/create-campaign.shCampaign creation script
README.mdFull setup documentation

The only things not in git are the actual secret values, which go in .env.

The full subscriber lifecycle

Here is what happens end to end:

  1. A visitor subscribes through the form on /newsletter.
  2. listmonk sends an opt-in confirmation email styled like the site.
  3. The visitor clicks the confirmation link and lands on a site-styled confirmation page.
  4. The subscription is confirmed. The subscriber is now active.
  5. I write a campaign body, run the script, test, and start.
  6. The email goes out through SMTP2GO. The template wraps it in the site's design.
  7. If the subscriber wants to unsubscribe, the link takes them to a site-styled unsubscribe page. There is no listmonk branding anywhere.

Image unavailable

The confirmation page a subscriber sees after clicking the opt-in link
Opt-in confirmation page

What I learned

Self-hosting email is easier than it looks. listmonk does the heavy lifting. SMTP2GO handles delivery. Cloudflare Tunnel handles exposure. The only hard part was getting the templates right and dealing with CORS between the site and the newsletter subdomain.

A Vercel function solved CORS and spam in one place. Instead of configuring CORS headers on the listmonk server, I moved the subscription logic to a serverless function on the same domain. The function checks the Turnstile token server-side before listmonk ever sees the request, so both CORS and spam are handled at the same boundary. The function is free on Vercel's Hobby plan.

Make it reproducible from the start. I spent extra time making every config a file and every setting an environment variable. The first time I deploy this on a new VM, it will take one command. That is worth the upfront cost.

Keep the form simple. The footer form was noise. A dedicated page and a post-footer prompt are enough. The people who want to subscribe will find the way.

I send occasional updates about things I am building. Subscribe here.

Send me an email if you have thoughts or feedback. I read every message.

No results found.
Search is not available right now.