GO திசைGoDisai

Story 6 · listmonk

The Post Office in a Single Binary

“If every new reader makes your bill bigger, the tool is charging you for being successful.”

20 min read · by Syed Jafer K

1. The Invoice That Grew With the Learners

GoDisai teaches tech in Tamil. Backend engineering, system design, cloud, AI and DSA, through self-paced courses and live batches.

GoDisai had two kinds of email.

The first kind went out every Sunday morning. Meena, who looked after the learner community, wrote a short newsletter called GoDisai Weekly. It had the week's new sessions, the dates of the next live batch, and one small tip worth trying that day. People liked it. In January it had 9,000 readers. By October it had 84,000.

The second kind went out all day. When someone enrolled in a course, the platform sent a confirmation. When a payment went through, it sent a receipt. An hour before every live class, it sent a reminder with the meeting link. Priya, who ran the backend, had wired those up to a separate paid email API long ago and had not looked at it since.

Both services sent a bill. The newsletter tool charged by the number of subscribers, in tiers. On the first of November, Meena opened the invoice and saw that crossing 75,000 readers had pushed the plan into the next tier. The bill had nearly doubled since spring, and she had not sent a single extra email.

There was a second problem, and it worried Priya more. Every learner's name, city, enrolled courses and progress were being copied into a newsletter company's database in another country, so that Meena could write "Hi Revathi, you are three sessions away from finishing Docker". India's Digital Personal Data Protection Act was now law, and the company's lawyer had started asking where personal data went and who could see it.

Meena and Priya took the question to Karthik, the friend from chapter 3 who had stopped renewing certificates by hand.

Karthik listened, then asked one question. "Who sends more email in India than you, and does not pay per subscriber for it?"

Nobody answered, so he answered himself. "A stockbroker. Look at listmonk."


2. Where listmonk Came From

To understand listmonk, start with the company that needed it.

Zerodha is an Indian stockbroker, founded in 2010 in Bengaluru by Nithin and Nikhil Kamath. It grew into one of the largest brokers in the country by number of active clients, with millions of people trading through it.

A broker has to send a lot of email, and much of it is not optional. After every trading day, a client who traded must receive a contract note, a document listing every trade with its price, charges and taxes. Exchange and regulator rules say these must reach the client within a fixed time. Add account statements, margin alerts, account changes and product announcements, and a large broker sends many millions of emails a month.

Zerodha's technology team is known for two habits. It is small for the size of the business, and it prefers to run its own software instead of renting it. The team has written publicly about building and hosting most of its own systems, and about choosing free and open-source software wherever it can.

Paying a newsletter service by the subscriber, with millions of subscribers, made little sense for a company like that. So Kailash Nadh, Zerodha's CTO, wrote a mailing list manager himself. Work on it started in 2018. It was released as open source under the name listmonk, under the AGPL-3.0 licence. The early release notes said it had already been used at Zerodha for months, across hundreds of campaigns and tens of millions of emails.

The design followed from the problem.

  • One binary. listmonk is written in Go and ships as a single program. There is no separate queue server, no worker fleet and no runtime to install.
  • PostgreSQL is the only dependency. Subscribers, lists, campaigns and statistics all live in one Postgres database.
  • Bring your own sender. listmonk does not deliver mail to inboxes itself. It hands messages to one or more SMTP servers you choose, such as Amazon SES, Postmark, or your own mail server, and controls how fast it does that.
  • Small footprint. The project's own benchmark describes a campaign of more than seven million emails using a fraction of one CPU core and a peak of about 57 MB of memory.

Karthik liked to tell the rest of the story this way. A broker built it for its own contract notes and newsletters, then gave it away. Now a Tamil tech-learning platform could run the same software on a small VPS.


3. What listmonk Does

listmonk is a newsletter tool and a transactional mail tool in one.

