Self-Hosting Postal with Docker Compose

What Is Postal?

Postal is an open-source mail delivery platform designed for sending and receiving email at scale. Unlike full-featured mail servers like Mailcow or Mailu that focus on personal email, Postal is built for transactional and bulk email delivery — think self-hosted SendGrid or Mailgun. It provides a web UI for managing organizations, servers, and domains, plus an HTTP API for programmatic sending, click/open tracking, bounce handling, and delivery webhooks.

Prerequisites

  • A Linux server (Ubuntu 22.04+ recommended) with at least 2 GB of RAM
  • Docker and Docker Compose installed (guide)
  • 10 GB of free disk space
  • A domain name with DNS access (MX, SPF, DKIM, DMARC records required)
  • Port 25 open outbound (check with your hosting provider)
  • A reverse proxy for HTTPS access to the web UI (Reverse Proxy Setup)

Docker Compose Configuration

Create a docker-compose.yml file:

services:
  postal-web:
    image: ghcr.io/postalserver/postal:3.3.5
    container_name: postal-web
    command: postal web-server
    restart: unless-stopped
    ports:
      - "5000:5000"
    volumes:
      - ./config/postal.yml:/config/postal.yml:ro
      - ./config/signing.key:/config/signing.key:ro
    depends_on:
      mariadb:
        condition: service_healthy
    environment:
      - WAIT_FOR_TARGETS=mariadb:3306
      - WAIT_FOR_TIMEOUT=90

  postal-smtp:
    image: ghcr.io/postalserver/postal:3.3.5
    container_name: postal-smtp
    command: postal smtp-server
    restart: unless-stopped
    ports:
      - "25:25"
    volumes:
      - ./config/postal.yml:/config/postal.yml:ro
      - ./config/signing.key:/config/signing.key:ro
    depends_on:
      mariadb:
        condition: service_healthy

  postal-worker:
    image: ghcr.io/postalserver/postal:3.3.5
    container_name: postal-worker
    command: postal worker
    restart: unless-stopped
    volumes:
      - ./config/postal.yml:/config/postal.yml:ro
      - ./config/signing.key:/config/signing.key:ro
    depends_on:
      mariadb:
        condition: service_healthy

  mariadb:
    image: mariadb:11.7
    container_name: postal-mariadb
    restart: unless-stopped
    environment:
      MARIADB_ROOT_PASSWORD: ChangeThisRootPassword
      MARIADB_DATABASE: postal
    volumes:
      - postal_db:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  postal_db:

Postal uses three separate processes from the same image:

ProcessPurpose
web-serverAdmin UI and HTTP API on port 5000
smtp-serverSMTP listener on port 25 for sending and receiving
workerBackground job processor for deliveries, retries, and webhooks

Generate Configuration Files

Create the config directory and signing key:

mkdir -p config

# Generate the RSA signing key (used for DKIM and internal signing)
openssl genrsa -out config/signing.key 2048

# Generate a Rails secret key
openssl rand -hex 64

Create config/postal.yml:

postal:
  web_hostname: postal.example.com
  web_protocol: https
  smtp_hostname: smtp.example.com
  secret_key: PASTE_YOUR_64_CHAR_HEX_SECRET_HERE

main_db:
  host: mariadb
  port: 3306
  username: root
  password: ChangeThisRootPassword
  database: postal

message_db:
  host: mariadb
  port: 3306
  username: root
  password: ChangeThisRootPassword
  prefix: postal_msg

worker:
  threads: 4

logging:
  enabled: true

smtp_server:
  port: 25

Initialize the Database

Before first use, initialize the database schema and create an admin user:

# Initialize database tables
docker compose run --rm postal-web postal initialize

# Create the first admin user
docker compose run --rm postal-web postal make-user

Follow the prompts to set the admin email and password. Then start all services:

docker compose up -d

Initial Setup

  1. Access the web UI at http://your-server:5000 (or behind your reverse proxy at https://postal.example.com)

  2. Log in with the admin credentials you created during initialization

  3. Create an organization — this groups related mail servers. Use your company or project name.

  4. Create a mail server within the organization — this is a logical mail server with its own domains, credentials, and tracking settings.

  5. Add a domain — enter your sending domain and configure the DNS records Postal generates:

RecordTypePurpose
SPFTXTAuthorizes your server to send email for the domain
DKIMTXTSigns outgoing emails (Postal generates the key)
DMARCTXTPolicy for handling authentication failures
Return PathCNAMEBounce handling subdomain
Route DomainMXFor receiving email (if needed)
  1. Create SMTP credentials — under the mail server, create a credential. This gives you SMTP username and password for sending.

Configuration

Sending Email via SMTP

Use the credentials from Postal in any application:

SMTP Host: smtp.example.com
SMTP Port: 25
Username: (from Postal credentials)
Password: (from Postal credentials)

Sending Email via HTTP API

Postal provides an HTTP API for sending:

curl -X POST https://postal.example.com/api/v1/send/message \
  -H "X-Server-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": ["recipient@example.com"],
    "from": "sender@example.com",
    "subject": "Test Email",
    "plain_body": "This is a test email from Postal."
  }'

Webhook Configuration

