Email

Install Postal Mail Server on Ubuntu 26.04 with Docker

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.

Original content from computingforgeeks.com - post 5283

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 mail server architecture diagram: admin browser reaches postal-web on 127.0.0.1:5000 through Caddy on 443, apps and recipient mail servers reach postal-smtp on port 25, postal-worker delivers outbound mail, all backed by postal-mariadb with the postal-mariadb-data volume

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.com and 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:

NameTypeValue
postal.example.comA / AAAA203.0.113.10 / 2001:db8::10
mx.postal.example.comA / AAAA203.0.113.10 / 2001:db8::10
spf.postal.example.comTXTv=spf1 ip4:203.0.113.10 ip6:2001:db8::10 ~all
rp.postal.example.comA / AAAA203.0.113.10 / 2001:db8::10
rp.postal.example.comMX10 postal.example.com
rp.postal.example.comTXTv=spf1 a mx include:spf.postal.example.com ~all
postal._domainkey.rp.postal.example.comTXToutput of postal default-dkim-record
routes.postal.example.comMX10 postal.example.com
track.postal.example.comA / AAAA203.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:

Postal form building a new mail server named Transactional in Development mode

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:

Postal DNS setup page for acme.example.com listing the SPF record and the DKIM TXT record

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.com to rp.postal.example.com
  • MX to mx.postal.example.com at 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):

Postal credentials page with an SMTP credential named app-smtp, key redacted

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:

Postal outgoing messages list showing the test message to jane@example.net 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:

Postal message detail showing the test message held because the server is in development mode

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.

Keep reading

Upgrade Ubuntu 24.04 to Ubuntu 26.04 LTS (Step by Step) Ubuntu Upgrade Ubuntu 24.04 to Ubuntu 26.04 LTS (Step by Step) UFW Firewall Commands with Examples on Ubuntu 26.04 / 24.04 Security UFW Firewall Commands with Examples on Ubuntu 26.04 / 24.04 Ubuntu 26.04 vs 24.04 LTS: Should You Upgrade Now? Ubuntu Ubuntu 26.04 vs 24.04 LTS: Should You Upgrade Now? Upgrade a Single Package on Ubuntu / Debian with apt Debian Upgrade a Single Package on Ubuntu / Debian with apt CapRover Docker Install on Debian 13: Deploy Your First App Containers CapRover Docker Install on Debian 13: Deploy Your First App Install MicroK8s Kubernetes on Rocky Linux 10 / AlmaLinux 10 / Ubuntu 24.04 Containers Install MicroK8s Kubernetes on Rocky Linux 10 / AlmaLinux 10 / Ubuntu 24.04

Leave a Comment

Press ESC to close