How to run a Postfix mail server in a Docker container
About a year ago I moved all my websites and services to a private cloud. It runs Docker swarm mode and consists out of three machines: two without direct internet access (private nodes) and one with a public IP (public node).
During the transition two problems popped up:
- The PHP Docker containers I used didn’t ship with a
sendmail
binary, so I couldn’t send e-mails from PHP withmail()
. - One of my services ran on a private node but still needed to send e-mails. Since it had no internet access, this wasn’t possible.
After some research and some thinking I came up with a solution: running a mail relay service on my public node, which would simply receive mail from PHP containers and services on private nodes and relay those mails to my (still managed) SMTP mail-server.
Since there a some tricky parts in doing so, I wrote a guide on how you can do this yourself.
What is Postfix?
Postfix is a mail server and a widely used alternative to Sendmail. Postfix attempts to be fast, easy to administer, and secure.
Containerizing Postfix
A word on compatibility
Since version 3.3.0 (February 21, 2018) it is possible to run Postfix in foreground mode. This is needed to directly start the Postfix service in a container.
Since version 3.4.0 (February 27, 2019)
it is possible to directly log to stdout
, which eliminates the previous syslogd dependency.
So in order to use Postfix in a container, version 3.4.0 or higher is recommended.
Creating a Docker image
There are already a lot of good and ready to use Postfix Docker images out there, but in this post we will create an image from scratch, based on Alpine Linux.
This has two big advantages: maximum flexibility and control. A mail server handles a lot of sensitive data, and you should not blindly trust and use an image published on Docker Hub.
The Dockerfile
The Dockerfile
is based on Alpine 3.13, which ships with Postfix 3.5.10.
# Inspired by https://github.com/bokysan/docker-postfix
# Alpine 3.13 ships with Postfix 3.5.10
FROM alpine:3.13
# Install dependencies
RUN apk add --no-cache --update postfix cyrus-sasl ca-certificates bash && \
apk add --no-cache --upgrade musl musl-utils && \
# Clean up
(rm "/tmp/"* 2>/dev/null || true) && (rm -rf /var/cache/apk/* 2>/dev/null || true)
# Mark used folders
VOLUME [ "/var/spool/postfix", "/etc/postfix" ]
# Expose mail submission agent port
EXPOSE 587
# Configure Postfix on startup
COPY docker-entrypoint.sh /usr/local/bin/
ENTRYPOINT ["docker-entrypoint.sh"]
# Start postfix in foreground mode
CMD ["postfix", "start-fg"]
So what is happening here? We install Postfix and its dependencies from the Alpine repository, mark used folders and expose the mail submission port.
We also register a custom entrypoint script, which allows us to configure Postfix. Finally we start Postfix in
foreground mode (start-fg
).
The docker-entrypoint.sh
script
In this specific case, we are configuring Postfix as in internal relay server. This is especially useful if you want to send mails from a container with no internet access. You can simply send the mail to the internal Postfix relay, which will then relay your email to an external SMTP server.
#!/bin/bash
set -e
# usage: file_env VAR [DEFAULT]
# ie: file_env 'XYZ_PASSWORD' 'example'
# (will allow for "$XYZ_PASSWORD_FILE" to fill in the value of
# "$XYZ_PASSWORD" from a file, especially for Docker's secrets feature)
# copied from mariadb docker entrypoint file
file_env() {
local var="$1"
local fileVar="${var}_FILE"
local def="${2:-}"
if [ "${!var:-}" ] && [ "${!fileVar:-}" ]; then
echo >&2 "error: both $var and $fileVar are set (but are exclusive)"
exit 1
fi
local val="$def"
if [ "${!var:-}" ]; then
val="${!var}"
elif [ "${!fileVar:-}" ]; then
val="$(< "${!fileVar}")"
fi
export "$var"="$val"
unset "$fileVar"
}
file_env 'POSTFIX_RELAY_PASSWORD'
if [ -z "$POSTFIX_HOSTNAME" -a -z "$POSTFIX_RELAY_HOST" -a -z "$POSTFIX_RELAY_USER" -a -z "$POSTFIX_RELAY_PASSWORD" ]; then
echo >&2 'error: relay options are not specified '
echo >&2 ' You need to specify POSTFIX_HOSTNAME, POSTFIX_RELAY_HOST, POSTFIX_RELAY_USER and POSTFIX_RELAY_PASSWORD (or POSTFIX_RELAY_PASSWORD_FILE)'
exit 1
fi
# Create postfix folders
mkdir -p /var/spool/postfix/
mkdir -p /var/spool/postfix/pid
# Disable SMTPUTF8, because libraries (ICU) are missing in Alpine
postconf -e "smtputf8_enable=no"
# Log to stdout
postconf -e "maillog_file=/dev/stdout"
# Update aliases database. It's not used, but postfix complains if the .db file is missing
postalias /etc/postfix/aliases
# Disable local mail delivery
postconf -e "mydestination="
# Limit message size to 10MB
postconf -e "message_size_limit=10240000"
# Reject invalid HELOs
postconf -e "smtpd_delay_reject=yes"
postconf -e "smtpd_helo_required=yes"
postconf -e "smtpd_helo_restrictions=permit_mynetworks,reject_invalid_helo_hostname,permit"
# Don't allow requests from outside
postconf -e "mynetworks=127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
# Set up hostname
postconf -e myhostname=$POSTFIX_HOSTNAME
# Do not relay mail from untrusted networks
postconf -e "relay_domains="
# Relay configuration
postconf -e relayhost=$POSTFIX_RELAY_HOST
echo "$POSTFIX_RELAY_HOST $POSTFIX_RELAY_USER:$POSTFIX_RELAY_PASSWORD" >> /etc/postfix/sasl_passwd
postmap lmdb:/etc/postfix/sasl_passwd
postconf -e "smtp_sasl_auth_enable=yes"
postconf -e "smtp_sasl_password_maps=lmdb:/etc/postfix/sasl_passwd"
postconf -e "smtp_sasl_security_options=noanonymous"
postconf -e "smtpd_recipient_restrictions=reject_non_fqdn_recipient,reject_unknown_recipient_domain,reject_unverified_recipient"
# Use 587 (submission)
sed -i -r -e 's/^#submission/submission/' /etc/postfix/master.cf
echo
echo 'postfix configured. Ready for start up.'
echo
exec "$@"
What is happening in this file? First we read from Docker environment variables in order to setup Postfix. Then we configure it as an SMTP relay.
The configuration can be a bit overwhelming. If anything is unclear, please have a look at the documentation.
At the end, we run the command from our Dockerfile
by executing $@
.
Building the image
Building the image is straightforward. Simply execute:
docker build -t postfix:alpine .
Using the Postfix image
If you are running a Docker swarm, you can use the image like this:
services:
postfix:
image: postfix:alpine
environment:
POSTFIX_HOSTNAME: "example.com"
POSTFIX_RELAY_HOST: "example.com:587"
POSTFIX_RELAY_USER: "user"
POSTFIX_RELAY_PASSWORD_FILE: /run/secrets/postfix_password
secrets:
- postfix_password
secrets:
postfix_password:
file: ./stack/secrets/postfix_password.txt
Security
The container will be run as root user. It is a good practice to run the container as a non-root user, according to the CIS Docker Benchmark 1.2.0
So there is still room for improvement.
In case there is a security vulnerability in Postfix, an attacker would still have to break out of the container in order to compromise the host system.
Also, as an extra layer of security, please make sure to DENY
access to the mail submission port 587
from public IPs.
If you use Docker you can’t do this with iptables
on your host system, since Docker overwrites your rules.
But most cloud providers will allow you to block ports in their web consoles/dashboards.
Conclusion
Since Postfix version 3.4 it’s fairly easy to run a Postfix mail server in a Docker container. 📨
It’s easy to update (just increase the Alpine version, rebuild the image and restart the container), you can apply memory and CPU limits on it, and it has a small footprint.
14.04.2021: Updated for usage with Alpine Linux 3.13