Skip to main content

How to Self-Host Postal — Open Source Email 2026

·OSSAlt Team
postalemailself-hostingmailgun-alternativesendgrid-alternative

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:

ProviderPrice 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:

  1. Messages dashboard: real-time view of queued, held, delivered, bounced, and failed messages
  2. SMTP logs: raw SMTP conversation logs for every delivery attempt
  3. 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

  1. 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
  2. Monitor blacklists: check mxtoolbox.com/blacklists weekly for the first month
  3. Handle bounces: Postal auto-disables addresses after hard bounces — honor this in your app
  4. Respect unsubscribes: process SpamComplaint webhooks immediately
  5. Use consistent From address: changing from addresses hurts reputation
  6. 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

Comments