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).
| Option | Free tier | When you start paying |
|---|---|---|
| MailerLite | 250 subscribers, 2,500 emails/month, 3 automations | Paid plans from $12/month, per account |
| Brevo (as SMTP relay) | 300 emails/day | Starter from about $9/month |
| Amazon SES | Covered by AWS Free Tier credits for new accounts | $0.10 per 1,000 emails (pay as you go) |
| Listmonk + Brevo on a VPS | Software free, Brevo free | Only the VPS, €6.49/month + VAT |
| Listmonk + Brevo on a homelab | Fully free | Electricity |
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 --idempotentand then--upgradebefore starting Listmonk. A fresh volume gets the schema created, and bumping the image tag applies database migrations on the nextdocker 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:
- 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.
- 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.
- 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
websiteinput) 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.
[discussion]
Comments are powered by Giscus — backed by GitHub Discussions. Sign in with GitHub to join the conversation.