All articles
TutorialsMay 29, 2026 · 23 min read

Self-Host Listmonk on a VPS for Your Own Newsletter

Self-Host Listmonk on a VPS for Your Own Newsletter

Email lists are one of the few channels you actually own - no algorithm sits between you and the people who asked to hear from you. The catch is that hosted newsletter platforms charge per subscriber, and the bill grows faster than the list does. Once you cross a few thousand contacts, the monthly invoice starts to look absurd for what amounts to sending a templated email.

Listmonk is the open-source answer. It is a single, fast Go binary backed by Postgres, it handles millions of subscribers comfortably, and it costs you nothing but the VPS it runs on. This tutorial walks through a production-ready install: Docker Compose, the official Listmonk image, Postgres for storage, Caddy for automatic HTTPS, a transactional SMTP relay for actual delivery, and a daily database backup.

Most VPS providers block outbound port 25, and sending bulk mail straight from a fresh server IP lands you in spam folders instantly. Listmonk does not send mail itself - it relays through an SMTP provider you configure. Plan to use a transactional email service (Amazon SES, Postmark, Mailgun, Brevo) for delivery. This guide assumes you will.

TL;DR

  • Install Docker and Docker Compose on a fresh VPS
  • Point a subdomain like mail.example.com at your server
  • Run Listmonk and Postgres with one docker-compose.yml
  • Set the first admin user and password via environment variables
  • Front the stack with Caddy for automatic HTTPS
  • Wire up an SMTP relay so your campaigns actually arrive
  • Create a list, add a subscription form, send your first campaign
  • Schedule a daily Postgres backup

Total time: about 20 minutes, plus DNS and SMTP setup.

What You Need

  • A VPS with at least 1 GB RAM running Ubuntu 22.04 or 24.04 (2 GB is comfortable for large lists)
  • A domain you can add DNS records to
  • Ports 80 and 443 open to the internet
  • An account with a transactional email provider (for SMTP credentials)
  • Root or sudo access

Listmonk is light. The binary idles around 30 MB of RAM, and Postgres is the only meaningful consumer. A 1 GB box runs it fine; the database size scales with your subscriber count and campaign history, not with traffic.

Step 1: Point a Subdomain at Your VPS

In your DNS provider, create an A record for the subdomain that will host the admin panel and subscription pages:

mail.example.com -> YOUR_VPS_IPV4

Add an AAAA record for IPv6 if you use it. Verify it resolves before continuing:

dig +short mail.example.com

DNS has to resolve before Caddy can fetch a Let's Encrypt certificate.

Step 2: Install Docker and Docker Compose

On a fresh Ubuntu server:

sudo apt update sudo apt install -y ca-certificates curl sudo install -m 0755 -d /etc/apt/keyrings sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \ -o /etc/apt/keyrings/docker.asc sudo chmod a+r /etc/apt/keyrings/docker.asc echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \ https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | \ sudo tee /etc/apt/sources.list.d/docker.list > /dev/null sudo apt update sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Confirm:

docker --version docker compose version

Step 3: Open the Firewall

sudo ufw allow 22/tcp sudo ufw allow 80/tcp sudo ufw allow 443/tcp sudo ufw enable

Caddy needs port 80 for the ACME HTTP challenge and 443 for HTTPS. You do not need to open any SMTP ports - Listmonk reaches your mail provider outbound.

Step 4: Create the Project Directory

sudo mkdir -p /opt/listmonk cd /opt/listmonk sudo mkdir -p uploads postgres-data caddy-data caddy-config

Everything persistent lives under /opt/listmonk. The uploads directory holds images and attachments you add to campaigns; postgres-data holds the entire list.

Step 5: Generate Your Secrets

You need a strong Postgres password and a strong admin password. Generate both now:

# Postgres password openssl rand -base64 24 # Listmonk admin password openssl rand -base64 18

Copy both into your password manager. You will paste them into the compose file in the next step.

Step 6: Write the Compose File

Create /opt/listmonk/docker-compose.yml:

services: listmonk-db: image: postgres:17-alpine container_name: listmonk-db restart: unless-stopped environment: POSTGRES_USER: listmonk POSTGRES_PASSWORD: REPLACE_WITH_POSTGRES_PASSWORD POSTGRES_DB: listmonk volumes: - ./postgres-data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U listmonk -d listmonk"] interval: 10s timeout: 5s retries: 6 networks: - listmonk-net listmonk: image: listmonk/listmonk:v3.0.0 container_name: listmonk restart: unless-stopped depends_on: listmonk-db: condition: service_healthy command: > sh -c "./listmonk --install --idempotent --yes --config '' && ./listmonk --upgrade --yes --config '' && ./listmonk --config ''" environment: LISTMONK_app__address: 0.0.0.0:9000 LISTMONK_db__host: listmonk-db LISTMONK_db__port: 5432 LISTMONK_db__user: listmonk LISTMONK_db__password: REPLACE_WITH_POSTGRES_PASSWORD LISTMONK_db__database: listmonk LISTMONK_db__ssl_mode: disable LISTMONK_ADMIN_USER: admin LISTMONK_ADMIN_PASSWORD: REPLACE_WITH_ADMIN_PASSWORD TZ: Etc/UTC volumes: - ./uploads:/listmonk/uploads:rw networks: - listmonk-net caddy: image: caddy:2 container_name: listmonk-caddy restart: unless-stopped ports: - "80:80" - "443:443" volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - ./caddy-data:/data - ./caddy-config:/config networks: - listmonk-net networks: listmonk-net:

