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": ["[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:
| 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.
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