Table of Contents
Listmonk is an open-source, self-hosted newsletter and mailing list manager written in Go. It handles subscribers, lists, segmentation, campaigns, templates, and analytics from a single binary backed by PostgreSQL.
The reason people move to it is pricing structure rather than features. Managed newsletter platforms charge by subscriber count, so your bill grows with your list whether or not you send more email. Self-hosted Listmonk costs whatever your VPS costs, plus whatever your SMTP relay charges per thousand emails. The subscriber count does not enter either number.
One thing to be clear about before you start: Listmonk manages your list and composes your campaigns. It does not deliver them. Whether your email reaches the inbox is determined by the SMTP relay you configure and the DNS records on your sending domain. Those two things get the same attention here as the installation.
Listmonk for Linux
Get the official release and installation documentation before deploying it on your VPS.
Download from Official Website →Step 1: Prepare the VPS and Domain
Listmonk plus PostgreSQL is not a heavy stack. 2 vCPU and 2 GB RAM runs a list of tens of thousands comfortably. 4 GB is the number to pick if you expect to grow, because PostgreSQL performance on subscriber queries and campaign analytics is what degrades first, and it degrades on memory.
Disk requirements are modest — the database stores subscribers and campaign records, not media. 40 GB is plenty for most lists.
Use Ubuntu 22.04 LTS or 24.04 LTS. Connect as a user with sudo:
ssh youruser@your-server-ip
Update the system:
sudo apt update && sudo apt upgrade -y
You need two DNS decisions before continuing.
The admin subdomain is where you log in and manage campaigns. Create an A record pointing it at the VPS:
mail.example.com A your-server-ip
The sending domain is what appears in the From address on your emails. It can be the same root domain. This is where the SPF, DKIM, and DMARC records go in step 7.
Open the firewall for SSH and web traffic only:
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
Note that Listmonk's own port is not in that list, and it should not be. The reverse proxy is the only thing that will reach it.
Step 2: Install Docker
Docker Compose is the cleanest way to run Listmonk and PostgreSQL together, and it makes upgrades a matter of changing an image tag.
Install Docker Engine with the Compose plugin:
curl -fsSL https://get.docker.com | sudo bash
Enable and start it:
sudo systemctl enable --now docker
Add your user to the docker group so you do not need sudo for every command:
sudo usermod -aG docker $USER
Log out and back in for that to take effect, then verify:
docker compose version
Step 3: Write the Docker Compose File
Create a directory for the deployment:
mkdir -p ~/listmonk && cd ~/listmonk
Create docker-compose.yml with two services:
services:
db:
image: postgres:16-alpine
container_name: listmonk-db
restart: unless-stopped
environment:
POSTGRES_USER: listmonk
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: listmonk
volumes:
- listmonk-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U listmonk"]
interval: 10s
timeout: 5s
retries: 6
app:
image: listmonk/listmonk:latest
container_name: listmonk-app
restart: unless-stopped
ports:
- "127.0.0.1:9000:9000"
depends_on:
db:
condition: service_healthy
environment:
LISTMONK_app__address: "0.0.0.0:9000"
LISTMONK_db__host: db
LISTMONK_db__port: 5432
LISTMONK_db__user: listmonk
LISTMONK_db__password: ${POSTGRES_PASSWORD}
LISTMONK_db__database: listmonk
volumes:
listmonk-data:
The 127.0.0.1:9000:9000 binding is the important line. It publishes the port on loopback only, so Listmonk is unreachable from the internet directly. Listmonk has no built-in login rate limiting, so an admin panel exposed on a public port is a login form anyone in the world can hammer.
Create a .env file next to the compose file with a long random password:
POSTGRES_PASSWORD=replace-with-a-long-random-string
Generate one properly rather than typing something:
openssl rand -base64 32
Restrict the file so it is not world-readable:
chmod 600 .env
Pinning postgres:16-alpine rather than postgres:latest is deliberate. A major PostgreSQL version jump on a container pull will refuse to start against an existing data directory, and finding that out during a restart is not the time to learn it.
Step 4: Initialise the Database and Start Listmonk
Start the database first and let it become healthy:
docker compose up -d db
Run the one-time install, which creates the schema and the first admin user:
docker compose run --rm app ./listmonk --install --idempotent --yes
Then bring up the application:
docker compose up -d
Verify both containers are running:
docker compose ps
Expected output is two services with an Up status, and the database row showing (healthy).
Check the application answered on loopback:
curl -I http://127.0.0.1:9000
If that fails, read the logs:
docker compose logs app
The usual first-run failure is a database connection error caused by the .env file not being picked up. Confirm it is in the same directory as docker-compose.yml.
Step 5: Put Listmonk Behind a Reverse Proxy with HTTPS
Listmonk is currently reachable only from the server itself. The reverse proxy is what makes it reachable over HTTPS from anywhere.
Install Nginx and Certbot:
sudo apt install nginx certbot python3-certbot-nginx -y
Create the site configuration at /etc/nginx/sites-available/listmonk:
server {
listen 80;
server_name mail.example.com;
location / {
proxy_pass http://127.0.0.1:9000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
The WebSocket headers are not optional. Listmonk streams live campaign send progress over a WebSocket connection, and without Upgrade and Connection set, the progress bar sits at zero while the campaign sends normally, which is confusing in exactly the moment you least want confusion.
Enable the site and reload:
sudo ln -s /etc/nginx/sites-available/listmonk /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Issue the certificate:
sudo certbot --nginx -d mail.example.com
Certbot rewrites the server block for port 443 and adds an HTTP-to-HTTPS redirect. Verify:
curl -I https://mail.example.com
If you get a redirect loop, the cause is almost always a missing X-Forwarded-Proto header. Check that line in the config above.
Step 6: Set the Application Hostname
This step is small, easy to skip, and directly determines whether your first campaign lands in the inbox.
Listmonk builds the Message-ID header on outgoing mail from the configured hostname. If that is left as the container's default, every message goes out with a Message-ID referencing localhost.localdomain. Gmail and Outlook treat that as a strong spam signal, and it is one of the few things that will get an otherwise clean sender filtered immediately.
Log into the admin panel at https://mail.example.com with the credentials created during the install step, then go to:
Settings → General
Set the root URL to your full admin URL:
https://mail.example.com
Save and restart the application container so the change takes effect on outgoing mail:
docker compose restart app
Do this before sending anything. A list that received its first campaigns with a broken Message-ID is harder to recover reputation for than one that started clean.
While you are in settings, change the admin password from whatever the installer generated.
Step 7: Configure the SMTP Relay
Listmonk does not send email itself. It hands each message to an SMTP relay, and that relay's IP reputation is what determines deliverability. Running your own mail server on the VPS is technically possible and practically a bad idea — a fresh VPS IP has no sending reputation, and most consumer mail providers filter mail from residential and cloud IP ranges by default.
Use a transactional email provider. Amazon SES is the cheapest at volume. Postmark and Brevo cost more per message but require less setup and have better bounce handling out of the box. Check each provider's current pricing and free tier against your actual send volume before committing, since those change regularly.
Whichever you choose, the process is the same three stages: verify your sending domain in their console, add the DNS records they generate, and create SMTP credentials. The SMTP credentials are separate from any API keys — providers generate a dedicated SMTP username and password.
In Listmonk, go to:
Settings → SMTP
Configure the server with the values your relay gave you:
Host: smtp.your-relay.com
Port: 587
Auth protocol: LOGIN
TLS: STARTTLS
Username: relay-generated-username
Password: relay-generated-password
Port 587 with STARTTLS is the correct combination for essentially every modern relay. Port 465 with implicit TLS also exists but is the less commonly documented path, and mismatching the port and TLS mode produces a connection that fails at send time rather than at configuration time.
Important: do not rely on the connection test button in the admin panel. It reports success in cases where the credentials are wrong, which means a green result there tells you almost nothing. The only real verification is sending an actual message.
Note also that new Amazon SES accounts start in sandbox mode, where you can only send to addresses you have verified. Request production access from the SES console before you plan a real campaign — approval typically takes about a business day.
Step 8: Add SPF, DKIM, and DMARC Records
These three DNS records on your sending domain tell receiving mail servers that your relay is authorised to send on your behalf. Missing any one of them costs you a meaningful share of your inbox placement, regardless of how clean your content or your relay is.
SPF lists which services may send mail for your domain. Add a TXT record at the domain root with your relay's include value:
v=spf1 include:your-relay-include-value ~all
The exact include string comes from your relay's documentation and differs per provider. A domain can only have one SPF record — if you already have one for Google Workspace or Microsoft 365, merge the new include into it rather than adding a second record. Two SPF records is a permanent error state, not a warning.
DKIM cryptographically signs outgoing messages so receivers can verify they were not altered and did come from an authorised sender. The relay generates the key pair; you publish the public half as a TXT or CNAME record at the selector your relay specifies:
selector._domainkey.example.com
Listmonk has no DKIM configuration of its own. The signing happens at the relay. Follow their setup wizard exactly and copy the record values without editing them.
DMARC tells receivers what to do with mail that fails SPF or DKIM, and gives you reports. Start in monitoring mode with a TXT record at _dmarc.example.com:
v=DMARC1; p=none; rua=mailto:[email protected]
Leave it at p=none for two or three weeks while you read the aggregate reports. Only tighten to p=quarantine and then p=reject once the reports are clean. Setting p=reject on day one alongside a typo in your SPF include will silently destroy your own legitimate email, and DMARC gives you no warning that it is happening.
Wait for propagation before testing. Most records resolve within thirty minutes; some take a few hours.
Step 9: Create a List and Import Subscribers
With sending configured, set up the list itself.
Create a list from:
Lists → + New
Choose double opt-in unless you have a specific reason not to. Single opt-in fills your list faster and fills it with typos, spam traps, and addresses that never consented. A spam trap hit does more damage to your sending reputation than the extra subscribers are worth.
Import existing subscribers from a CSV:
Subscribers → Import
Listmonk accepts standard CSV exports from Mailchimp, ConvertKit, Substack, and most other platforms without reformatting. Map the email and name columns when prompted, and select the target list.
Before importing a list you have not mailed in months, clean it. Addresses that have gone stale produce hard bounces, and a high bounce rate on your first send from a new sending domain is the fastest way to get your relay account suspended. Most relays will do this for you, or run the list through a validation service.
Grab the public subscription form from:
Lists → (your list) → Subscription form
Paste the generated HTML into your site, or link directly to the hosted subscription page Listmonk provides.
Step 10: Send a Test Campaign, Then the Real One
Create a campaign from:
Campaigns → + New
Set the name, subject, and sending From address. The From address must be on the domain you verified with your relay and configured SPF and DKIM for. A mismatch here fails authentication no matter how correct your DNS records are.
Write the content in the rich text, HTML, Markdown, or plain text editor. Listmonk supports Go template syntax for personalisation, so you can insert subscriber attributes into the body.
Now the step that matters most: create a temporary list with a single subscriber — an inbox you control, ideally at Gmail — and send the campaign there first.
When it arrives, open the message headers and confirm three things. SPF shows pass. DKIM shows pass. The Message-ID references your domain and not localhost.localdomain. If any of those is wrong, fix it before touching your real list. In Gmail this is under the three-dot menu, Show original.
Also confirm the message landed in the inbox rather than the promotions tab or spam.
Once the test is clean, send to the real list. Watch the campaign progress in the dashboard, then cross-check the numbers against your relay's own dashboard for the same time window. If Listmonk reports more sends than the relay accepted, the difference was rejected or throttled at the relay, and Listmonk does not always surface that as a campaign error.
Step 11: Back Up the Database
The Docker volume survives container restarts. It does not survive host failure, an accidental docker volume rm, or a corrupted upgrade. Your entire subscriber list lives in that volume.
Test a dump by hand first:
docker exec listmonk-db pg_dump -U listmonk listmonk > ~/listmonk-test.sql
Check the file is a sensible size before trusting it:
ls -lh ~/listmonk-test.sql
Then schedule it daily with cron:
0 2 * * * docker exec listmonk-db pg_dump -U listmonk listmonk > /backups/listmonk-$(date +\%Y\%m\%d).sql
Copy the dumps off the server to object storage or another machine. A backup on the same disk as the database is not a backup.
A backup script that writes a zero-byte file without raising an error is worse than having no backup, because you stop thinking about the problem. Check the output size occasionally, and restore one dump into a throwaway database at least once so you know the restore path works.
Related Guide
Linux VPS Hosting for ListmonkListmonk and PostgreSQL run comfortably on a small NVMe Linux VPS. Pick a region close to where you manage campaigns from.
Listmonk is now running behind HTTPS, sending through an authenticated relay, with SPF, DKIM, and DMARC in place and a working backup. The one habit worth keeping is the single-subscriber test send before every campaign to a list you care about — it costs thirty seconds and catches the mistakes that are expensive to make at scale.
Need a Linux Server for This?
Run Debian, Ubuntu, or any Linux distro on DropVPS โ fast NVMe SSD, full root access, and 24/7 support. Perfect for everything you just read.
- Full Root Access
- Debian & Ubuntu Ready
- 99.99% Uptime
- 24/7 Support
No commitment ยท Cancel anytime
