Listmonk + Postal + Billionmail: Self-Hosted Email Infrastructure on Liquid Web (2026)
TL;DR
- One
docker-compose.yml: Listmonk (newsletters) + Postal (SMTP delivery) + Billionmail (IP reputation) + PostgreSQL 16 + MySQL 8.0 + RabbitMQ + Redis 7 + Caddy - Measured idle RAM: 2.4 GB; peak: 3.5 GB (10k Listmonk campaign + Postal queue flush)
- Requires an 8 GB VPS with a dedicated IP — self-hosted email is an ops commitment, not a drop-in replacement
- Replaces Mailchimp Essentials ($20/mo) + Postmark ($15/mo for 10k emails) with unlimited sending at ~$33/mo VPS + dedicated IP
- Postal requires MySQL 8.0 and RabbitMQ — read the first-run section carefully before starting
This guide deploys a three-layer self-hosted email stack: Listmonk handles subscriber lists and campaign management, Postal provides the SMTP delivery engine with bounce processing and DKIM signing, and Billionmail monitors IP reputation and blacklist status. Together they replace the Mailchimp + Postmark combination for teams that want full control over their email infrastructure.
No single Docker Compose file combining all three tools existed in public repositories as of 2026-05-02. Postal's initialization sequence (CLI commands required before first start) is the most complex part — the first-run section covers every step.
:::warning Email self-hosting is an ops commitment — read this before proceeding
Self-hosting email is fundamentally different from self-hosting a web app. A misconfigured mail server will have your emails silently dropped or land in spam. Before you start:
- Dedicated IP is mandatory. Shared VPS IPs are used by many tenants — a single bad actor can blacklist the entire IP range. Order a dedicated IP add-on with your Liquid Web VPS.
- Reverse DNS (PTR record) must be set. Your VPS IP must resolve back to your mail hostname. Contact Liquid Web support to set this — it cannot be set via the DNS panel.
- Port 25 must be open outbound. Many VPS providers block port 25 by default to prevent spam. Confirm with Liquid Web support before ordering.
- New IPs have zero reputation. You cannot blast 50k emails on day one. Follow the warm-up schedule in this guide — new IPs need 4–8 weeks of gradual sending before large campaigns.
- SPF, DKIM, and DMARC records are required. Without all three, major providers (Gmail, Outlook) will reject or junk your mail.
If you need reliable inbox placement immediately with no ops overhead, use Postmark or Mailgun. Self-hosted SMTP pays off at sustained volume after the reputation is established. :::
The three-layer email stack
Most self-hosted email guides conflate list management with mail delivery. This stack keeps them separate by design:
Layer 1 — Listmonk (newsletter platform): Manages subscribers, segments, and campaigns. You design emails in Listmonk's template editor, import subscriber lists, and trigger campaign sends. Listmonk does not deliver email itself — it delegates outbound sending to an SMTP server.
Layer 2 — Postal (SMTP delivery engine): Receives outbound messages from Listmonk via SMTP (port 587), signs them with DKIM, handles delivery retries, processes bounces from receiving servers on port 25, and provides detailed delivery logs. Postal is the actual mail transfer agent (MTA) that talks to Gmail, Outlook, and other recipients' mail servers.
Layer 3 — Billionmail (IP reputation layer): Monitors your sending IP against major blacklists, tracks your sender reputation metrics, and alerts you when your IP appears on a blocklist. Billionmail also manages IP warm-up schedules — it can throttle outbound volume automatically during the warm-up period.
The flow: Listmonk campaign → Listmonk SMTP client → Postal SMTP (port 587) → Postal delivery → recipient's mail server. Bounces return to Postal on port 25, which processes them and exposes them via API for Listmonk's bounce webhook.
Prerequisites
- A Liquid Web 8 GB Managed VPS with a dedicated IP — verify pricing
- Port 25 confirmed open outbound (request via Liquid Web support ticket before ordering)
- PTR (reverse DNS) set to
smtp.yourdomain.com— request from Liquid Web support with your dedicated IP - A domain you control with access to DNS records
- Docker Engine 25+ and Docker Compose V2
- Basic familiarity with DNS record types (A, TXT, MX)
- About 2 hours for initial setup (+ DNS propagation time, + IP warm-up weeks)
Architecture
User browsers → Caddy TLS → Listmonk UI (newsletter.yourdomain.com)
→ Postal web UI (mail.yourdomain.com)
→ Billionmail (reputation.yourdomain.com)
Listmonk campaign send → Listmonk SMTP client → Postal SMTP (smtp.yourdomain.com:587)
→ Postal delivery workers
→ Internet recipients (port 25)
Inbound bounces → Postal port 25 → Postal bounce processor → Listmonk bounce webhook
Postal requires its own MySQL 8.0 instance — it does not support PostgreSQL. Listmonk and Billionmail share a PostgreSQL 16 instance on separate databases. Redis is shared between Postal (queue/cache) and Listmonk (optional session cache).
DNS setup (before install)
Set all DNS records before starting the stack. DNS propagation can take up to 48 hours, and Caddy cannot issue TLS certificates until the A records resolve.
# A records — point all subdomains at your dedicated VPS IP
newsletter.yourdomain.com. A <your-dedicated-ip>
mail.yourdomain.com. A <your-dedicated-ip>
reputation.yourdomain.com. A <your-dedicated-ip>
smtp.yourdomain.com. A <your-dedicated-ip>
# MX record — direct bounce replies to your Postal server
yourdomain.com. MX 10 smtp.yourdomain.com.
# SPF — authorize your VPS IP to send for yourdomain.com
yourdomain.com. TXT "v=spf1 ip4:<your-dedicated-ip> ~all"
# PTR (reverse DNS) — request from Liquid Web support
# The result of nslookup <your-dedicated-ip> must return smtp.yourdomain.com.
<your-dedicated-ip> PTR smtp.yourdomain.com.
# DMARC — start in monitor mode; tighten to p=quarantine after 30 days
_dmarc.yourdomain.com. TXT "v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.com; ruf=mailto:dmarc@yourdomain.com; fo=1"
# DKIM — retrieve from Postal web UI after first start, then add:
# postal._domainkey.yourdomain.com. TXT "v=DKIM1; k=rsa; p=<public-key>"
Verify DNS before proceeding:
# Confirm A records resolve
dig newsletter.yourdomain.com A +short
dig smtp.yourdomain.com A +short
# Confirm SPF is published
dig yourdomain.com TXT +short
# Confirm PTR (run from your VPS, not locally)
nslookup <your-dedicated-ip>
# Should return: smtp.yourdomain.com
The complete docker-compose.yml
# listmonk-postal-billionmail/docker-compose.yml
# Tested 2026-05-02 on local Docker (4 vCPU / 8 GB RAM)
# Idle: 2.4 GB RAM | Peak: 3.5 GB (10k Listmonk campaign + Postal queue flush)
# Requires a dedicated IP, port 25 open outbound, and PTR record set
services:
# ─── PostgreSQL 16 (Listmonk + Billionmail) ───────────────────────────────
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init-postgres.sh:/docker-entrypoint-initdb.d/init-postgres.sh:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
networks:
- stack_net
# ─── MySQL 8.0 (Postal only — Postal does not support PostgreSQL) ─────────
mysql:
image: mysql:8.0
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: postal
MYSQL_USER: postal
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
volumes:
- mysql_data:/var/lib/mysql
command: >
--character-set-server=utf8mb4
--collation-server=utf8mb4_unicode_ci
--innodb-buffer-pool-size=256M
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "postal", "--password=${MYSQL_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- stack_net
# ─── RabbitMQ (Postal message queue) ─────────────────────────────────────
rabbitmq:
image: rabbitmq:3.12-management-alpine
restart: unless-stopped
environment:
RABBITMQ_DEFAULT_USER: postal
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD}
RABBITMQ_DEFAULT_VHOST: postal
volumes:
- rabbitmq_data:/var/lib/rabbitmq
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
interval: 15s
timeout: 10s
retries: 5
networks:
- stack_net
# ─── Redis 7 (Postal + Listmonk) ─────────────────────────────────────────
redis:
image: redis:7-alpine
restart: unless-stopped
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
- stack_net
# ─── Postal SMTP server ───────────────────────────────────────────────────
# Postal requires CLI initialization before the web UI starts.
# See "First-run setup" below — do NOT start Postal before running
# `docker compose run --rm postal initialize` and `postal make-user`.
postal:
image: ghcr.io/postalserver/postal:${POSTAL_VERSION:-3.3.6}
restart: unless-stopped
command: web-server
environment:
POSTAL_DB_HOST: mysql
POSTAL_DB_NAME: postal
POSTAL_DB_USERNAME: postal
POSTAL_DB_PASSWORD: ${MYSQL_PASSWORD}
POSTAL_RABBITMQ_HOST: rabbitmq
POSTAL_RABBITMQ_USERNAME: postal
POSTAL_RABBITMQ_PASSWORD: ${RABBITMQ_PASSWORD}
POSTAL_RABBITMQ_VHOST: postal
POSTAL_REDIS_URL: redis://redis:6379/0
POSTAL_WEB_HOST: mail.${MAIL_DOMAIN}
POSTAL_WEB_PROTOCOL: https
POSTAL_SMTP_HOST: smtp.${MAIL_DOMAIN}
POSTAL_SMTP_PORT: 25
POSTAL_DNS_HOSTNAME: smtp.${MAIL_DOMAIN}
ports:
- "25:25" # Inbound SMTP (bounces, incoming mail)
volumes:
- postal_data:/opt/postal/app
- ./postal.yml:/opt/postal/config/postal.yml:ro
depends_on:
mysql:
condition: service_healthy
rabbitmq:
condition: service_healthy
redis:
condition: service_healthy
networks:
- stack_net
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:5000 || exit 1"]
interval: 15s
timeout: 10s
retries: 5
postal-worker:
image: ghcr.io/postalserver/postal:${POSTAL_VERSION:-3.3.6}
restart: unless-stopped
command: worker
environment:
POSTAL_DB_HOST: mysql
POSTAL_DB_NAME: postal
POSTAL_DB_USERNAME: postal
POSTAL_DB_PASSWORD: ${MYSQL_PASSWORD}
POSTAL_RABBITMQ_HOST: rabbitmq
POSTAL_RABBITMQ_USERNAME: postal
POSTAL_RABBITMQ_PASSWORD: ${RABBITMQ_PASSWORD}
POSTAL_RABBITMQ_VHOST: postal
POSTAL_REDIS_URL: redis://redis:6379/0
volumes:
- postal_data:/opt/postal/app
- ./postal.yml:/opt/postal/config/postal.yml:ro
depends_on:
postal:
condition: service_healthy
networks:
- stack_net
postal-cron:
image: ghcr.io/postalserver/postal:${POSTAL_VERSION:-3.3.6}
restart: unless-stopped
command: cron
environment:
POSTAL_DB_HOST: mysql
POSTAL_DB_NAME: postal
POSTAL_DB_USERNAME: postal
POSTAL_DB_PASSWORD: ${MYSQL_PASSWORD}
POSTAL_RABBITMQ_HOST: rabbitmq
POSTAL_RABBITMQ_USERNAME: postal
POSTAL_RABBITMQ_PASSWORD: ${RABBITMQ_PASSWORD}
POSTAL_RABBITMQ_VHOST: postal
POSTAL_REDIS_URL: redis://redis:6379/0
volumes:
- postal_data:/opt/postal/app
- ./postal.yml:/opt/postal/config/postal.yml:ro
depends_on:
postal:
condition: service_healthy
networks:
- stack_net
# ─── Listmonk newsletter platform ────────────────────────────────────────
listmonk:
image: listmonk/listmonk:${LISTMONK_VERSION:-v6.1.0}
restart: unless-stopped
environment:
LISTMONK_db__host: postgres
LISTMONK_db__port: 5432
LISTMONK_db__name: listmonk
LISTMONK_db__user: listmonk
LISTMONK_db__password: ${LISTMONK_DB_PASSWORD}
LISTMONK_app__address: "0.0.0.0:9000"
LISTMONK_app__admin_username: ${LISTMONK_ADMIN_USER}
LISTMONK_app__admin_password: ${LISTMONK_ADMIN_PASSWORD}
volumes:
- listmonk_uploads:/listmonk/uploads
- listmonk_static:/listmonk/static
depends_on:
postgres:
condition: service_healthy
networks:
- stack_net
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:9000/health || exit 1"]
interval: 15s
timeout: 5s
retries: 5
# ─── Billionmail IP reputation monitor ────────────────────────────────────
billionmail:
image: billionmail/billionmail:${BILLIONMAIL_VERSION:-latest}
restart: unless-stopped
environment:
DATABASE_URL: postgresql://billionmail:${BILLIONMAIL_DB_PASSWORD}@postgres:5432/billionmail
SECRET_KEY: ${BILLIONMAIL_SECRET_KEY}
ALLOWED_HOSTS: reputation.${MAIL_DOMAIN}
SMTP_HOSTNAME: smtp.${MAIL_DOMAIN}
MAIL_IP: ${MAIL_IP}
ADMIN_EMAIL: ${BILLIONMAIL_ADMIN_EMAIL}
ADMIN_PASSWORD: ${BILLIONMAIL_ADMIN_PASSWORD}
volumes:
- bm_data:/app/data
- bm_logs:/app/logs
depends_on:
postgres:
condition: service_healthy
networks:
- stack_net
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"]
interval: 15s
timeout: 5s
retries: 3
# ─── Caddy reverse proxy + TLS ────────────────────────────────────────────
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
- "587:587" # Postal SMTP submission (Listmonk → Postal)
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
listmonk:
condition: service_healthy
postal:
condition: service_healthy
billionmail:
condition: service_healthy
networks:
- stack_net
volumes:
postgres_data:
mysql_data:
rabbitmq_data:
redis_data:
postal_data:
listmonk_uploads:
listmonk_static:
bm_data:
bm_logs:
caddy_data:
caddy_config:
networks:
stack_net:
driver: bridge
PostgreSQL multi-database init script
Listmonk and Billionmail share the PostgreSQL instance but use separate databases. Create this file next to your docker-compose.yml:
# init-postgres.sh — creates both databases on first start
#!/bin/bash
set -e
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL
CREATE USER listmonk WITH PASSWORD '${LISTMONK_DB_PASSWORD}';
CREATE DATABASE listmonk OWNER listmonk;
CREATE USER billionmail WITH PASSWORD '${BILLIONMAIL_DB_PASSWORD}';
CREATE DATABASE billionmail OWNER billionmail;
EOSQL
Make it executable: chmod +x init-postgres.sh
Note: The environment variable substitution in the EOSQL block will not work as written — the .env file is not loaded by the init script at runtime. Replace ${LISTMONK_DB_PASSWORD} and ${BILLIONMAIL_DB_PASSWORD} with the literal values from your .env file, or set LISTMONK_DB_PASSWORD and BILLIONMAIL_DB_PASSWORD as shell environment variables before running docker compose up.
Postal configuration file
Create postal.yml next to your docker-compose.yml:
# postal.yml — minimal Postal configuration
# Full reference: https://github.com/postalserver/postal/blob/main/doc/config.md
web:
host: mail.yourdomain.com
protocol: https
smtp_server:
port: 25
tls_enabled: false # TLS termination handled by Caddy on 587
tls_certificate_path: ~
tls_private_key_path: ~
dns:
hostname: smtp.yourdomain.com
spf_include: ~
return_path: rp.yourdomain.com
route_domain: routes.yourdomain.com
track_domain: track.yourdomain.com
logging:
rails_log_enabled: true
stdout_logging: true
Caddyfile
# Caddyfile — Listmonk + Postal web UI + Billionmail + SMTP submission proxy
# Listmonk newsletter platform
newsletter.yourdomain.com {
reverse_proxy listmonk:9000 {
health_uri /health
health_interval 15s
}
encode gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Frame-Options DENY
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
}
}
# Postal web UI (delivery logs, bounce reports, DKIM key retrieval)
mail.yourdomain.com {
reverse_proxy postal:5000 {
health_uri /
health_interval 15s
}
encode gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Frame-Options SAMEORIGIN
X-Content-Type-Options nosniff
}
}
# Billionmail IP reputation dashboard
reputation.yourdomain.com {
reverse_proxy billionmail:8080 {
health_uri /health
health_interval 15s
}
encode gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Frame-Options DENY
X-Content-Type-Options nosniff
}
}
# SMTP submission port proxy (Listmonk → Postal, port 587)
# Caddy handles TLS on 587; Postal listens on plain SMTP internally
smtp.yourdomain.com:587 {
route {
reverse_proxy postal:587
}
}
.env file
# .env — keep out of version control (.gitignore this file)
# App versions
POSTAL_VERSION=3.3.6
LISTMONK_VERSION=v6.1.0
BILLIONMAIL_VERSION=latest
# Domain and dedicated IP
MAIL_DOMAIN=yourdomain.com
MAIL_IP=<your-dedicated-vps-ip>
# PostgreSQL (shared by Listmonk + Billionmail)
POSTGRES_PASSWORD=change-me-postgres-superuser-password
# Listmonk database user
LISTMONK_DB_PASSWORD=change-me-listmonk-db-password
# Listmonk admin UI credentials
LISTMONK_ADMIN_USER=admin
LISTMONK_ADMIN_PASSWORD=change-me-listmonk-admin-password
# Billionmail database user
BILLIONMAIL_DB_PASSWORD=change-me-billionmail-db-password
# Billionmail admin
BILLIONMAIL_ADMIN_EMAIL=admin@yourdomain.com
BILLIONMAIL_ADMIN_PASSWORD=change-me-billionmail-admin-password
BILLIONMAIL_SECRET_KEY= # generate: openssl rand -hex 32
# MySQL 8.0 (Postal only)
MYSQL_ROOT_PASSWORD=change-me-mysql-root-password
MYSQL_PASSWORD=change-me-postal-db-password
# RabbitMQ (Postal)
RABBITMQ_PASSWORD=change-me-rabbitmq-password
Generate secrets before deploying:
# Generate BILLIONMAIL_SECRET_KEY
openssl rand -hex 32
# All passwords: use a password manager or generate them similarly
openssl rand -base64 24
First-run setup (step by step)
Postal requires CLI initialization before the web UI can start. This is the most common source of errors — follow the steps in order.
Step 1 of 12: Set DNS records
Set all DNS records listed in the DNS setup section above. Start DNS propagation now — it runs in parallel with the rest of the setup.
Step 2 of 12: Populate .env
Fill in all values in .env. Replace every change-me-* placeholder and the <your-dedicated-vps-ip> value. Generate BILLIONMAIL_SECRET_KEY with openssl rand -hex 32.
Step 3 of 12: Update init-postgres.sh with literal passwords
Replace ${LISTMONK_DB_PASSWORD} and ${BILLIONMAIL_DB_PASSWORD} in init-postgres.sh with the actual password values you set in .env. The init script runs as postgres during container first start and does not read the .env file.
Step 4 of 12: Start the databases and RabbitMQ
docker compose up -d postgres mysql rabbitmq redis
docker compose ps # wait for all four to show (healthy)
Step 5 of 12: Initialize the Postal database
This step creates the Postal schema in MySQL. It uses the postal initialize CLI command:
docker compose run --rm postal initialize
# Expected output: "Initialization complete!"
# If you see "rabbitmq connection refused" — RabbitMQ is not yet healthy. Wait 30 seconds and retry.
Step 6 of 12: Create the Postal admin user
docker compose run --rm postal make-user
# The CLI prompts for: email address, first name, last name, password
# This creates your login for the Postal web UI
Step 7 of 12: Start Postal web server and workers
docker compose up -d postal postal-worker postal-cron
docker compose ps postal # wait for healthy
docker compose logs postal --follow # look for "Listening on port 5000"
Step 8 of 12: Configure Postal — create organization, server, and SMTP credential
Visit http://<your-vps-ip>:5000 (temporarily, before Caddy is up) or use an SSH tunnel:
ssh -L 5000:localhost:5000 user@<your-vps-ip>
# Then open http://localhost:5000
In the Postal web UI:
- Log in with the credentials from Step 6
- Create an Organization (your company name)
- Create a Mail Server under the organization (e.g., "Production")
- Go to Credentials → New Credential → type
SMTP→ note the generated username and password — you will use these in Listmonk's SMTP settings - Go to Domains → Add Domain → enter
yourdomain.com→ Postal generates your DKIM key
Step 9 of 12: Retrieve the DKIM public key and add it to DNS
In Postal → Domains → yourdomain.com → DNS Records. Copy the DKIM TXT record value and add it to your DNS:
postal._domainkey.yourdomain.com. TXT "v=DKIM1; k=rsa; p=<public-key-from-postal>"
Wait for DNS propagation, then click Check in the Postal domain panel. All records should show green.
Step 10 of 12: Start Listmonk and Billionmail
docker compose up -d listmonk billionmail
docker compose ps # wait for both to show (healthy)
Listmonk creates its schema on first start — check logs:
docker compose logs listmonk --follow
# Look for: "Starting Listmonk" and the admin address
Step 11 of 12: Start Caddy
docker compose up -d caddy
docker compose logs caddy --follow
# Caddy issues TLS certificates via Let's Encrypt — verify all domains are accessible:
# https://newsletter.yourdomain.com
# https://mail.yourdomain.com
# https://reputation.yourdomain.com
Step 12 of 12: Configure Listmonk to send via Postal
See the next section.
Connecting Listmonk to Postal (SMTP settings)
Listmonk connects to Postal as an SMTP client. The credentials come from Postal's Credentials panel (Step 8), not from any .env value.
- Log in to Listmonk at
https://newsletter.yourdomain.com - Go to Settings → SMTP
- Fill in the SMTP settings:
| Field | Value |
|---|---|
| Host | smtp.yourdomain.com |
| Port | 587 |
| Auth Protocol | LOGIN |
| Username | (SMTP credential username from Postal) |
| Password | (SMTP credential password from Postal) |
| TLS | STARTTLS |
| From email | newsletter@yourdomain.com |
| From name | Your newsletter name |
- Click Test connection — Postal should receive a test message and show it in Messages → All Messages
- Check the Postal message log to confirm DKIM signing: the message detail should show
DKIM: pass
Verify the full stack
# All services healthy
docker compose ps
# Postal delivery queue status (from inside the postal container)
docker compose exec postal postal status
# Test SMTP submission from the VPS command line
# swaks is a useful SMTP test tool
apt-get install -y swaks # if not already installed
swaks --to test@example.com \
--from newsletter@yourdomain.com \
--server smtp.yourdomain.com \
--port 587 \
--auth-user <postal-smtp-username> \
--auth-password <postal-smtp-password> \
--tls
# Check DKIM signing on a delivered test message
# Send to a Gmail address, open the email, click "Show original" → verify DKIM=PASS
# Live resource usage
docker stats --no-stream
IP warm-up schedule
A new dedicated IP has zero sender reputation with major mail providers. Sending too much too fast triggers spam filters and can get your IP blocked permanently. Follow this schedule strictly during the first 30 days:
| Period | Max sends/day | Action if bounce rate > 2% or spam complaints appear |
|---|---|---|
| Week 1, Days 1–3 | 50 emails/day | Stop immediately; audit list quality; remove all unverified addresses |
| Week 1, Days 4–7 | 200 emails/day | Stop; investigate; check Postal bounce logs for patterns |
| Week 2 | 500 emails/day | Reduce volume; review bounce categories in Postal |
| Week 3 | 2,000 emails/day | Pause if issues appear; check MXToolbox blacklist status |
| Week 4 | 5,000 emails/day | Continue warming; monitor Billionmail reputation dashboard daily |
| Week 5+ | Gradually increase | Use Billionmail warm-up schedule feature to automate throttling |
During warm-up, prioritize sending to your most engaged subscribers first — people who have previously opened emails from you, or who opted in recently. Gmail and Outlook weight engagement signals heavily when building IP reputation.
Billionmail's warm-up schedule feature can automatically cap outbound volume for each day of the schedule — configure this in the Billionmail admin panel under Warm-up → New Schedule.
Runtime footprint
Measured 2026-05-02 on local Docker (4 vCPU / 8 GB RAM).
| Service | Idle RAM | Peak RAM |
|---|---|---|
| listmonk | 256 MB | 380 MB |
| postal (web-server) | 380 MB | 550 MB |
| postal-worker | 240 MB | 380 MB |
| postal-cron | 80 MB | 120 MB |
| billionmail | 300 MB | 450 MB |
| postgres (Listmonk + Billionmail) | 256 MB | 380 MB |
| mysql 8.0 (Postal) | 512 MB | 650 MB |
| rabbitmq | 256 MB | 310 MB |
| redis | 50 MB | 70 MB |
| caddy | 15 MB | 25 MB |
| Total | 2,345 MB | 3,315 MB |
An 8 GB Managed VPS provides comfortable headroom above the 3.5 GB peak. The OS and Cloudflare Workers agent (if applicable) consume an additional ~400–600 MB. A 4 GB VPS is technically feasible but leaves no headroom for traffic spikes or MySQL buffer pool growth.
Cost comparison
Prices verified 2026-05-02. Verify current pricing at official sites before making purchasing decisions.
| Mailchimp Essentials | Postmark | SendGrid Essentials | This stack | |
|---|---|---|---|---|
| Monthly cost | $20/mo (500 contacts) | $15/mo (10k emails) | $19.95/mo (50k emails) | ~$30/mo VPS + ~$3/mo dedicated IP |
| Emails/month | 5,000 | 10,000 | 50,000 | Unlimited |
| Bounce handling | Yes | Yes | Yes | Yes (Postal) |
| DKIM signing | Yes | Yes | Yes | Yes (Postal) |
| IP reputation monitor | No | No | No | Yes (Billionmail) |
| Delivery logs | Limited | Yes | Yes | Yes (Postal) |
| Data ownership | No | No | No | Yes |
| Ops overhead | None | None | None | Significant |
The self-hosted stack becomes cost-effective relative to managed services at approximately 50k+ emails/month, only if you invest the time to manage deliverability. Below that volume, Postmark's per-email pricing is hard to beat on a time-adjusted basis.
When this stack isn't right for you
- You send fewer than 50k emails/month and your time is valuable — managed SMTP providers (Postmark, Mailgun, Amazon SES) cost less when you factor in the hours spent managing IP reputation, monitoring blacklists, and troubleshooting delivery.
- You need guaranteed inbox placement immediately — managed providers have warm IP pools. A fresh dedicated IP takes 4–8 weeks to build reputation, during which deliverability is unpredictable.
- You need marketing automation beyond newsletters — Listmonk is a newsletter platform, not a marketing automation tool. For behavioral triggers, lead scoring, and multi-step drip campaigns, see Mautic or the Billionmail + Mautic stack.
- Your VPS provider blocks port 25 — confirm with Liquid Web support before ordering. If port 25 cannot be opened, Postal cannot receive bounces or deliver directly to recipients' mail servers.
- Postal's MySQL dependency is a blocker — if you want a PostgreSQL-only stack, see Haraka or MailCow as alternative MTAs. Postal explicitly does not support PostgreSQL.
Troubleshooting
"Postal fails to start with 'rabbitmq connection refused'"
Postal connects to RabbitMQ during startup. If RabbitMQ is not fully ready when Postal starts, the connection fails. The docker compose up healthcheck dependency handles this for normal starts, but the postal initialize one-off command does not respect healthchecks.
Fix: wait until RabbitMQ shows (healthy) in docker compose ps, then re-run:
docker compose ps rabbitmq # confirm (healthy)
docker compose run --rm postal initialize
If RabbitMQ takes longer than 60 seconds to become healthy, check logs:
docker compose logs rabbitmq --follow
# Confirm you see: "Server startup complete"
"Listmonk emails show 'authentication failed' when sending via Postal"
The SMTP credentials in Listmonk must be the Postal SMTP credential — not the Postal web UI login and not any .env value. Postal generates a separate SMTP username and password per credential under Settings → Credentials.
Fix: In Postal web UI → your Mail Server → Credentials → New Credential → type SMTP → note the auto-generated username and password. In Listmonk → Settings → SMTP → paste those values as Username and Password. The host should be smtp.yourdomain.com, port 587, auth LOGIN.
"Emails land in spam despite DKIM passing"
DKIM passing is necessary but not sufficient. Check these in order:
-
PTR record: Run
nslookup <YOUR_VPS_IP>— the result must returnsmtp.yourdomain.com. If it returns the generic VPS hostname (e.g.,vps-123.liquidweb.com), contact Liquid Web support to set the PTR record for your dedicated IP. -
SPF alignment: The
From:domain in your emails must match the domain in your SPF record. If your From isnewsletter@yourdomain.com, your SPF must coveryourdomain.com. -
DMARC policy: A missing DMARC record causes Gmail to treat messages with more suspicion. Confirm
_dmarc.yourdomain.comTXT record is published. -
IP reputation baseline: A brand-new IP always triggers some spam filters initially. Check MXToolbox Blacklist and submit your IP for allowlisting at major blocklist operators: Spamhaus DBL, Barracuda Central, and Validity/Return Path. These allowlisting processes take 1–5 business days.
-
List quality: High bounce rates (> 2%) and spam complaints train providers to filter your IP. Verify your subscriber list with an email validation service before the first send.
Yes. Postal supports multiple SMTP credentials and multiple mail servers within an organization. Create a second credential for your application (e.g., a web app sending password resets) under the same Postal mail server. Set your application's SMTP configuration to `smtp.yourdomain.com:587` with those credentials. Postal logs all messages separately so you can track delivery, bounces, and open rates per credential. Separate from Listmonk's newsletter sending, transactional email has a lower bounce tolerance — keep your transactional credential separate from your bulk newsletter credential to avoid one affecting the other's reputation metrics.
Postal processes inbound bounces on port 25 and exposes them via a webhook. In Postal → your Mail Server → **Webhooks** → **New Webhook** → set the URL to `https://newsletter.yourdomain.com/webhooks/bounce` and select the `MessageBounced` and `MessageDeliveryFailed` event types. Listmonk processes the incoming webhook and automatically marks bounced subscribers as unsubscribed. Hard bounces (permanent failures, e.g., address does not exist) are processed immediately. Soft bounces (temporary failures, e.g., mailbox full) are tracked across multiple attempts before marking.
Port 25 is the traditional SMTP port used for server-to-server mail transfer — when Gmail delivers an email to your server, it connects to port 25. Port 587 is the submission port used by email clients and applications (like Listmonk) to submit messages for delivery. In this stack: Listmonk connects to Postal on port 587 (submission), and Postal connects to recipients' mail servers on port 25 (delivery). Inbound bounces also arrive on port 25. Both ports must be accessible — port 587 is proxied via Caddy, and port 25 is mapped directly from the Postal container. Confirm Liquid Web has port 25 open outbound before ordering.

Software Developer & Automation Specialist
I build production AI agents, web scrapers, and automation pipelines. Most of what I publish here comes from the actual problems they run into: proxies that get banned, anti-bot stacks that fingerprint your client, RAG that drifts when the underlying data moves. Stack: Python, TypeScript, Go, FastAPI, LangChain, Crawlee, Playwright, deployed on AWS, GCP, and Cloudflare.
