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.
One workflow note: /home/jalcocert/Desktop/Home-Lab was not available on this machine, so I did not add a reusable Home-Lab compose snippet or docker_config field for the app catalog entry.
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.
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?
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