In this blog post, I will show you how to set up a postfix SMTP server using the Alpine Linux Docker image. At end of this tutorial, we will end up with a small Docker image with postfix inside of it that can be configured using environment variables.

Why?

So you might ask why to bother with installing Postfix in Docker container if you can do the same thing using your Linux distribution’s package manager. The answer is simple. I want to have a fully operational postfix with all dependencies that can be configured very quickly using environment variables. I don’t want to browse through documentation of config files every time I want to deploy it. Setup of postfix is no simple task. Especially if one never did that before. Some wizards will lead you through the initial configuration process but after that, you will be left alone to figure things out.

Introduction

First of all, let’s start with explaining what is postfix. This is for those who never installed or configured postfix so far. Postfix is one of the most popular mail transfer agent (MTA) software that is currently used. It allows users to submit emails into a special queue and then it handles final mail delivery into destination servers. You can read more about it here in the official documentation: http://www.postfix.org/

Prerequisites

I assume that you know how to use Docker to run pre-configured images. Knowledge of image building is not necessary here because I will explain everything along the way.

Choosing Docker base image

Before we start our implementation we need to decide on a Docker base image that we want to use. I want my image to be as small as possible so I will use an Alpine base image. Alpine is small Linux distribution that will suit our needs perfectly. I will stick to version 3 of this image. If you want to pull it by hand you can use this command:

docker pull alpine:3

And then you can run sh shell and explore the image a little bit.

docker run --rm -ti alpine:3 sh

Packages that we will use

  • postfix - main program inside a container and gather orphaned processes.
  • dockerize - this will allow us to create a postfix config file dynamically using environment variables

Creating Dockerfile

We will start by creating dir where we will store all our config files and Docker file for this image

mkdir postfix-alpine && cd postfix-alpine

Create Dockerfile with the following contents

Dockerfile

FROM alpine:3
ENV DOCKERIZE_VERSION v0.6.1