Part What it gives you
Lists Public or private lists, with single or double opt-in
Subscribers Name, email, status, and free-form JSON attributes for anything else
Segmentation Pick subscribers with a SQL expression on their fields and attributes
Campaigns Write once, send to one or more lists, with scheduling and test sends
Templates Go templates, with a visual builder, rich text, Markdown, raw HTML or plain text
Transactional API Send one templated message to one person from your own app
Messengers Send through SMTP, or through webhooks for SMS, WhatsApp and push
Analytics Views, clicks, bounces and top links per campaign
Privacy Subscribers can export or wipe their own data
Access Users, roles, scoped API tokens, and single sign-on through OIDC

Meena read the table twice. "So the newsletter and the class reminders can come out of the same place?"

"Same place, same templates, same unsubscribe list," Karthik said. "And the learners' data stays on your server."


4. Before You Start

Karthik wrote the checklist on the whiteboard.

  1. A Linux server with Docker and the Docker Compose plugin. The small VPS that already ran GoDisai's internal tools had room, since listmonk uses very little memory.
  2. A domain for the admin panel and the subscription pages. They chose mail.godisai.example.
  3. Caddy in front for HTTPS, as in chapter 3.
  4. An SMTP provider that is allowed to send bulk mail. This is the part people skip.

He underlined the last one twice.

"listmonk is the post office counter. It sorts the letters and decides when each one goes out. It is not the postman. Sending mail straight from a VPS to Gmail and Outlook does not work well, because a new server's IP address has no reputation and most cloud providers block outgoing mail on port 25. Use a sending service. It costs per email sent, usually a few paise each, and nothing per subscriber."

They picked Amazon SES, because GoDisai already had an AWS account. Any SMTP provider would have worked the same way.


5. Getting the Sender Ready First

Before installing anything, Priya set up the sending domain. It is the slowest step, because DNS and the provider's approval both take time, so it is worth doing first.

They decided newsletters would come from weekly@godisai.example, and enrolment, payment and class emails from classes@godisai.example.

Three DNS records decide whether those emails reach the inbox or the spam folder.

Record What it says
SPF Which servers are allowed to send mail for this domain
DKIM A signature on every email, checked against a public key published in DNS
DMARC What receivers should do when SPF or DKIM fails, and where to send reports

SES gave Priya three DKIM records to add. She added an SPF record that included SES, and a DMARC record that started gently.

godisai.example.          TXT  "v=spf1 include:amazonses.com ~all"
_dmarc.godisai.example.   TXT  "v=DMARC1; p=none; rua=mailto:dmarc@godisai.example"

p=none means "report problems but do not reject anything yet". After a few weeks of clean reports, they would change it to quarantine.

New SES accounts start in a sandbox, where they can only send to addresses that have been verified. Priya requested production access the same day and explained the use case, a learner newsletter with double opt-in plus enrolment and class emails. Approval came two days later.

Large mailbox providers now expect bulk senders to have SPF, DKIM and DMARC in place, and to offer one-click unsubscribe. listmonk handles the unsubscribe part. The DNS part is yours.


6. The Compose File

On the server, Priya made a folder and downloaded the project's own compose file.

mkdir -p ~/listmonk && cd ~/listmonk
curl -LO https://github.com/knadh/listmonk/raw/master/docker-compose.yml

The file defines two services, app (listmonk) and db (PostgreSQL), with a volume for the database. She made three changes before starting anything.

She bound the port to localhost. The file publishes port 9000 on every interface. She changed it so only Caddy could reach it.

    ports:
      - "127.0.0.1:9000:9000"

She changed the database password. The sample file has a placeholder password in two places, once for Postgres and once for listmonk. She replaced both with a long random value from openssl rand -hex 24.

She pinned the image. The sample uses a moving tag. She replaced it with the exact release she was installing, so that a restart could never pull a new version by surprise.

The compose file's app service already runs listmonk's install and upgrade steps on start, in a safe form. --install --idempotent creates the tables only if they do not exist yet, and --upgrade applies any database changes a new version needs. That one line is why upgrades later would be simple.


