Why self-host Listmonk on a VPS
Listmonk is a single Go binary backed by PostgreSQL, able to manage massive lists and segmented campaigns with a very high send rate. Self-hosting becomes worthwhile as soon as your subscriber base exceeds a few thousand contacts: SaaS platforms then bill hundreds of euros a month for a task that a modest VPS performs for the price of the server. You retain full ownership of your email database (no leak to a third party), you connect the SMTP relay of your choice and you control the send pace to preserve your sender reputation. A VPS is essential here because Listmonk needs a persistent process, a PostgreSQL database and open outbound SMTP ports — freedoms that no shared hosting grants.
Concrete benefits of a self-hosted Listmonk
- No per-contact cost: the bill no longer depends on the size of your list but only on the VPS.
- Segmentation by SQL queries: target subsets of subscribers with a precision impossible on most SaaS platforms.
- Rich templating (Go templates + HTML) and A/B campaigns versionable in your repository.
- Complete REST API: trigger transactional sends from your application via webhook.
- Tracking of opens, clicks and bounces in a unified interface, without a third-party pixel.
- Free choice of relay (Postmark, Amazon SES, OVH, your own Postfix) to optimize deliverability and cost.
Technical requirements
Listmonk itself is very light, but PostgreSQL and the send volume call for reasonable sizing: plan for 2 vCPUs, 2 GB of RAM and 20 GB of disk for a base of a few tens of thousands of contacts; go up to 4 GB of RAM if you run large simultaneous campaigns. On the infrastructure side: Docker and Docker Compose, a domain mail.yourdomain.com with an A record to the VPS, and above all the email authentication DNS records (SPF, DKIM, DMARC) configured on the sending domain. An SMTP relay with credentials (host, port 587, user, password) is necessary — direct sending from a VPS's IP is often blocked by providers.
Deploy Listmonk step by step
Retrieve the official stack
On the VPS, create
/opt/listmonkthen download the reference compose file:curl -LO https://raw.githubusercontent.com/knadh/listmonk/master/docker-compose.yml. It declares two services,app(listmonk) anddb(PostgreSQL).Initialize the database
Run the initial migration before the first run:
docker compose run --rm app ./listmonk --install. This command creates the PostgreSQL schema and the administrator user. Enter a strong admin password at the prompt.Start the service
Start the stack with
docker compose up -d. Listmonk listens on port 9000 internally. Check the container status withdocker compose psand review the logs if needed.Configure the reverse proxy and SSL
Expose Listmonk behind a reverse proxy with HTTPS. Caddy example:
mail.yourdomain.com { reverse_proxy app:9000 }. The Let's Encrypt certificate is generated automatically. Never expose port 9000 directly on the Internet.Connect the SMTP relay
In Settings > SMTP, enter your relay's parameters (host, port 587, credentials, TLS enabled). Send a test email from the interface to validate the connection before any campaign.
Secure deliverability
Check with an external tool that SPF, DKIM and DMARC pass green on your sending domain. Start with small campaigns to "warm up" the relay's IP and monitor the bounce rate in the dashboard.
Advanced configuration: SMTP, lists and segments
Listmonk supports multiple SMTP servers simultaneously: create one server dedicated to transactional sends (confirmations, forgotten passwords) and another for marketing campaigns, each with its own credentials and rate limits. To handle bounces automatically, enable the Bounce Processor under Settings > Bounces: Listmonk polls an IMAP inbox (or a webhook from your relay) and removes invalid addresses without manual intervention.
For lists, prefer double opt-in (Settings > Lists > Opt-in): a confirmation email is sent to every new subscriber, protecting your reputation and complying with GDPR. Dynamic segments (Lists > New segment) rely on raw SQL queries against the subscriber table: for example attribs->>'plan' = 'pro' AND created_at > NOW() - INTERVAL '30 days' to target new premium customers. These custom attributes are passed at subscription time via the API (POST /api/subscribers) in the attribs field (free JSON object).
Updating Listmonk
Before any update, back up the database: docker exec listmonk_db_1 pg_dump -U listmonk listmonk > backup-before-update.sql. Then stop the stack, pull the new image and run the migration script:
docker compose down
docker compose pull
docker compose run --rm app ./listmonk --upgrade
docker compose up -dThe --upgrade flag applies the schema migrations required between versions. Check the changelog on the official repository before jumping between major versions: some migrations are irreversible. Once the service has restarted, verify the interface and send a test email to confirm everything is working.
Common troubleshooting
SMTP connection refused — connection refused on port 587. Most VPS providers block outbound SMTP ports (25, 465, 587) to fight spam. The fix: use an external SMTP relay (Postmark, Amazon SES, Brevo in relay mode) whose traffic goes out via HTTPS API or an allowed port. Also verify that TLS is enabled on the Listmonk side (STARTTLS for port 587).
PostgreSQL unreachable at startup — dial tcp db:5432: connect: connection refused. The app container starts before PostgreSQL is ready. Add a depends_on directive with condition: service_healthy in your docker-compose.yml, or wait a few seconds before restarting: docker compose restart app.
502 Bad Gateway with Caddy. The Listmonk container is not running or has crashed. Check docker compose ps: if app is in an Exited state, run docker compose logs app to identify the error, then docker compose up -d to restart it.
CSV import blocked or subscribers missing. Listmonk requires a CSV file encoded in UTF-8 without BOM, with at least one email column. A file exported from Excel may contain a BOM (invisible marker at the start of the file) or a Windows-1252 encoding: convert it with iconv -f WINDOWS-1252 -t UTF-8 list.csv > list-utf8.csv before importing.
Set the send rate limit (Settings > Performance > max concurrent connections and message rate) according to your SMTP relay's quotas. Too aggressive a send drives up the spam and rejection rate. For critical transactional emails, create a second mail server in Listmonk dedicated to those sends, separate from the marketing server: that way a reputation issue on the newsletters does not affect your confirmation emails.
GDPR and suppression lists
Listmonk automatically maintains a suppression list (unsubscribes + hard bounces). To comply with GDPR, enable double opt-in on all public lists and configure the unsubscribe page with a {{ UnsubscribeURL }} link in every template. Data from unsubscribed contacts remains in the database (to prevent accidental re-subscription) but is flagged blocklisted: they receive no further sends. Export and purge these entries regularly if your DPA requires it.
Use case: e-commerce store with transactional email and newsletters
Consider a self-hosted PrestaShop or WooCommerce store on a ServOrbit VPS. Configure Listmonk as the central email engine: order confirmations and shipping notifications go through the transactional API (/api/tx) with a Listmonk template; customers who opt in to the newsletter receive a weekly campaign via a Listmonk list. One SMTP relay, one platform, zero per-subscriber fees. Custom attribute segmentation (average cart value, preferred category, last purchase date) lets you target precise offers — without exporting your customer data to a third-party SaaS.
Backup and data portability
Listmonk stores all data in PostgreSQL (the listmonk-db Docker volume). To back up: docker exec listmonk_db_1 pg_dump -U listmonk listmonk > backup.sql. To restore on a new instance: deploy a fresh Listmonk, stop the app container, restore the database (psql -U listmonk listmonk < backup.sql) and restart. Attached files (inline images) live in the listmonk-uploads volume. Listmonk's REST API also lets you export subscribers as CSV at any time from the UI or via script.