A few notes:

  • The Postgres password appears twice - in the listmonk-db environment and in LISTMONK_db__password. They must match.
  • The double-underscore env vars (LISTMONK_db__host) map onto Listmonk's config sections. Passing --config '' tells it to ignore the config file and read everything from the environment.
  • --install --idempotent sets up the schema on first boot and does nothing on later boots. --upgrade applies any pending migrations when you bump the image tag. This combined command makes restarts and upgrades safe.
  • LISTMONK_ADMIN_USER and LISTMONK_ADMIN_PASSWORD seed the first super-admin account on install. After the first boot you can manage users in the UI; changing these env vars later does not rewrite the password.
  • Container ports stay internal. Caddy reaches Listmonk over the Docker network on port 9000.

Step 7: Write the Caddyfile

Create /opt/listmonk/Caddyfile:

mail.example.com { encode zstd gzip reverse_proxy listmonk:9000 }

Caddy will issue and renew a Let's Encrypt certificate automatically.

Step 8: Start the Stack

cd /opt/listmonk sudo docker compose up -d sudo docker compose logs -f

Watch the logs for three things:

  1. Postgres reports database system is ready to accept connections
  2. Listmonk runs the installer and prints setup complete followed by starting HTTP server on 0.0.0.0:9000
  3. Caddy issues a certificate for mail.example.com

Then open https://mail.example.com/admin in a browser. You should see the Listmonk login screen.

Step 9: Log In and Confirm the Setup

Log in with the admin user and the password you set in LISTMONK_ADMIN_PASSWORD. The first thing to fix is the public URL. Go to Settings -> General and set the root URL to:

https://mail.example.com

This value is baked into every link in your emails - unsubscribe links, view-in-browser links, tracked clicks. If it is wrong, those links break. Save and let Listmonk reload.

The unsubscribe and double opt-in links use the root URL above. If you ever move the instance to a new domain, update this setting first, or every link in already-sent campaigns and the subscription pages will point at the old host.

Step 10: Wire Up SMTP

This is the step that decides whether your emails land in inboxes or vanish. Listmonk relays through an external SMTP server - go to Settings -> SMTP and fill in the credentials from your transactional provider.

A typical configuration for a provider on the submission port looks like this:

Host: smtp.your-provider.com Port: 587 Auth proto: LOGIN Username: your-smtp-username Password: your-smtp-api-key TLS: STARTTLS Max conns: 10

Before this works reliably, set up the standard sending-domain DNS records with your provider:

  • SPF - a TXT record authorizing the provider to send for your domain
  • DKIM - a CNAME or TXT record your provider gives you, signing your mail
  • DMARC - a TXT record at _dmarc.example.com telling receivers what to do with failures

Your provider's dashboard generates the exact records. Add them, wait for verification, then hit Send test on the SMTP settings page in Listmonk. A test message that arrives in your inbox - not spam - means you are ready.

Step 11: Create a List and a Subscription Form

In the admin panel:

  1. Go to Lists -> New and create a list (for example Newsletter). Choose Public so it can be subscribed to via a form, and Double opt-in so new subscribers confirm by email - this keeps your list clean and your sender reputation healthy.
  2. Go to Lists and note the public subscription page, served at:
https://mail.example.com/subscription/form

That hosted form is enough to start collecting subscribers. To embed signup on your own site instead, drop a plain HTML form that posts to Listmonk:

<form method="post" action="https://mail.example.com/subscription/form" class="newsletter-form"> <input type="hidden" name="nonce" /> <input type="email" name="email" required placeholder="[email protected]" /> <input type="text" name="name" placeholder="Your name" /> <input type="checkbox" name="l" value="YOUR_LIST_UUID" id="newsletter" checked hidden /> <button type="submit">Subscribe</button> </form>

Replace YOUR_LIST_UUID with the list's UUID, shown on the Lists page. Submissions trigger the double opt-in confirmation email automatically.

To bring an existing list over, use Subscribers -> Import and upload a CSV with email and name columns. Import existing contacts as confirmed so they skip the opt-in step - but only do that for people who genuinely subscribed before.