7. The First Start and the First Admin

On the very first start, listmonk can create the Super Admin account from two environment variables.

LISTMONK_ADMIN_USER=priya LISTMONK_ADMIN_PASSWORD='a-long-password-from-the-password-manager' docker compose up -d

Those variables are read only on the first install. After that, they do nothing, and the account is managed in the web interface. Priya generated the password in the team's password manager, used it once here, and then cleared her shell history so it did not sit on the server.

docker compose ps
docker compose logs -f app

The logs ended with a line saying the HTTP server had started on port 9000. A request from the server itself answered.

curl -s http://127.0.0.1:9000/health

If you skip the environment variables, listmonk asks you to create the Super Admin in the browser the first time you open it. That works too, but then do it immediately after Caddy is ready, before anyone else knows the address.


8. Putting Caddy in Front

The Caddy block was the same three lines as every other service on the box.

mail.godisai.example {
    reverse_proxy 127.0.0.1:9000
}
sudo systemctl reload caddy

Priya signed in at https://mail.godisai.example/admin, opened Settings, and set the Root URL to https://mail.godisai.example.

This setting matters more than it looks. listmonk uses the root URL to build every link it puts into an email, including the unsubscribe link, the "view in browser" link, the tracking pixel and the click-tracking redirects. If it is left at http://localhost:9000, every one of those links in every email is broken.


9. Connecting the SMTP Server

Under Settings, SMTP, she entered the SES SMTP details.

Field Value
Host The SES SMTP endpoint for their region
Port 587
Auth protocol Login
Username and password The SES SMTP credentials, created for this purpose only
TLS STARTTLS
Max connections 10

listmonk can hold several SMTP servers at once and spread messages across them. A second provider is a cheap way to keep sending if the first one has a bad day.

The Test connection button sent a test email to her own address. It arrived in the inbox, not in spam. She opened the message headers and checked for three words, spf=pass, dkim=pass and dmarc=pass.

Then she set the speed limits under Settings, Performance.

  • Concurrency is how many messages listmonk sends in parallel.
  • Message rate is how many messages per second each worker may send.
  • Sliding window caps the total sent within a period, for example 10,000 per hour.

SES had given them a sending limit of 14 emails per second. She set listmonk to stay just under it. Sending faster than your provider allows only produces errors and bounces, and bounces hurt your reputation.


10. Moving 84,000 Readers Across

The old newsletter tool could export subscribers as CSV. Meena exported them, and Priya joined the file with what the GoDisai platform already knew about each learner.

listmonk imports a CSV with email, name and an optional attributes column holding JSON.

email,name,attributes
revathi@example.com,Revathi,"{""city"": ""Chennai"", ""courses"": [""docker"", ""redis""], ""sessions_done"": 23}"
arjun@example.com,Arjun,"{""city"": ""Madurai"", ""courses"": [""dsa""], ""sessions_done"": 4}"

The attributes are where listmonk is more flexible than most tools. There is no fixed set of custom fields. Each subscriber carries a JSON object, and you can store whatever you need.

Before importing, Meena and Priya made two decisions that mattered more than any setting.

They imported only people who had actually opted in. Signing up for a course is not the same as asking for a weekly newsletter. The old tool marked who had subscribed and who had been added from an old sign-up export years ago. They left the second group out. Sending a newsletter to people who never asked for it is how a new sending domain gets its reputation ruined in a week.

They imported the unsubscribed and bounced addresses too, as blocklisted. If those addresses were left out, someone might add them again later from an old file. Blocklisted subscribers never receive campaigns.

Under Subscribers, Import, she chose the GoDisai Weekly list, picked the file, and set the mode to subscribe. For the second file she used blocklist mode. The import ran in the background and finished in under a minute.

