How to Self-Host Postal — Open Source Email 2026
How to Self-Host Postal — Open Source Email 2026
TL;DR
Postal is a full-featured, open source email sending platform — a direct alternative to Mailgun and SendGrid for applications that need transactional email via API or SMTP. It handles millions of emails, provides open/click tracking, bounce handling, spam filtering, webhooks, and a clean web dashboard. Self-hosting eliminates per-email fees: Mailgun charges $35/month for 50K emails; a Hetzner VPS running Postal handles 500K+ emails/month for ~€10/month. The catch: email deliverability requires careful DNS setup (DKIM, SPF, DMARC, rDNS) and a clean IP reputation.
Key Takeaways
- Postal: 14K+ GitHub stars, written in Ruby, production-grade since 2016, used by thousands of companies
- API + SMTP: sends via REST API or SMTP relay — drop-in replacement for Mailgun/SendGrid credentials
- Tracking: open tracking (pixel), click tracking (URL rewriting), bounce handling, spam complaints all built-in
- Multi-domain/multi-tenant: one Postal instance can host multiple organizations, servers, and sending domains
- Deliverability essentials: your server IP must have rDNS, each domain needs SPF + DKIM + DMARC — Postal helps configure all of these
- Alternatives: Listmonk (newsletters/bulk, not transactional), Stalwart (receives email too), Haraka (Node.js, more DIY)
Why Self-Host Email Sending?
Mailgun, SendGrid, and Postmark are reliable but expensive at scale:
| Provider | Price per 50K emails |
|---|---|
| Mailgun | $35/month |
| SendGrid | $19.95/month |
| Postmark | $50/month |
| Postal (self-hosted) | ~€10/month (VPS) |
Beyond cost, self-hosting gives you:
- IP control: your dedicated IP builds its own reputation over time
- No vendor lock-in: your sending infrastructure isn't subject to account suspensions or policy changes
- Custom tracking domains: all tracking links go through your own domain
- Audit logs: every sent/bounced/opened email stored in your database
- Webhook control: delivery events sent to your endpoints, not stored in a third-party system
Prerequisites
- VPS: 2 vCPU / 4GB RAM minimum (dedicated IP address — critical for email)
- Dedicated IP: must not be on any blacklists (check mxtoolbox.com/blacklists)
- Domain: a sending domain you control (e.g.,
mail.example.com) - Reverse DNS: your IP's rDNS must resolve to your sending domain (set via your VPS provider's control panel)
- Docker + Docker Compose: installed on the server
- Open ports: 25 (SMTP), 587 (submission), 443 (web)
Important: Many cloud providers block port 25 by default (AWS, GCP, Hetzner). Check before starting. Hetzner allows port 25 after a brief request form. AWS requires contacting support. DigitalOcean allows it by default on dedicated Droplets.
Docker Compose Setup
Create /opt/postal/docker-compose.yml:
version: '3.8'
services:
mariadb:
image: mariadb:10.11
restart: always
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MYSQL_DATABASE: postal
MYSQL_USER: postal
MYSQL_PASSWORD: ${DB_PASSWORD}
volumes:
- ./mariadb:/var/lib/mysql
rabbitmq:
image: rabbitmq:3.13-management-alpine
restart: always
environment:
RABBITMQ_DEFAULT_USER: postal
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD}
volumes:
- ./rabbitmq:/var/lib/rabbitmq
postal:
image: ghcr.io/postalserver/postal:latest
restart: always
ports:
- "25:25"
- "127.0.0.1:5000:5000" # web UI (proxy via nginx)
volumes:
- ./config:/config
depends_on:
- mariadb
- rabbitmq
command: postal run
postal-worker:
image: ghcr.io/postalserver/postal:latest
restart: always
volumes:
- ./config:/config
depends_on:
- mariadb
- rabbitmq
command: postal worker
postal-cron:
image: ghcr.io/postalserver/postal:latest
restart: always
volumes:
- ./config:/config
depends_on:
- mariadb
- rabbitmq
command: postal cron
Configuration File
Create /opt/postal/config/postal.yml:
version: 1
web_server:
host: postal.example.com
protocol: https
port: 443
main_db:
host: mariadb
username: postal
password: "${DB_PASSWORD}"
database: postal
message_db:
host: mariadb
username: postal
password: "${DB_PASSWORD}"
prefix: postal
rabbitmq:
host: rabbitmq
username: postal
password: "${RABBITMQ_PASSWORD}"
vhost: /postal
smtp_server:
port: 25
tls_enabled: true
tls_certificate_path: /config/smtp.crt
tls_private_key_path: /config/smtp.key
dns:
mx_records:
- postal.example.com
smtp_server_hostname: postal.example.com
spf_include: spf.postal.example.com
dkim_identifier: postal
return_path: rp.postal.example.com
route_domain: routes.postal.example.com
logging:
rails_log_enabled: true
Create .env:
DB_ROOT_PASSWORD=strong-root-password
DB_PASSWORD=strong-postal-password
RABBITMQ_PASSWORD=strong-rabbit-password
Initial Setup
# Initialize the database and create admin user
docker compose run --rm postal postal initialize
# Prompts for admin email and password
# Start all services
docker compose up -d
# Generate DKIM keys for your first domain (run after setup)
docker compose exec postal postal generate-dkim postal.example.com
DNS Configuration
Postal generates exact DNS records after setup. Configure these in your DNS provider:
Required records for example.com sending domain:
; SPF — authorizes Postal's IP to send for example.com
example.com. TXT "v=spf1 include:spf.postal.example.com ~all"
; DKIM — cryptographic signature (copy from Postal dashboard)
postal._domainkey.example.com. TXT "v=DKIM1; k=rsa; p=MIGfMA0GCS..."
; DMARC — policy for SPF/DKIM failures
_dmarc.example.com. TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com"
; Return path (bounces)
rp.example.com. CNAME rp.postal.example.com.
; Postal server MX (for receiving bounces)
postal.example.com. MX 10 postal.example.com.
postal.example.com. A <your-server-ip>
Reverse DNS: In your VPS control panel, set the rDNS (PTR record) for your server IP to postal.example.com. This is mandatory — major email providers reject connections from IPs without matching rDNS.
Sending via API
Postal provides a simple HTTP API compatible with most email libraries:
// Send via Postal API
const response = await fetch('https://postal.example.com/api/v1/send/message', {
method: 'POST',
headers: {
'X-Server-API-Key': 'your-api-key-from-postal-dashboard',
'Content-Type': 'application/json',
},
body: JSON.stringify({
to: ['user@example.com'],
from: 'noreply@yourdomain.com',
subject: 'Welcome to our app!',
html_body: '<h1>Welcome!</h1><p>Thanks for signing up.</p>',
text_body: 'Welcome! Thanks for signing up.',
reply_to: 'support@yourdomain.com',
headers: {
'X-Custom-Header': 'value',
},
}),
})
const result = await response.json()
// result.data.messages['user@example.com'].id — message ID for tracking
Sending via SMTP
For existing apps that use SMTP, configure Postal as your SMTP server:
SMTP_HOST=postal.example.com
SMTP_PORT=587
SMTP_USER=<credential-key-from-postal-dashboard>
SMTP_PASS=<credential-secret>
SMTP_FROM=noreply@yourdomain.com
This works as a drop-in replacement for Mailgun, SendGrid, or SES SMTP credentials — change the host/user/pass and you're done.
Webhooks for Delivery Events
Configure webhooks in the Postal dashboard → HTTP Endpoints:
// app/api/postal-webhook/route.ts
export async function POST(req: Request) {
const body = await req.json()
switch (body.event) {
case 'MessageDelivered':
await markEmailDelivered(body.payload.message.id)
break
case 'MessageBounced':
await handleBounce(body.payload.message.to, body.payload.bounce.type)
break
case 'MessageLinkClicked':
await recordClick(body.payload.message.id, body.payload.url)
break
case 'MessageOpened':
await recordOpen(body.payload.message.id)
break
case 'SpamComplaint':
await unsubscribeUser(body.payload.message.to)
break
}
return new Response('OK')
}
Postal signs webhooks with an HMAC-SHA256 signature — verify it in production:
import { createHmac } from 'crypto'
function verifyPostalSignature(payload: string, signature: string, secret: string): boolean {
const expected = createHmac('sha256', secret).update(payload).digest('hex')
return signature === expected
}
Rate Limiting and Queue Management
Postal processes email delivery via RabbitMQ queues. Configure sending limits to protect your IP reputation and prevent being blacklisted:
In the Postal web dashboard → Servers → your server → Settings:
- Send limit: max emails per hour (start at 200/hr, increase as reputation builds)
- Send limit period: the window for the send limit (1 hour recommended)
- Bounce rate threshold: auto-suspend sending if bounce rate exceeds X% (5% is a safe threshold)
For high-volume sends, spread sending over time rather than blasting all at once:
// Stagger bulk sends with Sidekiq/BullMQ
async function sendCampaignEmail(recipients: string[], templateId: string) {
const BATCH_SIZE = 100
const DELAY_MS = 10_000 // 10 seconds between batches
for (let i = 0; i < recipients.length; i += BATCH_SIZE) {
const batch = recipients.slice(i, i + BATCH_SIZE)
await Promise.all(batch.map(email => sendViaPostal(email, templateId)))
if (i + BATCH_SIZE < recipients.length) {
await new Promise(resolve => setTimeout(resolve, DELAY_MS))
}
}
}
Monitoring Postal
Check Postal's health via its built-in dashboard:
- Messages dashboard: real-time view of queued, held, delivered, bounced, and failed messages
- SMTP logs: raw SMTP conversation logs for every delivery attempt
- Suppression list: addresses Postal has auto-suppressed due to hard bounces or spam complaints
Set up external monitoring with Uptime Kuma or similar:
# Uptime Kuma monitor for Postal web
- name: Postal Web UI
url: https://postal.example.com
type: http
interval: 60
# TCP monitor for SMTP port
- name: Postal SMTP Port 25
host: postal.example.com
port: 25
type: tcp
interval: 60
For metrics, Postal exposes a /metrics endpoint compatible with Prometheus:
# prometheus.yml scrape config
scrape_configs:
- job_name: postal
static_configs:
- targets: ['postal.example.com:5000']
metrics_path: /metrics
bearer_token: your-postal-api-key
Troubleshooting Common Issues
Emails landing in spam:
- Run your domain through mail-tester.com to get a spam score and specific recommendations
- Check that DKIM signature is valid:
dig +short postal._domainkey.yourdomain.com TXT - Verify DMARC policy isn't blocking legitimate sends: check Google Postmaster Tools for delivery rate
- Ensure your server IP isn't on any blacklists: mxtoolbox.com/blacklists
Port 25 connection refused:
# Test if port 25 is accessible from outside
nmap -p 25 postal.example.com
# If filtered: check VPS firewall and provider port 25 policy
ufw allow 25/tcp
High bounce rate causing auto-suspension:
# Check bounce types in Postal dashboard
# Hard bounces (invalid address) — remove from your list permanently
# Soft bounces (mailbox full, temp failures) — retry after 24h
# Reset suspension after fixing the root cause
docker compose exec postal postal unsuspend-server <server-id>
RabbitMQ queue backup:
# Check queue depths via management UI at :15672
# Or via CLI:
docker compose exec rabbitmq rabbitmqctl list_queues name messages
# If postal.delivery queue is backing up, restart workers:
docker compose restart postal-worker
Deliverability Best Practices
- Warm up your IP: Don't blast 10,000 emails on day one. Start with 50-100/day and ramp up over 2-4 weeks
- Monitor blacklists: check mxtoolbox.com/blacklists weekly for the first month
- Handle bounces: Postal auto-disables addresses after hard bounces — honor this in your app
- Respect unsubscribes: process SpamComplaint webhooks immediately
- Use consistent From address: changing from addresses hurts reputation
- Send relevant content: low engagement → spam folders → reputation damage
Backup and Upgrades
Daily backup script:
#!/bin/bash
DATE=$(date +%Y%m%d)
BACKUP_DIR=/opt/backups/postal
mkdir -p $BACKUP_DIR
# Dump both postal databases
docker compose exec -T mariadb mysqldump -u root -p${DB_ROOT_PASSWORD} postal > "$BACKUP_DIR/postal-$DATE.sql"
docker compose exec -T mariadb mysqldump -u root -p${DB_ROOT_PASSWORD} --databases $(docker compose exec -T mariadb mysql -u root -p${DB_ROOT_PASSWORD} -e "SHOW DATABASES LIKE 'postal-msg-%';" --skip-column-names | tr '\n' ' ') > "$BACKUP_DIR/postal-msg-$DATE.sql"
gzip "$BACKUP_DIR/postal-$DATE.sql"
gzip "$BACKUP_DIR/postal-msg-$DATE.sql"
# Keep 14 days
find $BACKUP_DIR -name "*.gz" -mtime +14 -delete
Note: Postal creates a separate database per mail server (named postal-msg-<id>). Back up all of them.
Upgrading Postal:
cd /opt/postal
# Pull latest image
docker compose pull postal postal-worker postal-cron
# Run any pending database migrations
docker compose run --rm postal postal upgrade
# Restart all Postal services
docker compose up -d --force-recreate postal postal-worker postal-cron
Always check the Postal changelog before upgrading. Major releases sometimes require manual migration steps for database schema changes.
Methodology
- Postal documentation: docs.postalserver.io
- GitHub: github.com/postalserver/postal (14K+ stars)
- Tested with Postal 3.x, Docker Compose, MariaDB 10.11, on Hetzner CX22
Browse open source alternatives to email marketing and transactional email tools on OSSAlt.
Related: How to Self-Host Listmonk — Mailchimp Alternative Email Newsletter 2026 · Best Open Source Alternatives to Resend 2026