# install packages
RUN apk add --no-cache --update postfix bash && \
    apk add --no-cache --upgrade musl musl-utils && \
    (rm "/tmp/"* 2>/dev/null || true) && (rm -rf /var/cache/apk/* 2>/dev/null || true)

# install dockerize
RUN wget https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz \
    && tar -C /usr/local/bin -xzvf dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz \
    && rm dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz

# copy postfix config file template into image
COPY main.cf.tmpl /etc/postfix/main.cf.tmpl

# copy entrypoint script into an image
COPY docker-entrypoint.sh /

# postfix is listening on port 25
EXPOSE 25
STOPSIGNAL SIGKILL

CMD /docker-entrypoint.sh

Config files

Next, we will create config files. We will start with docker-entrypoint.sh file. Create it in postfix-alpine dir where your Dockefile is sitting.

docker-entrypoint.sh

#/bin/bash

# Run dockerize and template file main.cf.tmpl into main.cf
# then start postfix as child process
dockerize -template /etc/postfix/main.cf.tmpl:/etc/postfix/main.cf postfix start-fg

Make docker-entrypoint.sh executable.

chmod +x docker-entrypoint.sh

Next, we will create a dockerize template file. This template is using go template syntax. You can read more about it here: https://pkg.go.dev/text/template. Functions available inside dockerize templates are documented here: https://github.com/jwilder/dockerize#using-templates Create file named main.cf.tmpl alongside with Dockerfile and docker-entrypoint.sh files with following contents:

main.cf.tmpl

maillog_file = /dev/stdout
smtp_helo_name = {{ .Env.POSTFIX_SMTP_HELO_NAME }}
myorigin = {{ .Env.POSTFIX_MYORIGIN }}

smtpd_banner = $myhostname ESMTP
biff = no
append_dot_mydomain = no
readme_directory = no

compatibility_level = 2

smtpd_tls_session_cache_database = btree:${data_directory}/smtpd_scache
smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache

smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_unauth_destination
myhostname = {{ .Env.POSTFIX_MYHOSTNAME }}
alias_maps = hash:/etc/aliases
alias_database = hash:/etc/aliases
mydestination = localhost.localdomain, localhost
relayhost =
mynetworks = 127.0.0.0/8 192.168.0.0/16 172.16.0.0/12
mailbox_size_limit = 0
recipient_delimiter = +
inet_interfaces = all
inet_protocols = ipv4

As you can see we are using environment variables to create config file dynamically. For example this line:

smtp_helo_name = {{ .Env.POSTFIX_SMTP_HELO_NAME }}

is using POSTFIX_SMTP_HELO_NAME env variable that we will set on docker container start and dockerize will substitute everything inside {{ }} with a value of this variable. Config file that is provided here is minimal and you can add more configuration lines here. For full documentation of postfix main.cf config file go to: http://www.postfix.org/postconf.5.html.

Also take a closer look at this line:

maillog_file = /dev/stdout

Here we are telling postfix that we want all log files to go to stdout for us to be able to see them via docker logs command.

Build docker image

If all files are in place start build your docker image using the following command.

docker build -t postfix-alpine:latest .

Let’s check if the image was created and what is its size. Type docker images in the terminal.

docker images
REPOSITORY          TAG          IMAGE ID       CREATED             SIZE
postfix-alpine      latest       61f9fa7f4a21   2 minutes ago      55MB

As you can see our image is very small - only 55MB.

Running image

After build you can check if your new image is working with this command:

docker run \
        -d \
        --rm \
        --init \
        --env POSTFIX_SMTP_HELO_NAME=localhost \
        --env=POSTFIX_MYORIGIN=localhost \
        --env=POSTFIX_MYHOSTNAME=localhost \
        --name postfix-alpine \
        -p 8025:25 \
        postfix-alpine:latest

Next check if it is working by typing docker ps

docker ps
CONTAINER ID   IMAGE                   COMMAND                  CREATED         STATUS         PORTS                                   NAMES
e1e2198ba7fd   postfix-alpine:latest   "/bin/sh -c /docker-…"   3 seconds ago   Up 2 seconds   0.0.0.0:8025->25/tcp, :::8025->25/tcp   postfix-alpine

You should see the status Up for x seconds.

Go inside the container and check if the template file is properly configured.

docker exec -ti postfix-alpine sh

And type the following commands inside the docker shell

cd /etc/postfix/
cat main.cf

You should see the following result. As expected references to environment variables inside the template were replaced by localhost.

maillog_file = /dev/stdout
smtp_helo_name = localhost
myorigin = localhost

smtpd_banner = $myhostname ESMTP
biff = no
append_dot_mydomain = no
readme_directory = no

compatibility_level = 2

smtpd_tls_session_cache_database = btree:${data_directory}/smtpd_scache
smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache

smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_unauth_destination
myhostname = localhost
mydestination = localhost.localdomain, localhost
relayhost =
mynetworks = 127.0.0.0/8 192.168.0.0/16 172.16.0.0/12
mailbox_size_limit = 0
recipient_delimiter = +
inet_interfaces = all
inet_protocols = ipv4

Testing connection

Your image is exposed for connections on port 8025 specified in the docker run command. We can try to connect and see if postfix is running. Type nc localhost 8025 and see the result. You should see something similar to this:

nc localhost 8025
220 localhost ESMTP

If this is the case that means postfix is working.

What to do next

I purposefully omitted the part that should go here and would be about testing sending emails using our new server. This is because to do this we need proper network / DNS configuration and this topic is so vast that it can be described in a whole new blog post. What you can do instead is install a package named msmtp-mta using your Linux distribution package manager and perform a simple test as follows.

Create msmtp-mta config file with the following contents.

~/.msmtprc

defaults
auth           off
tls            off
logfile        ~/.msmtp.log

account        local
host           localhost
port           8025
from           root@localhost

account default : local

Try sending email to your private email address:

echo "Test email." | msmtp -a default *****@******.***

Open docker logs and see what happened:

docker logs -f postfix-alpine
postfix/smtpd[103]: connect from unknown[172.17.0.1]
postfix/smtpd[103]: 03166B6078D: client=unknown[172.17.0.1]
postfix/cleanup[107]: 03166B6078D: message-id=<>
postfix/qmgr[84]: 03166B6078D: from=<root@localhost>, size=256, nrcpt=1 (queue active)
postfix/smtpd[103]: disconnect from unknown[172.17.0.1] ehlo=1 mail=1 rcpt=1 data=1 quit=1 commands=5
postfix/smtp[108]: connect to gmail-smtp-in.l.google.com[173.194.73.27]:25: Operation timed out

As you can see in my case connection is working but my internet provided is blocking port 25 to Gmail where my mailbox is situated so I can’t send this email. In your case, there could be another error if the network is not properly configured.

Summary

In this blog post, I described how to create a small docker image containing a postfix mail server. Based on this instructions you can expand this image however you want to achieve a production-ready postfix server.