The new list was set to double opt-in. From now on, anyone signing up on the website would get an email with a confirmation link, and would only start receiving the newsletter after clicking it. That one setting keeps fake and mistyped addresses out of the list.


11. Asking the List a Question in SQL

The next Sunday, Meena wanted to announce a new Kubernetes live batch only to learners who had finished a good part of the Docker course and had not yet enrolled in Kubernetes.

In most newsletter tools this means building a segment in a form. In listmonk, the Query box on the subscribers page takes a piece of PostgreSQL.

subscribers.attribs->'courses' ? 'docker'
  AND NOT (subscribers.attribs->'courses' ? 'kubernetes')
  AND (subscribers.attribs->>'sessions_done')::INT >= 10

The -> operator reads the courses list from the JSON attributes, and ? checks whether a value is in that list. The ->> operator reads a value as text, and ::INT turns sessions_done into a number so it can be compared. The page showed 3,214 matching subscribers.

She saved those people into a new list called Kubernetes batch invite with the bulk action, and used that list for the campaign.

Priya added one more query to her own notes, for later.

EXISTS (
  SELECT 1 FROM campaign_views
  WHERE campaign_views.subscriber_id = subscribers.id
    AND campaign_views.campaign_id = 12
)

That finds everyone who opened campaign 12. Because the data is in their own Postgres, any question they could write in SQL, they could ask.


12. Templates That Know Who They Are Talking To

listmonk has two kinds of template. A campaign template is the outer layout, with the GO திசை wordmark, the footer and the unsubscribe link, and each campaign's content goes inside it. A transactional template is a complete message used by the API.

Both are Go templates. Meena's newsletter opened like this.

<p>Vanakkam {{ .Subscriber.FirstName }},</p>

{{ if ge (.Subscriber.Attribs.sessions_done) 20.0 }}
<p>You have finished more than twenty sessions. The capstone projects are waiting for you.</p>
{{ else }}
<p>This week's sessions are short. One a day is enough to keep your streak going.</p>
{{ end }}

JSON numbers arrive in Go templates as decimals, which is why the comparison uses 20.0 and not 20.

The footer of the campaign template carried the parts that must be in every bulk email.

<p>
  You get this because you subscribed to GoDisai Weekly.
  <a href="{{ UnsubscribeURL }}">Unsubscribe</a>
  or <a href="{{ MessageURL }}">view this in your browser</a>.
</p>
{{ TrackView }}

UnsubscribeURL builds a personal unsubscribe link for each reader. listmonk also adds the List-Unsubscribe headers that let mail apps show their own one-click unsubscribe button. TrackView adds the tiny image that counts opens. Links can be tracked by writing {{ TrackLink "https://godisai.example/courses" }} instead of the plain address, or with the @TrackLink shorthand after a URL.

Meena preferred to write the weekly text itself in the rich text editor, and only Priya touched the HTML templates. That split worked well.


13. The First Campaign

The first GoDisai Weekly from their own server went out on a Sunday at 7 in the morning.

Meena did it in this order, which became the team's checklist.

  1. Create the campaign, choose the list and the template, and write the content.
  2. Send a test to three people on the team, one on Gmail, one on Outlook and one on a phone.
  3. Check the links, the images, the Tamil text and the unsubscribe link in each test.
  4. Schedule the campaign for 7:00, rather than pressing start at 6:58 with a nervous finger.

listmonk sent the 84,000 emails at the rate Priya had set. The campaign page showed a progress bar, the rate per minute, and later the views, clicks and bounces. The container's memory use never went above a few dozen megabytes. Meena checked her inbox, her mother's inbox and the spam folder of her old college account. It was in the inbox everywhere, and the Tamil text showed correctly in every one.

The next month's invoice from SES was for the number of emails actually sent. It came to a small fraction of the old newsletter bill.


14. Listening for Bounces

Some addresses are dead. Some mailboxes are full. Some people mark a newsletter as spam instead of unsubscribing. A sender who keeps mailing those addresses looks careless to mailbox providers, and its reputation drops.

