Postal is a self-hosted mail delivery platform for applications, the open source answer to Mailgun or SendGrid. Your apps hand it mail over SMTP or its HTTP API, and it signs, queues and delivers it, retries failures, records every message and fires webhooks on bounces. It does not give users mailboxes to read, so for staff email you want Postfix and Dovecot or a suite such as iRedMail instead.
Postal 3 runs entirely in Docker and no longer needs RabbitMQ, so older guides that install it from packages with a message broker are out of date. We ran this Postal mail server setup on Ubuntu 26.04 in October 2026 with Postal 3.3.7, MariaDB 12.3 and Caddy 2.11, built an organization, a mail server and a sending domain, and sent a test message through its SMTP port with swaks.
How Postal fits together
Postal runs as three containers from one image, plus a database and a web proxy that you start yourself. All of them use the host’s network, so they listen on the server’s own ports:

postal-web serves the admin interface and the HTTP API on 127.0.0.1:5000, postal-smtp accepts mail on port 25, and postal-worker works through the queue, delivering outbound mail and sending webhooks. MariaDB holds the configuration in a postal database plus one message database per mail server (ours was postal-server-1), so disk use grows with the mail you keep.
Prerequisites
Postal’s docs ask for more than a typical web app, because the message databases and the worker both stay busy under load:
- 4 GB RAM, 2 CPU cores, 25 GB disk
- a dedicated Ubuntu 26.04 server with a public IP
- outbound port 25 allowed by your hosting provider
- a hostname such as
postal.example.comand access to its DNS
Check outbound port 25 before anything else, because many cloud providers block it on new accounts and Postal is useless without it. A quick test from the server is nc -zv -w5 gmail-smtp-in.l.google.com 25; if it times out, ask your provider to lift the block. We built ours on a LAN VM with 4 GB, using a sslip.io name in place of a real domain, which is enough to test everything except delivery to the internet.
Install Docker and the Postal helper
Postal needs Docker Engine with the Compose plugin from Docker’s repository. Add the repository. The codename lookup picks the right suite for your release:
sudo apt update
sudo apt install -y ca-certificates curl git jq
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
printf "Types: deb\nURIs: https://download.docker.com/linux/ubuntu\nSuites: %s\nComponents: stable\nSigned-By: /etc/apt/keyrings/docker.asc\n" "$(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")" | sudo tee /etc/apt/sources.list.d/docker.sources
Install the engine and plugins. The Postal wrapper calls docker compose, so the Compose plugin is required, not optional. The Docker CE guide for Ubuntu 26.04 walks through each part if you want the background:
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Postal ships its own wrapper script that drives Docker Compose for you. Clone the installer repository to the path the script expects and link the command into your path:
sudo git clone https://github.com/postalserver/install /opt/postal/install
sudo ln -s /opt/postal/install/bin/postal /usr/bin/postal
Running postal with no arguments lists what it can do, from start and status to make-user and default-dkim-record, which you will use below. The same script handles upgrades later, so keep the /opt/postal/install clone rather than deleting it after setup.
Run MariaDB in a container
Postal requires MariaDB 10.6 or newer and does not support MySQL. The simplest setup is MariaDB in its own container, bound to localhost so it never faces the network. Two choices belong in the run command itself, because you cannot fix them later without moving data: a named volume, so the database survives the container being recreated, and a version tag, because MariaDB does not support going back a major version. We used the 12.3 LTS tag. Generate a strong root password first instead of the postal placeholder the docs show:
export DB_PASS=$(openssl rand -hex 16)
echo "$DB_PASS"
sudo docker run -d --name postal-mariadb -p 127.0.0.1:3306:3306 --restart always -v postal-mariadb-data:/var/lib/mysql -e MARIADB_DATABASE=postal -e MARIADB_ROOT_PASSWORD="$DB_PASS" mariadb:12.3
Save the printed password somewhere safe, because DB_PASS disappears when you log out and the next section needs it. Then confirm the server version:
sudo docker exec postal-mariadb mariadb --version
That runs the client inside the container, which reports the server build it ships with:
mariadb from 12.3.3-MariaDB, client 15.2 for debian-linux-gnu (x86_64) using EditLine wrapper
We checked that the volume does its job: after creating the admin user, we deleted the container with sudo docker rm -f postal-mariadb, ran the same command again, and the user was still in the database. Without -v, MariaDB writes to an anonymous volume, and the same recreate would have started from an empty database. Postal also ran on the untagged image (13.0.2 at the time), but an untagged container can jump a major version the next time it is recreated. The Postal docs add one tuning note: innodb_log_file_size should be at least ten times your largest message, which matters if you raise Postal’s message size limit.
Generate the Postal configuration
postal bootstrap writes the config for the hostname you’ll use for the web interface, so pick it now and point its A record at the server before you start Caddy later:
sudo postal bootstrap postal.example.com
It checks for the latest release and creates three files in /opt/postal/config. That folder is mounted into the containers as /config, so any path you add to postal.yml later starts with /config:
Latest version is: 3.3.7
=> Creating /opt/postal/config/postal.yml
=> Creating /opt/postal/config/Caddyfile
=> Creating signing private key
The generated postal.yml uses password: postal for both the main database and the message databases, and Postal will not connect until both match MariaDB. Replace them with the password from the previous step; if you opened a new shell since, run export DB_PASS= with the saved value first, or the edit writes an empty password:
sudo sed -i "s/^ password: postal$/ password: $DB_PASS/" /opt/postal/config/postal.yml
sudo grep -c "password: $DB_PASS" /opt/postal/config/postal.yml
The count must be 2. The same file holds the dns block Postal uses when it tells you which records to create, with names derived from your hostname:
dns:
mx_records:
- mx.postal.example.com
spf_include: spf.postal.example.com
return_path_domain: rp.postal.example.com
route_domain: routes.postal.example.com
track_domain: track.postal.example.com
Leave those names as generated unless you have a reason to change them, because the DNS records later in this guide follow them exactly. Postal also prints these names on every domain’s setup page, so changing them after you publish DNS means changing records in every sending domain too.
Initialize Postal and create the admin user
Initializing pulls the Postal image and loads the database schema. It took 44 seconds on our VM, most of it the image download:
sudo postal initialize
The last lines confirm it reached MariaDB and created the schema. A wrong password in postal.yml fails right here, so this is the step that proves the earlier edit:
Initializing database
Loading config from /config/postal.yml
Database 'postal' already exists
No schema migrations exist. Loading schema with db:schema:load
Create the first admin account next. The tool asks for an email, a first and last name and an initial password:
sudo postal make-user
Use a real address you can read, because this account is the global admin. Postal sends its own password resets through the smtp: section of postal.yml, which points at a local port where nothing listens yet, so configure that section with a credential and a sender on a verified domain as the installation docs describe once Postal is running. Our run looked like this:
Postal User Creator
Enter the information required to create a new Postal user.
This tool is usually only used to create your initial admin user.
E-Mail Address : [email protected]
First Name : Josphat
Last Name : Mutai
Initial Password : **************
User has been created with e-mail address [email protected]
Run make-user again later for a second global admin; day-to-day users are better invited per organization from the web interface.
Start Postal
Start the three Postal processes and check them. The wrapper runs Docker Compose with the file in /opt/postal/install, so you never edit a compose file yourself:
sudo postal start
sudo postal status
All three should be up and running from the same image, and Compose may print a warning about an obsolete version attribute in that file, which is harmless:
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
postal-smtp-1 ghcr.io/postalserver/postal:3.3.7 "/docker-entrypoint.…" smtp 21 seconds ago Up 20 seconds
postal-web-1 ghcr.io/postalserver/postal:3.3.7 "/docker-entrypoint.…" web 21 seconds ago Up 20 seconds
postal-worker-1 ghcr.io/postalserver/postal:3.3.7 "/docker-entrypoint.…" worker 21 seconds ago Up 20 seconds
The PORTS column is empty because the containers use host networking. Check the listeners on the host instead:
sudo ss -tlnp | grep -E ":(25|5000)\s"
The web process stays on localhost for Caddy to proxy, while SMTP listens on every interface:
LISTEN 0 1024 127.0.0.1:5000 0.0.0.0:* users:(("ruby",pid=31573,fd=5))
LISTEN 0 4096 *:25 *:* users:(("ruby",pid=31620,fd=5))
Nothing is reachable from outside yet except SMTP, so the next two steps open the firewall and publish DNS before Caddy puts the web interface online.
Open the firewall
Because Postal and Caddy run on the host network, UFW filters their ports normally. Allow SSH, SMTP and the two web ports, and nothing else. OpenSSH covers port 22 only, so allow your own port instead if sshd listens elsewhere, and open the same three ports in your cloud provider’s security group if it has one:
sudo ufw allow OpenSSH
sudo ufw allow 25/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable
With those rules on, our server still accepted SMTP on 25 and served the login page over 443 from another machine, while port 5000 timed out, which is what you want. MariaDB is published only on 127.0.0.1, so it is not reachable from outside either. Keep port 80 open even though the UI is HTTPS-only, because Caddy redirects plain HTTP visitors there and can answer Let’s Encrypt HTTP challenges on it.
Create the DNS records for Postal
Postal needs a handful of records for its own hostname before mail flows reliably. These follow the Postal DNS documentation, with the MX name matching the mx_records entry bootstrap generated. Replace 203.0.113.10 and 2001:db8::10 with your server’s addresses:
| Name | Type | Value |
|---|---|---|
| postal.example.com | A / AAAA | 203.0.113.10 / 2001:db8::10 |
| mx.postal.example.com | A / AAAA | 203.0.113.10 / 2001:db8::10 |
| spf.postal.example.com | TXT | v=spf1 ip4:203.0.113.10 ip6:2001:db8::10 ~all |
| rp.postal.example.com | A / AAAA | 203.0.113.10 / 2001:db8::10 |
| rp.postal.example.com | MX | 10 postal.example.com |
| rp.postal.example.com | TXT | v=spf1 a mx include:spf.postal.example.com ~all |
| postal._domainkey.rp.postal.example.com | TXT | output of postal default-dkim-record |
| routes.postal.example.com | MX | 10 postal.example.com |
| track.postal.example.com | A / AAAA | 203.0.113.10 / 2001:db8::10 |
The track record is only half of open and click tracking: it also needs an extra site block in the Caddyfile, which the generated file carries as a commented example, so skip that row until you enable tracking. The return-path DKIM value comes from the signing key bootstrap created. Postal signs with that key whenever a sending domain has no working DKIM record of its own, so publish it even if you plan to add DKIM per domain:
sudo postal default-dkim-record
It prints a single TXT value to paste as-is, without line breaks:
v=DKIM1; t=s; h=sha256; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCzkmRgrv3HscZSqDbbHKSYsq5Jh1OIa0/OqNiqmfSyylpfzQxvRNMnJoaxlwi0I301gpnQ7jPIXIQGmn3cjC74amc2Tkr9c01USmM/fzg42aunETyZeBO1BAWWJaInXs6gCgFbQraFtPwpw6xVKzVO3oqmo5vQHzGRV8jUscIJoQIDAQAB;
Postal tries IPv6 first when a recipient’s mail server has an IPv6 address, and most VPS providers give you one by default, so the IPv6 rows are not optional on such a server. If you would rather send over IPv4 only, remove the IPv6 address from the server instead of leaving it half configured. Also ask your hosting provider to set the reverse DNS (PTR) of both server addresses to postal.example.com. The Postal DNS page does not cover it, but receiving servers check that the IP and the name match, and mail from an IP without a matching PTR often lands in spam or gets refused.
Put Caddy in front of the web interface
Bootstrap already wrote a Caddyfile that proxies your hostname to port 5000, and Caddy requests a Let’s Encrypt certificate for it automatically. That works because the firewall and DNS steps above are done, and Caddy keeps retrying with growing delays if it starts before the name resolves:
sudo docker run -d --name postal-caddy --restart always --network host -v /opt/postal/config/Caddyfile:/etc/caddy/Caddyfile -v /opt/postal/caddy-data:/data caddy
Our lab server had no public address, so Let’s Encrypt could not reach it. For that case we added tls internal to the site block before starting Caddy, which makes Caddy sign the certificate with its own local CA. The browser warns about it, but everything else behaves the same:
sudo sed -i "s|^ reverse_proxy 127.0.0.1:5000| tls internal\n reverse_proxy 127.0.0.1:5000|" /opt/postal/config/Caddyfile
On a real server, skip that and keep the generated file as it is:
postal.example.com {
reverse_proxy 127.0.0.1:5000
}
Open https://postal.example.com and log in with the admin user. If you would rather use Nginx or HAProxy, the Postal docs list them as alternatives, and Caddy is just the default.
Set up an organization, mail server and domain
Postal groups everything as organization, then mail server, then sending domains and credentials. After the first login it asks you to create an organization; we called ours Acme. Then build a mail server inside it:

