Self-Host a Newsletter with Listmonk for Under €8 a Month

Run newsletters for several sites with Listmonk, Brevo's free SMTP and a 1 GB VPS: double opt-in, welcome emails and weekly digests for under €8 a month.

Chalk diagram on a blackboard: four website windows with envelopes feeding one server box, which relays to a grid of envelopes, with a ruler and compass below

You can run a full newsletter setup for several websites (double opt-in, branded confirmation emails, welcome sequences and weekly digests) for under €8 a month. The recipe is Listmonk on a 1 GB VPS, Caddy for HTTPS, and Brevo’s free SMTP plan for sending. If you already have an always-on homelab box, you can drop the VPS and run the whole newsletter stack for free.

I moved the newsletters for four of my sites (this one included) off MailerLite to exactly this stack. MailerLite bills each account separately, and on the free plan its API cannot create campaigns from your own HTML, so automating a weekly digest meant paying for every site. This guide covers the setup I ended up with and the traps I hit on the way.

What a Newsletter Actually Costs

Hosted email platforms charge per subscriber and per account. Self-hosting splits the job in two: software that manages lists (Listmonk, free and open source) and a relay that delivers the mail (an SMTP provider, free at low volume).

OptionFree tierWhen you start paying
MailerLite250 subscribers, 2,500 emails/month, 3 automationsPaid plans from $12/month, per account
Brevo (as SMTP relay)300 emails/dayStarter from about $9/month
Amazon SESCovered by AWS Free Tier credits for new accounts$0.10 per 1,000 emails (pay as you go)
Listmonk + Brevo on a VPSSoftware free, Brevo freeOnly the VPS, €6.49/month + VAT
Listmonk + Brevo on a homelabFully freeElectricity

The self-hosted line stays flat no matter how many sites you run. One Listmonk instance holds a separate list per site, and all of them share the same server and the same relay.

The Architecture

The whole stack is four containers on one small server:

  • Listmonk stores subscribers, lists, templates and campaigns, and exposes a REST API.
  • Postgres is Listmonk’s database.
  • Caddy terminates HTTPS for a news. subdomain on each site and gets certificates automatically.
  • A small Python signup service takes form posts from the websites and sends branded confirmation emails (more on why below).

Brevo does the actual delivery over SMTP. Listmonk never talks to recipient mail servers directly, so the VPS IP address has no effect on deliverability, and you don’t need to open port 25.

On my server the four containers use about 100 MB of RAM combined while idle:

listmonk-app-1      37 MiB
listmonk-caddy-1    31 MiB
listmonk-db-1       19 MiB
listmonk-signup-1   13 MiB

A 1 vCPU / 1 GB VPS (Hetzner CPX02 in my case: 20 GB NVMe, 20 TB traffic) handles this with room to spare. CPX02 costs €5.99 a month plus €0.50 for the public IPv4 address, which comes to €7.98 with Slovak VAT. Two better options exist at the same or lower price. The ARM CAX11 costs the same €5.99 but has 2 vCPUs and 4 GB of RAM, and every image in this stack runs on ARM. The x86 CX23 (2 vCPUs, 4 GB) costs €5.49, but it was out of stock in every EU location when I set this up. Take whichever is available when you read this. The stack has no CPU-specific requirements, and moving it later is a pg_dump, a copy of the compose folder and a DNS change. Add a 2 GB swap file so a Postgres vacuum or a Docker image pull can’t push it into the out-of-memory killer.

VPS or Homelab?

My first plan was to run Listmonk on my homelab machine. It had no public ingress, and that matters more for a newsletter than for most self-hosted apps:

  • Signup forms must always work. If the server is down when a reader submits the form, you lose that subscriber.
  • Confirmation and unsubscribe links must always work. A broken unsubscribe link is a fast route to spam complaints.

If your homelab box runs 24/7 and you’re comfortable with a Cloudflare Tunnel, the free route works fine. Expose only the public paths (signup, confirmation, unsubscribe) and keep the admin UI on your LAN. If your homelab reboots for updates or sits on a laptop, a VPS for a few euros a month removes that whole class of problem.

Docker Compose for Listmonk

This is the compose file running on my server, with credentials moved into .env files:

services:
  db:
    image: postgres:17.6-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: listmonk
      POSTGRES_DB: listmonk
    env_file: [.env]          # POSTGRES_PASSWORD
    volumes:
      - pg:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U listmonk"]
      interval: 10s
      timeout: 5s
      retries: 6
    shm_size: 128mb

  app:
    image: listmonk/listmonk:v6.2.0
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    ports:
      - "127.0.0.1:9000:9000"
    env_file: [.env]          # LISTMONK_db__password, LISTMONK_ADMIN_* for first run
    environment:
      TZ: Europe/Bratislava
      LISTMONK_app__address: 0.0.0.0:9000
      LISTMONK_db__host: db
      LISTMONK_db__port: 5432
      LISTMONK_db__user: listmonk
      LISTMONK_db__database: listmonk
      LISTMONK_db__ssl_mode: disable
    command: [sh, -c, "./listmonk --install --idempotent --yes --config '' && ./listmonk --upgrade --yes --config '' && ./listmonk --config ''"]
    volumes:
      - uploads:/listmonk/uploads

  caddy:
    image: caddy:2.10.2-alpine
    restart: unless-stopped
    depends_on: [app]
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config