listmonk can learn about bounces in two ways. It can check a mailbox that receives bounce messages over POP3, or it can receive webhooks from the sending service. For SES, Priya created an SNS topic for bounces and complaints and pointed it at listmonk's SES webhook address under her domain, then turned on SES bounce handling under Settings, Bounces.

She set the actions.

Bounce type Count Action
Hard bounce (address does not exist) 1 Blocklist
Soft bounce (mailbox full, server busy) 3 Unsubscribe from lists
Complaint (marked as spam) 1 Blocklist

After the first campaign, 612 addresses bounced hard and were blocklisted without anyone touching them. Most were college email addresses that had been closed when the learners graduated. The old tool had kept mailing them for years.


15. Class Emails Through the Same Door

The enrolment, payment and class emails were Priya's part of the project.

She created a transactional template called Class reminder. Data sent by the platform is available inside it under .Tx.Data.

<p>Hi {{ .Subscriber.FirstName }},</p>
<p>Your live class <strong>{{ .Tx.Data.title }}</strong> from the {{ .Tx.Data.course }} batch starts at {{ .Tx.Data.starts_at }}.</p>
<p><a href="{{ .Tx.Data.meet_url }}">Join the class</a></p>
<p>Before you join, try these:</p>
<ul>
  {{ range .Tx.Data.prepare }}
  <li>{{ . }}</li>
  {{ end }}
</ul>

Then she created an API user under Users with a role that allowed only sending transactional messages. The GoDisai platform called listmonk an hour before each live class, once for every enrolled learner.

curl -u "godisai_platform:$LISTMONK_TOKEN" \
  -X POST "https://mail.godisai.example/api/tx" \
  -H 'Content-Type: application/json' \
  -d '{
        "subscriber_email": "revathi@example.com",
        "subscriber_mode": "fallback",
        "template_id": 4,
        "from_email": "GoDisai Classes <classes@godisai.example>",
        "data": {
          "title": "Kubernetes Deployments",
          "course": "Docker and Kubernetes",
          "starts_at": "7:00 PM IST",
          "meet_url": "https://meet.example/abc-defg-hij",
          "prepare": ["Start minikube", "Pull the chapter 19 repository"]
        }
      }'

subscriber_mode set to fallback means listmonk uses the subscriber's saved name if the address is already known, and still sends the email if it is not. Learners who never joined the newsletter get their class reminders and receipts, and are not added to any list.

Enrolment confirmations and payment receipts used two more templates and the same API call.

Two rules kept these emails healthy.

  • Class and payment emails went through a separate SMTP server entry, using a different SES configuration set, so a bad newsletter week could never delay a reminder for a class starting in an hour.
  • The API token was scoped. If it leaked, it could send class emails, but it could not read or export the subscriber list.

The old transactional email service was switched off at the end of the month.


16. Backups and the Bell

Every subscriber, list, template and statistic now lived in one PostgreSQL database. Priya added it to the nightly backup she already ran for the platform's other services.

#!/usr/bin/env bash
# /home/priya/bin/listmonk-backup.sh
set -euo pipefail
cd /home/priya/listmonk
STAMP=$(date +%F)
docker compose exec -T db pg_dump -U listmonk listmonk | gzip > /backups/listmonk-$STAMP.sql.gz
find /backups -name 'listmonk-*.sql.gz' -mtime +14 -delete

The rclone job from chapter 4 copied /backups off the server, encrypted. If the script failed, ntfy from chapter 1 rang Priya's phone.

This backup now held the personal data of 84,000 learners. That is exactly why it was encrypted before it left the server, and why only two people had the key.


17. Upgrading

Upgrades became a short, planned routine.

  1. Read the release notes for anything marked as breaking.
  2. Run the backup script by hand.
  3. Change the pinned image tag in the compose file to the new version.
  4. docker compose pull && docker compose up -d.
  5. Watch the logs. The --upgrade step in the compose command applies the new database changes on start.
  6. Sign in and send one test email.

