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:
| Process | Purpose |
|---|---|
web-server | Admin UI and HTTP API on port 5000 |
smtp-server | SMTP listener on port 25 for sending and receiving |
worker | Background 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
-
Access the web UI at
http://your-server:5000(or behind your reverse proxy athttps://postal.example.com) -
Log in with the admin credentials you created during initialization
-
Create an organization — this groups related mail servers. Use your company or project name.
-
Create a mail server within the organization — this is a logical mail server with its own domains, credentials, and tracking settings.
-
Add a domain — enter your sending domain and configure the DNS records Postal generates:
| Record | Type | Purpose |
|---|---|---|
| SPF | TXT | Authorizes your server to send email for the domain |
| DKIM | TXT | Signs outgoing emails (Postal generates the key) |
| DMARC | TXT | Policy for handling authentication failures |
| Return Path | CNAME | Bounce handling subdomain |
| Route Domain | MX | For receiving email (if needed) |
- 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:
| Component | Location | Priority |
|---|---|---|
| MariaDB data | postal_db volume | Critical |
postal.yml config | ./config/postal.yml | Critical |
| Signing key | ./config/signing.key | Critical |
# 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
| Resource | Minimum | Recommended |
|---|---|---|
| RAM | 1 GB | 2 GB |
| CPU | 1 core | 2 cores |
| Disk | 5 GB | 20 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.
Related
Get self-hosting tips in your inbox
Get the Docker Compose configs, hardware picks, and setup shortcuts we don't put in articles. Weekly. No spam.
Comments