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": ["[email protected]"],
    "from": "[email protected]",
    "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.

Comments