listmonk also shows a notice in the admin panel when a new version is out, so nobody had to remember to check.


18. When Things Go Wrong

Priya added a page to the team wiki.

Symptom Likely cause
Links in emails point to localhost:9000 The Root URL in Settings was never changed
Test email lands in spam SPF, DKIM or DMARC is missing or failing. Check the message headers.
Campaign sends very slowly Message rate or sliding window set lower than needed
Many throttling errors in the logs Sending faster than the SMTP provider allows. Lower the rate.
Only verified addresses receive mail The SES account is still in the sandbox
Bounces never show up The SNS or webhook address is wrong, or bounce processing is off
New signups never arrive in the list Double opt-in emails are failing to send. Check SMTP.
401 from the transactional API Wrong API user or token, or the role lacks permission

For anything else, docker compose logs app usually names the problem.


19. Epilogue: The Sunday After Diwali

On the Sunday after Diwali, Meena sent a special edition. A thank-you note to every learner, the dates of the January live batches, and a short list of the sessions each reader had left in the course they had started, built in the template from their own attributes.

It went to 91,000 people. It cost about what one person spends on a dinner out. The emails sat in the inboxes of people who had asked for them, and the 400 who wanted to leave left with one click.

That evening the company's lawyer asked Priya the question again. "Where does learner data go when we send a newsletter?"

"Nowhere," Priya said. "It stays on our server. Only the email itself leaves, one at a time, to the person it is for."

Karthik, who had come in for the Diwali sweets, told the story one more time. A broker in Bengaluru had needed to send contract notes to millions of people without renting someone else's post office, so its CTO built one and gave it away. GoDisai had simply picked it up.


Quick Reference Cheat-Sheet

I want to Do this
Get the compose file curl -LO https://github.com/knadh/listmonk/raw/master/docker-compose.yml
Create the first admin LISTMONK_ADMIN_USER=... LISTMONK_ADMIN_PASSWORD=... docker compose up -d on the first start
Keep it private Bind the port to 127.0.0.1:9000 and put Caddy in front
Fix broken links in emails Set the Root URL in Settings to the public HTTPS address
Reach inboxes Use an SMTP sending service and publish SPF, DKIM and DMARC
Avoid provider errors Set concurrency, message rate and sliding window under your provider's limit
Keep fake addresses out Use double opt-in lists
Store custom fields Put them in the subscriber's JSON attributes
Segment subscribers subscribers.attribs->'courses' ? 'docker' in the Query box
Personalise a message {{ .Subscriber.FirstName }} and {{ .Subscriber.Attribs.sessions_done }}
Include unsubscribe {{ UnsubscribeURL }} in every campaign template
Send a class email from code POST /api/tx with template_id, subscriber_email and data
Handle bounces Turn on bounce processing and blocklist hard bounces and complaints
Back it up pg_dump the database nightly and encrypt it before it leaves the server
Upgrade Back up, change the pinned tag, docker compose pull && docker compose up -d

Key takeaways

  1. listmonk came from real need. Zerodha's CTO wrote it to send a broker's volume of email without per-subscriber pricing, and released it as open source.
  2. One binary and one Postgres. That is the whole system, and it runs comfortably on a small VPS.
  3. listmonk is the counter, not the postman. It sends through SMTP providers you choose, so delivery depends on your DNS records and your sender's reputation.
  4. Clean lists matter more than settings. Import only people who opted in, keep unsubscribed people blocklisted, use double opt-in, and process bounces.
  5. Attributes and SQL make segmentation simple. Any question about your learners can be written as a query.
  6. Newsletters and class emails can share one tool. Use the transactional API with a scoped token and a separate SMTP entry.

Self-hosting your mailing list means your learners' data stays with you, and your bill depends on how much you send, not on how many people like you.