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.sql defines 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

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/health returned:
{"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