Postal can notify your application of delivery events via webhooks:

  • MessageSent — email was accepted by the receiving server
  • MessageBounced — email bounced (hard or soft)
  • MessageDeliveryFailed — delivery permanently failed
  • MessageLinkClicked — recipient clicked a tracked link
  • MessageLoaded — recipient opened the email (pixel tracking)

Configure webhooks in the mail server settings under “Webhooks.”

Worker Threads

Adjust worker concurrency in postal.yml:

worker:
  threads: 4    # Increase for higher throughput

Each thread handles one delivery at a time. For small installations, 2-4 threads is sufficient. For high-volume sending, increase to 8-16 and consider running multiple worker containers.

Reverse Proxy

Postal’s web UI runs on port 5000. Place it behind a reverse proxy for HTTPS.

For Caddy:

postal.example.com {
    reverse_proxy postal-web:5000
}

For Nginx:

server {
    server_name postal.example.com;
    location / {
        proxy_pass http://postal-web:5000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

See Reverse Proxy Setup for full configuration.

Backup

Back up these components for full disaster recovery:

ComponentLocationPriority
MariaDB datapostal_db volumeCritical
postal.yml config./config/postal.ymlCritical
Signing key./config/signing.keyCritical
# Database backup
docker exec postal-mariadb mysqldump -u root -p --all-databases > postal-backup.sql

See Backup Strategy for a comprehensive approach.

Troubleshooting

Emails Not Delivering

Symptom: Messages show as “Held” or “Failed” in the Postal web UI. Fix: Check port 25 outbound connectivity: telnet smtp.gmail.com 25. Many cloud providers block port 25 by default. Request an unblock from your provider or configure a relay host.

DKIM Verification Failing

Symptom: Receiving servers report DKIM failures. Fix: Verify the DKIM DNS record matches what Postal generated. Check with: docker compose run --rm postal-web postal default-dkim-record. Ensure the signing key file hasn’t changed since DNS was configured.

Database Connection Errors on Startup

Symptom: Postal containers crash with database connection errors. Fix: Ensure MariaDB is fully ready before Postal starts. The depends_on with service_healthy and WAIT_FOR_TARGETS environment variable handle this, but if MariaDB is slow to initialize, increase WAIT_FOR_TIMEOUT.

Web UI Shows 500 Error

Symptom: Internal server error when accessing the web UI. Fix: Check if the database was initialized: docker compose run --rm postal-web postal initialize. Also verify secret_key in postal.yml is set — without it, Rails sessions fail.

Resource Requirements

ResourceMinimumRecommended
RAM1 GB2 GB
CPU1 core2 cores
Disk5 GB20 GB+ (depends on message retention)

Postal is relatively lightweight compared to full mail servers like Mailcow. The three-process architecture (web, smtp, worker) shares the same codebase, keeping total memory usage moderate.

Verdict

Postal fills a specific niche: self-hosted transactional email delivery. If you’re sending application notifications, marketing emails, or automated messages and want to stop paying SendGrid or Mailgun, Postal is the best self-hosted option. The web UI is clean, the API is comprehensive, and delivery tracking (opens, clicks, bounces) works out of the box.

Don’t use Postal as a personal email server — it has no IMAP access, no webmail for reading email, and no calendar/contacts. For personal email, use Mailcow or Mailu. For transactional email delivery at scale, Postal is the right choice.

Frequently Asked Questions

Can I use Postal as my personal email server?

No. Postal is designed for outbound transactional and bulk email delivery — it’s a self-hosted SendGrid/Mailgun, not a personal mailbox. It has no IMAP access, no webmail for reading email, and no calendar/contacts. For personal email, use Mailcow or Mailu. Postal handles the sending side: application notifications, marketing emails, and automated messages.

Will my emails end up in spam?

Deliverability depends on proper DNS configuration. You need SPF, DKIM, and DMARC records configured correctly. Postal generates DKIM keys automatically — add the TXT record it provides to your DNS. Additionally, many cloud providers block port 25 by default. Check with your provider and request an unblock if needed. New sending IPs have no reputation — warm them up gradually.

How does Postal compare to Mailcow?

Mailcow is a full personal email server — it includes IMAP, webmail (SOGo), anti-spam, and calendar/contacts. Postal is an outbound-only mail delivery platform for sending transactional and bulk emails. They serve completely different purposes. You might use both: Mailcow for your personal/team email, and Postal for sending application notifications.

Does Postal support webhooks for delivery tracking?

Yes. Postal can send webhooks for email delivery events — sent, delivered, bounced, held, and clicked. Configure webhook URLs per organization or server in the web UI. The webhook payloads include delivery details, bounce codes, and click tracking data. This is essential for integrating Postal with your application’s email delivery pipeline.

Can Postal handle incoming email?

Yes, Postal can receive inbound email and process it via HTTP webhooks. Configure inbound routing rules that forward received messages to your application’s webhook endpoint as HTTP POST requests. This is useful for reply processing and support workflows, but Postal does not store received emails like a mailbox server.

How does Postal’s click and open tracking work?

Postal rewrites links in HTML emails to route through its tracking proxy. When a recipient clicks a link, Postal logs the click and redirects to the original URL. Open tracking uses a 1x1 tracking pixel embedded in HTML emails. Both features are configurable per organization and can be disabled if you don’t need them. Tracking data is visible in the Postal web dashboard.

Comments