Email is still one of the most useful owned channels on the web.
It is also one of the easiest channels to outsource completely: subscribers in one SaaS, forms in another, templates somewhere else, and sending reputation hidden behind a dashboard you do not control.
listmonk takes the opposite path. It is a self-hosted newsletter and mailing list manager, written in Go, shipped as a single binary, and backed by PostgreSQL.
What is listmonk?
listmonk is a self-hosted app for newsletters, mailing lists, and campaign delivery.
It gives you the pieces you expect from serious mailing-list software:
- Subscriber management.
- Public and private lists.
- Single and double opt-in.
- Subscriber attributes as JSON.
- Query-based segmentation.
- Campaign drafts, schedules, pauses, resumes, cancellations, and archives.
- Rich text, HTML, plain text, Markdown, and visual campaign content.
- Reusable templates with Go template expressions.
- Transactional email API.
- Media uploads and attachments.
- Link click tracking.
- Open tracking pixels, with privacy controls.
- Bounce processing.
- Public subscription pages.
- Campaign archives and RSS feed.
- User roles, list roles, API users, and granular permissions.
- OIDC SSO.
- Custom HTTP messengers for non-email delivery.
That makes listmonk useful when you want newsletter software, but you do not want your subscriber database locked into a hosted email-marketing platform.
Tech Overview
The backend is Go.
The app uses Echo for HTTP routing, PostgreSQL for data, sqlx and named SQL queries for database access, koanf for configuration, Go templates plus Sprig for message rendering, smtppool for SMTP delivery, POP3 and provider webhooks for bounces, and embedded static assets for release builds.
The frontend dashboard is Vue 2 with Buefy, Bulma, Vite, TinyMCE, CodeMirror, Chart.js, Vue Router, Vuex, and vue-i18n.
The source tree is easy to map:
cmd/contains HTTP handlers, install/upgrade commands, auth, campaigns, subscribers, media, bounces, users, and settings.internal/core/owns the business operations.internal/manager/runs the campaign scheduler, batching, rendering, rate limiting, and delivery queue.internal/messenger/contains SMTP and HTTP postback messengers.internal/bounce/handles POP3 and provider webhook bounces.internal/subimporter/handles bulk subscriber imports.queries/contains named SQL queries.schema.sqldefines the PostgreSQL schema.frontend/contains the admin dashboard.
The data model is built around subscribers, lists, subscriber-list relationships, campaigns, campaign-list relationships, templates, media, tracked links, link clicks, bounces, roles, users, sessions, and settings.
Why Self-Host listmonk?
Self-hosting listmonk makes sense when your subscriber list is part of your infrastructure.
Good fits:
- A blog or publication newsletter.
- Product update lists.
- Open-source project announcements.
- Community mailing lists.
- Internal announcement lists.
- Subscriber sync from a CRM or app database.
- Transactional messages through an API.
- SMS or push campaigns through custom messengers.
The tradeoff is email responsibility. listmonk is the campaign manager, not a magic deliverability service. You still need a reliable SMTP provider or messenger backend, clean DNS records, bounce handling, unsubscribe compliance, and careful list hygiene.
Self-Hosting listmonk with Docker
Pre-Requisites - Docker
Install Docker on your system before proceeding:
- Linux: Official Docker Engine install guide
- Windows / Mac: Docker Desktop
Verify installation: docker --version && docker compose version
The official install path is simple: run listmonk with PostgreSQL, then visit the web UI on port 9000.
The upstream compose file is convenient, but it defaults the Postgres username, password, and database to listmonk. For a reusable homelab config, require explicit .env values instead.
services:
db:
image: postgres:17-alpine
container_name: listmonk-db
restart: unless-stopped
environment:
POSTGRES_USER: ${LISTMONK_DB_USER:?Set LISTMONK_DB_USER in .env}
POSTGRES_PASSWORD: ${LISTMONK_DB_PASSWORD:?Set LISTMONK_DB_PASSWORD in .env}
POSTGRES_DB: ${LISTMONK_DB_NAME:?Set LISTMONK_DB_NAME in .env}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${LISTMONK_DB_USER:?Set LISTMONK_DB_USER in .env}"]
interval: 10s
timeout: 5s
retries: 6
volumes:
- listmonk-db:/var/lib/postgresql/data
app:
image: listmonk/listmonk:latest
container_name: listmonk-app
restart: unless-stopped
depends_on:
db:
condition: service_healthy
ports:
- "${LISTMONK_HTTP_PORT:-9000}:9000"
command:
- sh
- -c
- ./listmonk --install --idempotent --yes --config '' && ./listmonk --upgrade --yes --config '' && ./listmonk --config ''
environment:
TZ: ${TZ:-UTC}
LISTMONK_app__address: 0.0.0.0:9000
LISTMONK_db__host: db
LISTMONK_db__port: 5432
LISTMONK_db__user: ${LISTMONK_DB_USER:?Set LISTMONK_DB_USER in .env}
LISTMONK_db__password: ${LISTMONK_DB_PASSWORD:?Set LISTMONK_DB_PASSWORD in .env}
LISTMONK_db__database: ${LISTMONK_DB_NAME:?Set LISTMONK_DB_NAME in .env}
LISTMONK_db__ssl_mode: disable
LISTMONK_db__max_open: 25
LISTMONK_db__max_idle: 25
LISTMONK_db__max_lifetime: 300s
LISTMONK_ADMIN_USER: ${LISTMONK_ADMIN_USER:-}
LISTMONK_ADMIN_PASSWORD: ${LISTMONK_ADMIN_PASSWORD:-}
volumes:
- listmonk-uploads:/listmonk/uploads
volumes:
listmonk-db:
listmonk-uploads:
Create .env:
TZ=Europe/Warsaw
LISTMONK_HTTP_PORT=18091
LISTMONK_DB_USER=listmonk
LISTMONK_DB_PASSWORD=replace-with-a-long-random-password
LISTMONK_DB_NAME=listmonk
LISTMONK_ADMIN_USER=
LISTMONK_ADMIN_PASSWORD=
Then validate and start:
docker compose config
docker compose up -d
Open:
http://localhost:18091
If you leave LISTMONK_ADMIN_USER and LISTMONK_ADMIN_PASSWORD empty, create the first Super Admin user through the web UI. If you want non-interactive first boot, set both variables only for the first startup.
Field Note: Docker Smoke Test
I ran a bounded Docker smoke test on 2026-08-28 without touching existing containers.
The trial used raw docker run commands instead of the repo compose file, because this Docker host already had many running containers and I wanted to avoid allocating a new Docker network.
Trial details:
- PostgreSQL container:
listmonk-foss-post-1787905784-db - App container:
listmonk-foss-post-1787905784-app - App image:
listmonk/listmonk:latest - Database image:
postgres:17-alpine - Host port:
18091 - Container port:
9000 - Temporary data path:
/tmp/foss-post/listmonk-foss-post-1787905784 - Network: existing Docker bridge
The test result:
- PostgreSQL became ready.
- listmonk installed the schema.
- The first Super Admin user was created from temporary environment variables.
- The upgrade step reported no pending migrations.
- listmonk logged
v6.2.0. - The filesystem media provider initialized.
- The default SMTP messenger initialized from sample settings.
- The HTTP server started on
:9000. GET http://127.0.0.1:18091/healthreturned:
{"data":true}
After validation, I removed both trial containers and the temporary /tmp data directory.
Configuration Notes
listmonk can read TOML config files, but the official Docker pattern passes config through environment variables.
The mapping is:
- Prefix with
LISTMONK_. - Use double underscores for nested config keys.
- Example:
LISTMONK_db__host=db. - Use
--config ''to rely only on environment variables.
The Docker entrypoint also supports the LISTMONK_*_FILE pattern, which is useful with Docker or Podman secrets.
Most application settings are then managed from the admin UI. For a production instance, decide these before your first real campaign:
| Area | What to configure |
|---|---|
| Root URL | Public URL used in campaigns, links, forms, and archives |
| SMTP | Host, port, auth, TLS mode, From addresses, retry behavior |
| Privacy | Open tracking, click tracking, unsubscribe header behavior |
| Bounces | POP3 mailbox or provider webhook processing |
| Media | Filesystem upload path or storage provider |
| Users | Roles, list roles, API users, OIDC SSO |
| Public pages | Subscription forms, archives, custom CSS/JS |
Bounces and Deliverability
Bounce handling is not optional once you send real volume.
listmonk supports POP3 mailbox scanning and webhooks for providers such as SES, Azure Communication Services, SendGrid, Postmark, Forward Email, and Lettermint.
The app can classify soft, hard, and complaint bounces, then blocklist or otherwise act on subscribers based on configured rules.
For public deployments, expose only the routes you need:
/subscription/*for public subscription management./link/*for tracked link redirects./campaign/*for campaign views and tracking pixels./public/*for public static assets./webhooks/service/*for provider bounce webhooks./uploads/*if media files are served from listmonk./admin/*and/api/*only where admins and integrations need them.
Development Setup
For binary-based setup:
./listmonk --new-config
./listmonk --install
./listmonk
For source builds, install Go, Node.js, and Yarn, then run:
make dist
That builds the frontend and packages the app into the release-style binary.
When listmonk Fits
listmonk is a good fit if you want:
- A self-hosted newsletter dashboard.
- PostgreSQL-backed subscriber data.
- Strong import/export and API workflows.
- Subscriber attributes and segmentation.
- Campaign templates and archives.
- SMTP delivery through your provider.
- Bounce processing.
- Granular user/list permissions.
- OIDC SSO.
- A single application binary instead of a heavy marketing suite.
It may not be ideal if you want:
- A fully managed sender reputation platform.
- Built-in CRM automation workflows.
- Drag-and-drop customer journey automation.
- Hosted deliverability support.
- A no-maintenance newsletter SaaS.
Alternatives to Know
For adjacent self-hosted email and newsletter workflows, compare:
- Mautic for broader marketing automation.
- Keila for newsletter campaigns and forms.
- Mailtrain for mailing-list management.
- Ghost if your newsletter is tied to a publication CMS.
- Postal if you need a self-hosted mail delivery platform.
listmonk stands out when you want fast list management, campaigns, APIs, and Postgres data ownership without adopting a full marketing automation suite.
Project Links
- listmonk website
- listmonk source code
- listmonk AGPL-3.0 license
- listmonk documentation
- Home-Lab listmonk compose reference
Conclusion
listmonk is one of the cleanest self-hosted newsletter stacks I have inspected: Go backend, Vue admin UI, PostgreSQL data model, Docker image, idempotent install/upgrade commands, and clear operational boundaries.
The isolated Docker smoke test worked: PostgreSQL initialized, listmonk installed and upgraded the schema, the app logged v6.2.0, and /health returned {"data":true}.
The real production work is outside the container: configure SMTP, DNS, bounce handling, unsubscribe behavior, backups, and privacy settings before sending campaigns to real subscribers.
FAQ
Does listmonk need PostgreSQL?
postgres:17-alpine.
Can listmonk run without Docker?
config.toml, run --install, then run the binary.
Can listmonk send transactional email?
Does listmonk send mail by itself?
Can I test listmonk with Mailpit or MailDev?
Yes. Both Mailpit and MailDev can act as local SMTP catchers for listmonk.
Use them when you want to test campaign templates, transactional messages, unsubscribe/footer links, and SMTP wiring without sending email to real subscribers. In the same Docker Compose network, configure listmonk with SMTP_HOST=mailpit or SMTP_HOST=maildev, plus SMTP_PORT=1025.
MailDev is enough when you want to confirm that listmonk sent the email and preview the rendered HTML. Mailpit is stronger when you want deeper QA, such as HTML checks, link checks, screenshots, search, and tagging.
For production newsletters, switch listmonk to a real SMTP provider or supported messenger backend and configure SPF, DKIM, DMARC, bounces, unsubscribe behavior, and list hygiene properly.
Can I use listmonk for a minimal Hugo newsletter?
Yes. For a static Hugo site, listmonk can be the newsletter backend, but the clean minimal flow is not just the transactional API.
Use listmonk’s subscriber/list flow for subscriptions, confirmation, unsubscribe links, preferences, and campaigns. Use the transactional API only for one-off triggered messages, such as a custom welcome email, admin notification, or app-specific transactional message.
A minimal architecture looks like this:
Hugo form
-> tiny backend endpoint
-> listmonk public subscription/subscriber API
-> double opt-in confirmation email
-> subscriber confirms
-> later campaigns are sent from listmonk
-> SMTP provider delivers to real inboxes
The backend can be a small Cloudflare Worker, serverless function, or tiny API service. Avoid calling authenticated listmonk admin APIs directly from browser JavaScript, because that would expose credentials.
For a simple publication newsletter:
- Create one list in listmonk, preferably double opt-in.
- Add a small subscribe form to Hugo.
- Submit the form to a tiny backend endpoint or a suitable public listmonk subscription endpoint.
- Let listmonk handle confirmation and unsubscribe URLs.
- Test locally with Mailpit, MailDev, or Inbucket.
- Configure a real SMTP provider before sending to real subscribers.
In short: listmonk handles the list and campaign logic, the tiny backend protects the API call, Mailpit/MailDev/Inbucket test the flow, and SendGrid/SES/Postmark/etc. deliver the real email.
Could I build a minimal custom newsletter with FastAPI and SQLite?
Yes. A small FastAPI + SQLite + email provider API stack can cover the basic newsletter flow if you want maximum control and minimal moving parts.
The rough shape is:
Hugo form
-> FastAPI endpoint
-> SQLite subscribers table
-> confirmation token email via SendGrid/Postmark/SES/etc.
-> confirmed subscriber
-> admin script sends newsletter
-> unsubscribe token link
That gives you full ownership of the subscribe endpoint, double opt-in token generation, confirmation links, subscriber storage, unsubscribe links, suppression list, admin send script, provider API integration, exports, backups, and event logs.
Use SQLite as the source of truth and CSV only for import/export. CSV gets awkward for token lookup, uniqueness, concurrent writes, and status changes.
This is equivalent to listmonk only for the basics. listmonk already provides the campaign UI, templates, segmentation, bounce handling, subscriber management, import/export, analytics, unsubscribe/preferences pages, and delivery queue behavior.
When is a custom FastAPI newsletter better than listmonk?
A custom stack makes sense when the goal is portable contact infrastructure, not a full newsletter product.
For example:
Website forms
-> FastAPI intake API
-> SQLite contacts/leads/subscribers/events
-> enrichment pipeline
-> SendGrid/Postmark/SES/etc.
-> delivery and event logs back into SQLite
This can be a good fit when you run multiple websites, want everything defined as code, want SQLite data that is easy to inspect and back up, and want your lead enrichment or agents to work against the same database.
A practical schema would separate contacts, consent_events, email_events, and enrichment_runs. That makes the system more agent-friendly: migrations live in git, SQL queries are transparent, enrichment prompts can be versioned, and all forms normalize into the same data model.
Do not skip the boring production pieces: double opt-in for newsletter/marketing email, unsubscribe links in every newsletter, consent/event logging, rate limiting, bot protection, hashed tokens, provider webhooks for bounces or unsubscribes, SQLite backups, and SPF/DKIM/DMARC for the sending domain.
Use listmonk when you want a ready-made newsletter product. Use FastAPI + SQLite + provider API when you want a small owned lead/contact/email layer that can be reused across your sites.
Was the Docker test isolated?
/tmp/foss-post, and the existing Docker bridge network. Only the trial containers and temporary data directory were removed.
Comments