volumes:
  pg:
  uploads:
  caddy_data:
  caddy_config:

Two details are worth copying:

  • The startup command runs --install --idempotent and then --upgrade before starting Listmonk. A fresh volume gets the schema created, and bumping the image tag applies database migrations on the next docker compose up -d.
  • Port 9000 is bound to 127.0.0.1. Only Caddy reaches Listmonk; nothing on the public interface does.

Pin exact image tags (v6.2.0, 17.6-alpine) rather than latest. A newsletter server is something you want to upgrade on purpose, not by accident during a reboot.

Caddy: One Admin Host, Many Public Hosts

Each site gets a news. subdomain (an A record pointing at the VPS), so confirmation and unsubscribe links carry the site’s own domain. The admin UI and the API should only be reachable on one of them:

{
	email you@example.com
}

(listmonk) {
	encode gzip
	header -Server
	handle /newsletter/* {
		reverse_proxy signup:8000
	}
	handle {
		reverse_proxy app:9000
	}
}

# Admin UI lives on one host only; the others serve subscriber pages and the signup endpoint.
(public_only) {
	@private {
		path /admin /admin/* /api/*
		not path /api/public/*
	}
	respond @private 404
}

news.linuxcore.dev {
	import listmonk
}

news.site-two.com, news.site-three.eu {
	import public_only
	import listmonk
}

If you use Cloudflare for DNS, set these records to DNS only (grey cloud) so Caddy can complete the ACME challenge itself.

Listmonk has a single root URL setting, so every unsubscribe link would point at the admin host by default. I fixed that per site in the email templates with a Sprig replace on {{ UnsubscribeURL }}, swapping the root host for that site’s news. host.

Sending Through Brevo’s Free SMTP

In Listmonk go to Settings → SMTP and add Brevo’s relay (smtp-relay.brevo.com, port 587, STARTTLS) with the SMTP login and key from Brevo’s dashboard. Before the first real send:

  1. Verify each sending domain in Brevo and add the SPF, DKIM and DMARC records it gives you. Without them, Gmail and Outlook will junk your mail or reject it.
  2. Set a sliding window under Settings → Performance. Brevo’s free plan caps you at 300 emails per day. I run a window of 290 messages per 24 hours, which leaves room for confirmation emails during a big send.
  3. Send a test to a Gmail address and open “Show original” to confirm SPF, DKIM and DMARC all say PASS.

With the sliding window on, a campaign to 1,000 subscribers simply takes about four days to drain instead of failing halfway. Once that delay starts to hurt, the cheapest upgrade is pointing Listmonk at Amazon SES at $0.10 per 1,000 emails. Swapping the SMTP server is a settings change; nothing else in the stack moves.

Double Opt-In: Why I Wrote a Signup Service

Listmonk supports double opt-in lists out of the box. I still replaced its confirmation flow with a small Python service, for two reasons I only found after going live:

  • The opt-in email is global. Its sender and subject are instance-wide settings, so a reader who signs up on a recipe site gets a confirmation “from” your Linux blog. That looks like phishing.
  • The confirmation page needs a second click. Listmonk’s opt-in link opens a page with a confirm button. Readers clicked the link in the email, saw a page, closed it and never confirmed. I missed that button myself on my first test signup.

The fix: turn off Listmonk’s own opt-in mail (app.send_optin_confirmation = false) and let a signup endpoint do three things. It adds the subscriber as unconfirmed, sends a branded confirmation through Listmonk’s transactional API, and serves a confirmation page that completes the opt-in by itself:

sub = lm.call("POST", "/api/subscribers", {
    "email": email, "name": email.split("@")[0], "status": "enabled",
    "lists": [site["list_id"]], "preconfirm_subscriptions": False,
})
optin = f"{site['news']}/newsletter/confirm?" + urlencode({"s": sub["uuid"], "l": list_uuid})
lm.call("POST", "/api/tx", {
    "subscriber_email": email, "template_id": confirm_template[site_key],
    "from_email": site["from"], "subject": f"Confirm your {site['name']} subscription",
    "content_type": "html", "data": {"optin_url": optin},
})

The confirmation endpoint then posts to Listmonk’s own /subscription/optin/{uuid} handler. All double opt-in rules stay inside Listmonk; the service only changes how the email and the page look. The service is the Python standard library only (http.server plus urllib), so it runs in a bare python:3.13-alpine image without a requirements.txt.

Add two cheap defenses to any public signup endpoint:

  • A honeypot field (a hidden website input) that bots fill and humans don’t.
  • A per-site rate limit so a script can’t use your form to mail-bomb strangers through your Brevo quota.

Welcome Sequences Without an Automation Builder

Listmonk has no built-in drip automation. A cron job every 10 minutes covers it: read confirmed subscribers per list from /api/subscribers, work out how many days each has been subscribed, and send the next due email through /api/tx. A small JSON state file records what each subscriber already received.

One trap: an unsubscribe link only works inside a campaign, because Listmonk needs a campaign to know which lists the reader is leaving. Transactional emails have no campaign. I create one draft “anchor” campaign per list that is never sent, and build the welcome emails’ unsubscribe links against its UUID.

Weekly Digests From RSS

Each site’s digest is a cron job that reads the site’s RSS feed and keeps the posts published since the last send. If there are none, it exits. The very first run only records the current time, so new subscribers never get the whole back catalogue. Otherwise it renders them into the site’s campaign template, creates the campaign through /api/campaigns, and starts it:

# /etc/crontab style, CRON_TZ=Europe/Bratislava
0 8 * * 5 cd /opt/listmonk && docker compose run --rm --no-deps -T signup python digest.py linuxcore

Running the scripts through docker compose run on the signup image means the host needs nothing beyond Docker, and the scripts reach Listmonk on the internal network at http://app:9000.

Backups

Your subscriber list is the asset. Losing it means losing every confirmed opt-in. A nightly pg_dump with 14 days of retention covers it:

#!/bin/sh
set -e
umask 077
cd /opt/listmonk
f=backups/listmonk-$(date +%F).sql.gz
docker compose exec -T db pg_dump -U listmonk listmonk | gzip > "$f.tmp" && mv "$f.tmp" "$f"
find backups -type f -mtime +14 -delete

Then pull those files to a second machine. A backup that lives only on the VPS disappears with the VPS. My laptop pulls them every morning at 04:45 with rsync over SSH.

Migrating Subscribers From MailerLite

Export each list from MailerLite as CSV and import it through Listmonk’s API with preconfirm_subscriptions: true. These people already confirmed with you once, and making them confirm again loses a large share of them. Keep the original subscribe date in the subscriber’s attributes so a welcome sequence knows who has already received which email. Pause the welcome cron while you import, or the new subscribers all get email one at once.

The Settings API Trap

If you script Listmonk’s configuration, know this before you run it: GET /api/settings returns every password field masked as •••. If you PUT that object back unchanged, Listmonk saves the mask as your real SMTP password, and every send fails with an authentication error. Blank the password fields ("") before any settings PUT; Listmonk keeps the stored value when the field is empty. I learned this the hard way and had to re-enter the Brevo key.

When Self-Hosting Is the Wrong Call

This setup suits technical site owners running one or more small lists. It’s the wrong choice if:

  • You need a visual automation builder or e-commerce triggers. Listmonk is lists, templates and campaigns, and anything beyond that is your own code.
  • You send to tens of thousands daily and want someone else to own deliverability monitoring.
  • Nobody will patch the server. An unpatched public VPS holding a list of email addresses is a GDPR problem waiting to happen. Keep SSH key-only and root login off, and allow only 22, 80 and 443 through the firewall.

For everyone else, the stack costs under €8 a month, or nothing on an existing homelab. It runs four newsletters from one place, and you own the list outright.

Frequently Asked Questions

Can I run a newsletter completely free?
Yes, if you already have an always-on homelab machine. Run Listmonk there, expose only the signup and subscriber pages through a Cloudflare Tunnel, and send through Brevo's free SMTP plan, which allows 300 emails per day. The only cost is electricity.
How much does a self-hosted Listmonk newsletter cost on a VPS?
Under €8 a month including VAT. Listmonk, Postgres, Caddy and a small Python signup service use roughly 100 MB of RAM together, so a 1 vCPU / 1 GB VPS such as Hetzner's CPX02 is enough. Brevo's free plan covers sending up to 300 emails per day. Hetzner's CX23 costs €5.49 when it is in stock.
Does Listmonk send email itself?
No. Listmonk manages subscribers, lists, templates and campaigns, then hands each message to an SMTP server. Using a relay like Brevo or Amazon SES keeps your VPS IP out of the deliverability picture.
Does Listmonk support welcome email sequences?
Listmonk has no built-in drip automation. A short cron script that reads confirmed subscribers from the Listmonk API and sends each step through the transactional API (/api/tx) does the same job.
What happens when I outgrow Brevo's 300 emails per day?
Listmonk's sliding window spreads a campaign across several days. When that gets too slow, point Listmonk at Amazon SES, which costs $0.10 per 1,000 emails without a plan, or a paid Brevo plan.

Get notified when new articles and designs land:

No spam. Unsubscribe any time.

Sergej Voronko
Sergej Voronko
SAP Basis · Senior Operations Manager · Linux infrastructure engineer
About the author →

[discussion]

Comments are powered by Giscus — backed by GitHub Discussions. Sign in with GitHub to join the conversation.