Step 12: Send Your First Campaign

  1. Go to Campaigns -> New.
  2. Give it a subject, pick the Newsletter list, and choose a template. Listmonk ships with a clean default template you can edit under Campaigns -> Templates.
  3. Write the body in the rich text, raw HTML, or Markdown editor.
  4. Click Save, then Preview to send yourself a test, then Start campaign.

Listmonk queues the messages and pushes them through your SMTP relay at the concurrency you set earlier. The dashboard shows live sending progress, and if you enabled tracking, opens and clicks roll in afterward.

For recurring sends triggered from your own app - a welcome email, a receipt - use Listmonk's transactional API instead of campaigns:

curl -u "api_user:api_token" \ -X POST "https://mail.example.com/api/tx" \ -H "Content-Type: application/json" \ -d '{ "subscriber_email": "[email protected]", "template_id": 1, "data": { "name": "Sam", "order": "1234" } }'

Create the API user and token under Settings -> Users (or Admin -> Users depending on version), and build the template under Campaigns -> Templates with a transactional type.

Step 13: Back Up Postgres

Your entire list, every campaign, and all subscriber state live in Postgres. Back it up daily.

Create /usr/local/bin/listmonk-backup.sh:

#!/usr/bin/env bash set -euo pipefail BACKUP_DIR="/var/backups/listmonk" DATE="$(date +%F)" mkdir -p "$BACKUP_DIR" docker exec listmonk-db \ pg_dump -U listmonk -d listmonk \ | gzip > "$BACKUP_DIR/listmonk-db-$DATE.sql.gz" # Keep uploaded media too tar czf "$BACKUP_DIR/uploads-$DATE.tar.gz" -C /opt/listmonk uploads # Prune anything older than 14 days find "$BACKUP_DIR" -mtime +14 -delete

Make it executable and schedule a daily run:

sudo chmod +x /usr/local/bin/listmonk-backup.sh echo "20 3 * * * root /usr/local/bin/listmonk-backup.sh" | \ sudo tee /etc/cron.d/listmonk-backup

For off-site safety, sync /var/backups/listmonk to S3, Backblaze B2, or another box with rclone on the same schedule. To restore, pipe a dump back into a fresh container with gunzip -c dump.sql.gz | docker exec -i listmonk-db psql -U listmonk -d listmonk.

Step 14: Upgrade Listmonk Safely

Listmonk tags releases on the listmonk/listmonk image. Pin to a specific tag (the compose above uses v3.0.0). To upgrade:

cd /opt/listmonk # Take a fresh backup first sudo /usr/local/bin/listmonk-backup.sh # Bump the image tag in docker-compose.yml, then: sudo docker compose pull sudo docker compose up -d

The --upgrade --yes step in the start command applies any new database migrations automatically on boot. Read the release notes before a major version jump, and roll the tag back from your backup if anything looks wrong.

Troubleshooting

Login page loads but credentials are rejected. The admin user is only seeded on the very first install. If you started the stack once with a placeholder password, the account already exists. Reset it from the database, or wipe postgres-data and start fresh before you have real data.

Test emails never arrive. Almost always SPF/DKIM/DMARC or a blocked port. Confirm your DNS records are verified in the provider dashboard, use port 587 with STARTTLS rather than port 25, and check the provider's activity log to see whether the message even left Listmonk.

Links in emails point to localhost or the wrong host. The root URL under Settings -> General is wrong. Set it to https://mail.example.com and save.

Caddy fails to issue a certificate. DNS hasn't propagated, or ports 80/443 are blocked. Run dig +short mail.example.com and check sudo ufw status.

Sending is slow or the provider throttles you. Lower the SMTP Max connections in Listmonk to match your provider's rate limit. Sending faster than the provider allows just produces deferrals and hurts reputation.

Going Further

Add bounce processing. Listmonk can read bounce notifications over POP3 or via your provider's webhook and automatically disable dead addresses. Configure it under Settings -> Bounces to keep your list healthy and your sender score high.

Lock the admin panel behind a VPN. The public subscription and unsubscribe pages must stay reachable, but /admin does not. Expose the admin path only on a Tailscale or WireGuard network and keep it off the public internet. Our Tailscale guide and WireGuard guide both walk through it.

Front it with your existing reverse proxy. If you already run Caddy or another proxy for other apps, drop the caddy service from the compose file and add a mail.example.com block to your existing config. See our Caddy reverse proxy guide for the pattern.

Automate transactional mail from your app. The /api/tx endpoint turns Listmonk into a templated transactional mailer for welcome emails, receipts, and password resets - one self-hosted service for both newsletters and system mail.

That's it. You own the list, the data lives on your VPS, and the only per-subscriber cost is whatever your SMTP provider charges to actually deliver - usually a fraction of a hosted newsletter platform.


Need a VPS to run your own newsletter stack? Our Linux plans come with NVMe storage, IPv6, and snapshots as standard - plenty for Listmonk and Postgres on a single box. See the options.