The mode matters. Live delivers mail to real recipients, while Development holds every message in the web interface and delivers nothing, which is how we tested without public DNS. Start in Development while you wire up an app, because a misconfigured app can otherwise mail real customers from a server whose DNS is not ready yet. Switch the server to Live under its Settings once the DNS checks pass.
Add the domain you’ll send from under Domains. Postal then shows the records that domain needs, with a DKIM selector generated for it:

For our acme.example.com, Postal asked for these records in the sending domain’s own DNS zone:
- SPF TXT at
@:v=spf1 a mx include:spf.postal.example.com ~all - DKIM TXT at
postal-tuomlH._domainkey(the selector differs per domain) - return path CNAME
psrp.acme.example.comtorp.postal.example.com - MX to
mx.postal.example.comat priority 10, only to receive mail
If the domain already sends mail through another service, add only the include:spf.postal.example.com part to its existing SPF record instead of creating a second one, because a domain with two SPF records fails SPF checks entirely. Click Check my records are correct after publishing; the red “misconfigured DNS records” banner stays until every record resolves.
Send a test message
Apps authenticate with a credential, not with your login. Under Credentials, add one of type SMTP and give it a name; Postal generates the key, which is the SMTP password (redacted below):

The SMTP username is the organization and server short names joined with a slash, acme/transactional here. swaks makes a good first client because it prints the whole SMTP conversation:
sudo apt install -y swaks
swaks --server postal.example.com --port 25 --auth PLAIN --auth-user acme/transactional --auth-password 'your-smtp-key' --from [email protected] --to [email protected] --header 'Subject: Postal test from mail01' --body 'Sent through Postal on Ubuntu 26.04'
Postal accepted the login and the message, which proves the credential, the server and the domain all line up:
<- 220 postal.example.com ESMTP Postal/CMS9L5G9
-> EHLO mail01
<- 250-My capabilities are
<- 250 AUTH CRAM-MD5 PLAIN LOGIN
-> AUTH PLAIN <redacted>
<- 235 Granted for acme/transactional
-> MAIL FROM:<[email protected]>
<- 250 OK
-> RCPT TO:<[email protected]>
<- 250 OK
...
<- 250 OK
-> QUIT
<- 221 Closing Connection
The server offered no STARTTLS in that list, so this connection was plain text, password included. That is fine on localhost or a private network, but an app connecting over the internet needs TLS, so either enable TLS for Postal’s SMTP server (set tls_enabled: true under smtp_server, provide a certificate and key, then sudo postal restart, as the SMTP TLS docs describe) or send through the HTTP API over HTTPS instead. In Development mode the message shows up as held:

Opening it shows the credential and domain it came through and the reason it was held, with a Release message button that sends it on once the server is ready:

Held messages are kept for seven days (ours showed “held until October 24”), which gives you time to inspect what an app actually sends before a single message leaves the server. Check the From address and the headers here, because a typo in the sending domain is far cheaper to catch now than after it hurts the domain’s reputation. Release a message only once its domain shows clean DNS checks.
Before you send real mail
A working Postal install is the easy half. Mailbox providers judge a new sending IP harshly, so go through this list before switching the server to Live:
- outbound port 25 confirmed open from the server
- PTR records for both server addresses matching
postal.example.com - all Postal host records resolving, plus SPF and DKIM per domain
- a DMARC record on each sending domain
- a webhook on bounces so your app stops mailing dead addresses
Then start with low volume to addresses that expect your mail and raise it over a few weeks, because a fresh IP that suddenly sends thousands of messages looks like a spammer. Back up both the postal-mariadb-data volume and /opt/postal/config: the per-domain DKIM private keys live in the database, while the signing key for the return-path DKIM record lives in the config folder, and losing either means republishing DNS. If you need user mailboxes rather than application mail, a Postfix server or an iRedMail install is the better fit, and the Caddy guide covers the proxy in more depth if you host other sites on the same box.