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.
mail.example.com at your serverdocker-compose.ymlTotal time: about 20 minutes, plus DNS and SMTP setup.
80 and 443 open to the internetListmonk 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.
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.
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
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.
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.
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.
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:
listmonk-db environment and in LISTMONK_db__password. They must match.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.9000.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.
cd /opt/listmonk
sudo docker compose up -d
sudo docker compose logs -f
Watch the logs for three things:
database system is ready to accept connectionssetup complete followed by starting HTTP server on 0.0.0.0:9000mail.example.comThen open https://mail.example.com/admin in a browser. You should see the Listmonk login screen.
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.
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:
_dmarc.example.com telling receivers what to do with failuresYour 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.
In the admin panel:
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.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.
Newsletter list, and choose a template. Listmonk ships with a clean default template you can edit under Campaigns -> Templates.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.
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.
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.
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.
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.