Skip to content

Listmonk

Listmonk manages subscribers, sends campaigns, handles double opt-in and tracks opens and clicks. A single Go binary and PostgreSQL, with two one-shot services that prepare the schema and the mail settings before it starts.

Last run on a host 8 Nov 2026 · data restored from a backup

Pinned at v6.2.0 (released 26 Jun 2026) · the newest upstream release

In the upstream images: 2 CRITICAL · 53 HIGH with a published fix — per image

To try it: Docker, nothing else. See Try it locally.

To deploy it behind the proxy: Traefik running, a domain, and an SMTP relay you enter after installation.

On your own machine, with no proxy, domain or certificate. From business/listmonk/:

Terminal window
cp .env.local.example .env.local
# The secrets ship empty; generate the ones this stack needs
echo "DB_PASSWORD=$(openssl rand -hex 32)" >> .env.local
docker compose -f docker-compose.local.yml --env-file .env.local up -d

Then open http://localhost:9000. Stop it again with the same command and down.

From business/listmonk/:

  1. Copy the environment file and set APP_TRAEFIK_HOST and TZ:

    Terminal window
    cp .env.example .env
  2. Generate the database password and write it into .env as well. Listmonk has no _FILE form for its database password, so the value exists twice — once as the secret file, once inline:

    Terminal window
    mkdir -p .secrets volumes/postgres volumes/uploads
    sudo chown 1000:1000 volumes/uploads # listmonk runs as 1000:1000
    openssl rand -base64 32 | tr -d '\n' > .secrets/db_pwd.txt
    sed -i "s|^DB_PWD_INLINE=.*|DB_PWD_INLINE=$(cat .secrets/db_pwd.txt)|" .env
  3. Start it. listmonk-install creates the schema on the first boot and migrates it later; smtp-guard replaces upstream’s SMTP placeholder smtp.yoursite.com — a real domain — with smtp.invalid, so nothing is sent before you set a relay. Both exit 0, then listmonk-app starts:

    Terminal window
    docker compose up -d
    docker compose ps -a
    docker compose logs smtp-guard
  4. Over the VPN, open https://<APP_TRAEFIK_HOST>/admin. The first visit creates the super admin account, with no setup secret — the reason the route ships closed (access-vpn).

  5. In the interface, go to Settings → SMTP and enter the relay credentials. They are stored encrypted in the database rather than in a file. Until a relay is set, the subscription form answers 500 and no mail leaves the host.

  6. Open the subscriber paths to the internet — see Admin behind the VPN.

Terminal window
docker compose ps -a # db and listmonk-app healthy, the two one-shots exited 0

Then send a campaign to a list containing only your own address, and confirm it arrives. Sending is the whole purpose of the stack and the part a running container says nothing about.

Admin behind the VPN, subscribers on the internet

Section titled “Admin behind the VPN, subscribers on the internet”

The stack ships access-vpn with limit-standard and headers-standard. Subscriber endpoints — /subscription/*, /link/*, /campaign/* — have to work for people who are not on your VPN. APP_TRAEFIK_ACCESS=access-public opens the whole host, /admin included.

The better shape splits the two: a second Traefik router with a higher priority, scoped to /admin and carrying access-vpn@file in its access position, the other three positions as on the main router; the existing router keeps the subscriber paths. The label block is in business/listmonk/README.md.

The campaign and template preview renders inside an iframe from the same origin. headers-standard sends X-Frame-Options: DENY, which blocks it; the router therefore carries frame-self@file before ${APP_TRAEFIK_HEADERS}@file, which sends SAMEORIGIN instead.

Bounces need either IMAP access to the sending mailbox or a relay that posts bounce webhooks. Without one of the two, addresses that no longer exist stay on the list and quietly damage the sending reputation.

Listmonk requests update.listmonk.app to see whether a newer version exists. It is a plain GET with no payload and no installation identifier — the vendor learns the source address and the timing.

On by default. Turn it off in Settings → General → Check for updates; the setting lives in the database.

Database PostgreSQL, container listmonk-db, database listmonk, user listmonk
Password .secrets/db_pwd.txt
State volumes/postgres · volumes/uploads (media used in campaigns)
Quiescing Not needed — the dump is consistent on its own.

For borgmatic:

postgresql_databases:
- name: listmonk
username: listmonk
# The server's own client, inside its container
pg_dump_command: docker exec listmonk-db pg_dump
pg_restore_command: docker exec -i listmonk-db pg_restore
psql_command: docker exec -i listmonk-db psql

Subscriptions and their opt-in state are rows, so an older snapshot brings back addresses that unsubscribed after it was taken, and the next campaign mails them again. Compare the subscriber table against the unsubscribe log before the first send following a restore.

Restore order: database, then the application.

  1. Read the release notes.

  2. Back up the database and volumes/uploads together.

  3. Raise APP_TAG in .env, then:

    Terminal window
    docker compose pull && docker compose up -d
    docker compose logs listmonk-app --follow
  4. Open a campaign and a template. Schema changes surface in the editor rather than at start.

The interface asks for administrator credentials that are not in .env. Listmonk v6 removed them from configuration. The wizard on first visit is where the account is created; the older ADMIN_USER and ADMIN_PASSWORD variables are ignored.

A banner asks you to remove admin_username from the configuration. Those fields are already absent here. The banner can be dismissed.

The campaign preview is blank. The iframe is being blocked. Confirm frame-self@file stands before ${APP_TRAEFIK_HEADERS}@file on the router.