listmonk is a self-hosted newsletter and mailing list manager: subscribe forms, templates, segments, and campaign analytics, with a real Postgres database behind it. It's the tool for people who send a weekly update and don't want to pay Mailchimp for the privilege.
What it is
Double opt-in and unsubscribe handled properly, subscriber segments you can target, a template editor with merge tags, and per-campaign open/click/unsubscribe rates. It also accepts existing lists by CSV import, so migrating from a hosted provider is a file upload.
Before you start
- Sending bulk email from a home server is a reputation minefield. You need a domain with SPF, DKIM and DMARC aligned, and a dedicated sending IP. Without those you'll land in spam and there's no code fix.
- Use a transactional provider's SMTP for actual delivery, and listmonk for list management. Trying to deliver tens of thousands of messages from your own residential IP will not work.
- Double opt-in isn't optional if you're in the EU. It's built in, so leave it on.
1 — Create the directory
mkdir -p ~/services/listmonk/{data,pgdata,static}
cd ~/services/listmonk
openssl rand -hex 32
openssl rand -base64 24
2 — Write the compose file
services:
app:
image: listmonk/listmonk:latest
container_name: listmonk
restart: unless-stopped
ports:
- "127.0.0.1:9000:9000"
environment:
LISTMONK_app__address: 0.0.0.0:9000
LISTMONK_app__admin__user: CHANGE_ME_admin
LISTMONK_app__admin__password: CHANGE_ME
LISTMONK_app__db__user: listmonk
LISTMONK_app__db__password: CHANGE_ME
LISTMONK_app__db__database: listmonk
LISTMONK_app__db__host: db
LISTMONK_app__db__port: 5432
LISTMONK_app__db__ssl_mode: disable
LISTMONK_app__site__name: My Newsletter
LISTMONK_app__site__url: https://news.example.com
volumes:
- ./data:/var/lib/listmonk
- ./static:/listmonk/static
depends_on:
db:
condition: service_healthy
db:
image: postgres:17-alpine
container_name: listmonk-db
restart: unless-stopped
environment:
POSTGRES_USER: listmonk
POSTGRES_PASSWORD: CHANGE_ME
POSTGRES_DB: listmonk
volumes:
- ./pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U listmonk"]
interval: 10s
timeout: 5s
retries: 5
3 — Start it
docker compose up -d
docker compose logs -f app | grep -i "listening\|migrat\|error"
4 — First-run setup
- Log in at
https://news.example.comwith the admin credentials above, and change the password. - Under Settings → SMTP, enter your provider's credentials. Send a test campaign to yourself with Send test email before doing anything else — check the headers with
dig +short TXT _dmarc.yourdomain.comand verify SPF and DKIM pass. - Import your existing list, or create a subscription page under Lists → your list → Opt-in forms and embed the hosted form.
- Build a template. The editor is a block editor; the useful part is the merge tags —
{{ .Subscriber.Name }},{{ .Subscriber.Attribs.city }}for custom fields. - Send your first real campaign to a small segment. Watch the open rate in the analytics, then check the
Sentlog for any bounces and let listmonk clean the list.
5 — Keep the list clean
# export the list regularly - it's your data
docker exec listmonk-app sh -c 'listmonk subscriber export --with-subscriptions -o /tmp/list.csv' \
&& docker cp listmonk-app:/tmp/list.csv ./list-$(date +%F).csv
# back up
docker exec -u postgres listmonk-db pg_dump -U listmonk listmonk | gzip > listmonk-$(date +%F).sql.gz
{{ .UnsubscribeURL }} merge tag in every template and test it in a real send. Never hand-roll the link.