Docker mailserver

This mailserver setup follows Workaround's SPmail guide for Debian 12 “Bookworm”. Key changes are that instead of installing on Debian 11 virtual machine1, with a Maria mysql database2, this setup is for installation on latest Alpine linux Docker image with s6-rc init using sqlite database.

As this follows Workaround's SPmail guide for Debian 12 “Bookworm”, significant amounts of text have been copied and generally modified from there. I hereby credit Workaround's author Christoph Haas. Furthermore Christoph's guide is very descriptive and should be referenced to get a better understanding of how to put together a mailserver.

The notes here are my current working attempt to get an Alpine s6-rc Docker implementation of Postfix and Dovecot, with sqlite based mail server functional and are currently incomplete.

  1. Use of virtual machines is much more common these days than base metal for applications. However Workarounds Debian email server could be loaded on base metal.
  2. The database requirements for a small mailserver with a few dozen domains, with each domain having hundreds of emails and aliases is well within the capacity of the sqlite database. The use of a full multi user server / client relational database is not necessary, particularly for a Docker based server implementation. See SQLite vs MySQL vs PostgreSQL: A Comparison Of Relational Database Management Systems

I go annoyed with the messy UID and GID and found this reference to attempt to standardise upon. Sadly there seems to be no comprehensive standard!

Required for server:

  • sqlite ⇒ /usr/bin/sqlite3
  • postfix ⇒ /usr/sbin/postfix
  • postfix-sqlite plugin?
  • #dovecot ⇒ #main dovecot included in dovecot-sqlite
  • dovecot-sqlite ⇒ /usr/sbin/dovecot
  • #dovecot-pop3 ⇒ #Only if need pop3 connectivit2y
  • dovecot-imapd ⇒ plugin?
  • dovecot-lmtpd ⇒ plugin?
  • dovecot-pigeonhole-plugin ⇒ plugin?
  • apache2 ⇒ /usr/sbin/httpd
  • php ⇒ /usr/bin/php/usr/bin/php81 So the standard php package currently links to php81 at this time of writing.
  • Adminer is downloaded separately adminer is a single php file program to administer database programs including sqlite
  • rspamd ⇒ /usr/sbin/rspamd The email spam removing daemon
  • #swaks ⇒ Seems to only be in testing repository?
  • mutt ⇒ /usr/bin/mutt
  • jq ⇒ /usr/bin/jq

To assist with working in container:

  • util-linux ⇒ adds some addition utilities to Alpine, e.g. whereis This is not required for the final package and can be commented out of the Dockerfile.
  • pwgen ⇒ pwgen generates encrypted passwords
  • vim ⇒ because it is nicer to use than vi
  • less ⇒ because sometimes it is nicer to use than cat (it looks like util-linux includes the more command. less seems more full featured and what I am use to. It looks like less will not work properly unless export TERM=rxvt is used. Hmmm… unimpressed
  • sqlite3 /app/mailserver.db to start sqlite and create or open mailserver.db.
  • .open /app/mailserver.db to open mailserver.db
  • .tables to list tables in a database
  • To list tables from sqlite_schema:
        TYPE ='table' AND 
        name NOT LIKE 'sqlite_%';
  • SELECT * FROM table_name; to query all data from a table.

This table just holds the list of domains that you will use as virtual_mailbox_domains in Postfix.

Column Purpose
id A unique number identifying each row. It is added by the database automatically.
name The name of the domain you want to receive email for.

This SQL statement creates a table like that:

sql code

CREATE TABLE IF NOT EXISTS `virtual_domains` (
 `name` VARCHAR(50) NOT NULL,

sqlite code

CREATE TABLE IF NOT EXISTS 'virtual_domains' (

DROP TABLE IF EXISTS virtual_domains; will delete the table.

The next table contains information about your users. Every mail account takes up one row.

Column Purpose
id A unique number identifying each row. It is added by the database automatically.
domain_id Contains the number of the domain’s id in the virtual_domains table. This is called a foreign key. A “delete cascade” makes sure that if a domain is deleted that all user accounts in that domain are also deleted to avoid orphaned rows.
email The email address of the mail account.
password The hashed password of the mail account. It is prepended by the password scheme. By default it is {BLF-CRYPT} also known as bcrypt which is considered very secure. Previous ISPmail guides used {SHA256-CRYPT} or even older crypt schemes. Prepending the password field the hashing algorithm in curly brackets allows you to have different kinds of hashes. So you can easily migrate your old passwords without locking out users. Users with older schemes should get a new password if possible to increase security.
quota The number of bytes that this mailbox can store. You can use this value to limit how much space a mailbox can take up. The default value is 0 which means that there is no limit.

This is the appropriate SQL query to create that table:

sql code

CREATE TABLE IF NOT EXISTS `virtual_users` (
 `domain_id` INT(11) NOT NULL,
 `email` VARCHAR(100) NOT NULL,
 `password` VARCHAR(150) NOT NULL,
 PRIMARY KEY (`id`),
 UNIQUE KEY `email` (`email`),
 FOREIGN KEY (domain_id) REFERENCES virtual_domains(id) ON DELETE CASCADE

sqlite code

CREATE TABLE IF NOT EXISTS `virtual_users` (
 `domain_id` INTEGER NOT NULL,
 `password` TEXT NOT NULL,
 FOREIGN KEY (domain_id) REFERENCES virtual_domains(id) ON DELETE CASCADE

The last table contains forwardings from an email address to other email addresses.

Field Purpose
id A unique number identifying each row. It is added by the database automatically.
domain_id Contains the number of the domain’s id in the virtual_domains table again.
source The email address that the email was actually sent to. In case of catch-all addresses (that accept any address in a domain) the source looks like “”.
destination The email address that the email should instead be sent to.

This is the required SQL query you need to run:


CREATE TABLE IF NOT EXISTS `main.virtual_aliases` (
 `domain_id` INT(11) NOT NULL,
 `source` VARCHAR(100) NOT NULL,
 `destination` VARCHAR(100) NOT NULL,
 PRIMARY KEY (`id`),
 FOREIGN KEY (domain_id) REFERENCES virtual_domains(id) ON DELETE CASCADE


CREATE TABLE IF NOT EXISTS `virtual_aliases` (
 `domain_id` INTEGER NOT NULL,
 `source` TEXT NOT NULL,
 `destination` TEXT NOT NULL,
 FOREIGN KEY (domain_id) REFERENCES virtual_domains(id) ON DELETE CASCADE

The following test dat acan be used to test the data returns from postfix.

REPLACE INTO virtual_domains (id,name) VALUES ('1','');
REPLACE INTO virtual_users (id,domain_id,password,email)
 VALUES ('1', '1', '{BLF-CRYPT}$2y$05$.WedBCNZiwxY1CG3aleIleu6lYjup2CIg0BP4M4YCZsO204Czz07W', '');
REPLACE INTO virtual_aliases (id,domain_id,SOURCE,destination)
 VALUES ('1', '1', '', '');

This sample data should be deleted before using the mailserver live.

DELETE FROM mailserver.virtual_domains WHERE name='';

phpMyAdmin is a web based mysql management interface.

adminer is a web based single php file database manager, that is suitable for many type of databases, including sqlite and being only a single file is easier to implement. Copy the latest version of adminer to .config/adminer

To test:
database: SQLite 3
password: standard
database: /app/mailserver.db

There is another program call SQLiteStudio. This look looks like a full GUI program, that will probably not be suitable for installation on a Docker mailserver. Noted.

Adminer needs a few php modules to run, session and pdo_sqlite, apk packages: php$phpverx-session, php$phpverx-pdo_sqlite, php$phpverx-sqlite3. Also Adminer does not like working with Sqlite without forcing some kind of password protect. Hence the Adminer plugin module and password-less plugin need to be used.

It seems that Apache2 php runs more efficiently if the php-fpm module is used and setup. See Alpine Apache with php-fpm


/ # postconf mail_version
mail_version = 3.7.4

A mapping in Postfix is just a table that contains a left-hand side (LHS) and a right-hand side (RHS). To make Postfix get information about virtual domains from the database we need to create a ‘cf’ file (configuration file). Start by creating a file called /etc/postfix/ for the virtual_mailbox_domains mapping that contains:

dbpath = /app/mailserver.db
query = SELECT 1 FROM virtual_domains WHERE name='%s'

Imagine that Postfix receives an email for and wants to find out if is a virtual mailbox domain. It will run the above SQL query and replace ‘%s’ by ‘’. If it finds such a row in the virtual_domains table it will return a ‘1’. Actually it does not matter what exactly is returns as long as there is a result.

Now you need to make Postfix use this database mapping: postconf virtual_mailbox_domains=sqlite:/etc/postfix/

The “postconf” command conveniently adds configuration lines to your /etc/postfix/ file. It also activates the new setting instantly so you do not have to reload the Postfix process.

The test data you created earlier added the domain “” as one of your mailbox domains. Let’s ask Postfix if it recognizes that domain: postmap -q sqlite:/etc/postfix/

You should get ‘1’ as a result. That means your first mapping is working. Feel free to try that with other domains after the -q in that line. You should not get a response

The virtual_mailbox_maps which is mapping email addresses (left-hand side) to the location of the user’s mailbox on your hard disk (right-hand side). Postfix has a built-in transport service called “virtual” that can receive the email and store it into the recipient’s email directory. But we will not make Postfix save the email to disk. We will delegate that to Dovecot as it allows us better control.

All that Postfix needs to know is whether an email address belongs to a valid mailbox. That simplifies things a bit because we just need the left-hand side of the mapping.

Similar to the above virtual_domains mapping you need an SQL query that searches for an email address and returns “1” if it is found.

To accomplish that please create another configuration file at /etc/postfix/

dbpath = /app/mailserver.db
query = SELECT 1 FROM virtual_users WHERE email='%s'

Tell Postfix that this mapping file is supposed to be used for the virtual_mailbox_maps mapping: postconf virtual_mailbox_maps=sqlite:/etc/postfix/

Test if Postfix is happy with this mapping by asking it where the mailbox directory of our user would be: postmap -q sqlite:/etc/postfix/

Again you should get “1” back which means that is an existing virtual mailbox user on your server. Very good. Later when we deal with the Dovecot configuration we will also use the password field but Postfix does not need it right here.

The virtual_alias_maps mapping is used for forwarding emails from one email address to one or more others. In the database multiple targets are achieved by using multiple rows.

Create another “.cf” file at /etc/postfix/

dbpath = /app/mailserver.db
query = SELECT destination FROM virtual_aliases WHERE SOURCE='%s'

Make Postfix use this database mapping: postconf virtual_alias_maps=sqlite:/etc/postfix/

Test if the mapping file works as expected: postmap -q sqlite:/etc/postfix/

You should see the expected destination:

So if Postfix receives an email for it will redirect it to

Basic postfix control are:

  • postfix start to start postfix in background
  • postfix start-fg to start postfix in the foreground
  • postfix stop to stop postfix
  • postfix reload to reload configuration
  • pstfix status to return current postfix operating status

It looks a shell script is used to control Postfix, in Alpine is is located here


The systemd service script is a hoot!, it seems to do nothing meaningful.


Look like postfix is started from /etc/init.d/postfix. This script notes the following options, “Usage: /etc/init.d/postfix {start|stop|restart|reload|flush|check|abort|force-reload|status}”


  • /usr/libexec/postfix/postfix-script
  • /usr/sbin/postfix postfix library modules
  • /etc/postfix configuration files

s6 setup

The s6 rc run file:


Alpine posfix would seem to be setup to use postlogd, as has the following line already configured: postlog unix-dgram n - n - 1 postlogd. Hence the following does not need to be used: /bin/echo 'postlog unix-dgram n - n - 1 postlogd' >> '/etc/postfix/'


/ # dovecot --version
2.3.20 (80a5ac675d) 

database setup: /etc/dovecot/dovecot-sql.conf.ext

See Dovecot howto on sqlite setup Dovecot configuration sqlite, linked from Dovecot HOWTOs / Examples / Tutorials

You will find this file well documented although all configuration directives are commented out. Add these lines at the bottom of the file:

driver = sqlite
CONNECT = /app/mailserver.db
user_query = SELECT email AS USER, \
  '*:bytes=' || quota AS quota_rule, \
  '/var/vmail/%d/%n' AS home, \
  5000 AS uid, 5000 AS gid \
  FROM virtual_users WHERE email='%u'
password_query = SELECT password FROM virtual_users WHERE email='%u'
iterate_query = SELECT email AS USER FROM virtual_users


  • The Dovecot file uses the \ at the end of each line to indicate the command continues on to the next line. Sqlite does not allow this.
  • The mysql concat command syntax is || for sqlite
  • connect = is the full path to the sqlite mailserver database

Dovecot database testing

Testing directly within sqlite3:
  • Open the sqlite3 database at the command line sqlite3 /app/mailserver.db
  • user_query: SELECT email AS USER, '*:bytes=' || quota AS quota_rule, '/var/vmail/%d/%n' AS home, 5000 AS uid, 5000 AS gid FROM virtual_users WHERE email='';
    • output example on success:|*:bytes=0|/var/vmail/%d/%n|5000|5000
  • password_query: SELECT password FROM virtual_users WHERE email='';
    • Output on success {BLF-CRYPT}$2y$05$.WedBCNZiwxY1CG3aleIleu6lYjup2CIg0BP4M4YCZsO204Czz07W
  • iterate_query: SELECT email AS user FROM virtual_users;
    • Output:
Testing with openssl

openssl s_client -servername -connect

Testing with doveadmin

doveadm auth test doveadm auth test, you will be prompted for password. A successful response looks like:

passdb: auth succeeded
extra fields:

doveadm user doveadm user will give output:

field	value
uid	5000
gid	5000
home	/var/vmail/
mail	maildir:~/Maildir
quota_rule	*:bytes=0

whereas doveadm user * will give a list of users:


The debian 10 systemctl dovecot.service:


  • /usr/sbin/dovecot to start in background
    • [-F] to start in foreground
    • [-c <config file>]
    • [reload] forces Dovecot to reload configuration
    • [stop] shutsdown Dovecot and all child processes
    • [-a] dump configuration settings and exit (it is a long list!)
    • [–help]
    • [–version]
    • [–build-options] show build options and exit

Early on, before 2015 there were not many free SSL certificate providers. I used StartSSL for a free certificate. They would purchased by a company that managed to get their certificate deregister…… So StartSSL basically became non-usable circa 2017. Fourtunately a better solution came about a year or 2 earlier called LetsEncrypt. This could be used with certbot to get free certificates and eventually free wildcard certificates. Then came Traefik which handled certificates

Alpine apk apache2 distribution seems to follow the Red Hat setup style. The daemon is httpd instead of apache2. So I need to learn a new setup. Keep features:

  • http is the main program, /usr/sbin/http
    • httpd -S list the current defined
    • httpd -M list the current defined modules
    • httpd -k restart to restart, also stop to kill, graceful to gracefully stop
  • main configuration files located at /etc/apache2, including httpd.conf

There are a number of ways to configure apache 2.4 to access remote server, see Apache module mod_proxy_fcgi for details. I used the following commands.

  • DocumentRoot “/var/www/roundcube” this is not really necessary, however it removes a warning message.
  • ProxyPassMatch "\.php$" "fcgi://roundcubemail:9000/var/www/html" enablereuse=on basically redirects all *.php requests to the fcgi server.
    • The first part of the match, “\.php$” basically indicates that all *.php call should be directed to the proxy
    • The second part "fcgi://roundcubemail:9000/var/www/html" enablereuse=on is the proxy call (fcgi:) to IP/TCP roundcubemail:9000. The DNS resolves roundcubemail to the local IP address at port 9000. The /var/www/html provides the access path on the remote server.

vim .config/apache2/

vim .config/apache2/

I though I should give nginx a try as I was having linted success with Apache and remote php-fpm.

The Apline package locations as of php version 8.2 are as follows. This may vary with version and also definitely varies with different distributions and their package managers.


I decided to setup php-fpm [fpm = FastCGI Process Manager, and CGI = Common Gateway Interface, reputedly more performant than the builtin Apache php module. Amazing an acronym in acronym, some people are so smart they are dumb!]. I basically followed Alpine Linux instructions Apache with php-fpm

php-fpm acts like a php server and can be setup to allow remote connections from clients, usually web pages. The server is normally setup to listen on port 9000. Additional servers can be setup using other ports. This allows servers per app and different server php configuration, e.g. amount of memory and number of process, etc. This is performed by defining additional php-fpm server configuration pools that are differentiated by ip address and port, or socket file. If the client and servers are on the same machine they can communicate via local host and port or via socket file. If remotely hosted they communicate via ip address and port number. The pool definition only allows referencing via discrete ip address and port numbers, where as the web browsers can use name resolution of ip addresses adding flexibility.

An annoying problem is the php-fpm version is carried on through the file system. The Alpine Docker stable version at writing is 8.1 (called 81). So the following 3 locations need to be considered when updating. This also means that the latest version can not simply be used.

  • Dockerfile ARG PHP_VERSION=82
  • Main configuration script, PHP_VERSION=82
  • s6-rc.d php-fpm longrun start script at s6-rc.d/php-fpm/run, the command /usr/sbin/php-fpm82 -F needs to be adjusted manually with version.
  • Need to create directory in log, sudo mkdir .config/log/php82

As php-fpm automatically runs in daemon mode, when using s6-rc init as a longrun it needs to be run in foreground mode, /usr/sbin/php-fpm82 -F, as per the -F option. In daemon mode, s6-rc thinks php-fpm has failed and attempts to restart. As the original program is running, the subsequent repeated attempts to run, give the noted repeated error message. (I believe there is a method in s6-rc to stop after a defined number of failed attempts. I have not looked into this yet.)

  • To list installed php modules php -m
  • A comprehensive php information list is provided with php -i
  • To check php-fpm defined pools, where xx is the version, php-fpm82 -tt
  • To list php-fpm information, php-fpm81 -i, note this is a text version of the the php → phpinfo();
  • php-fpm82 -h to list all options
  • Managing PHP-FPM Learning how php-fpm works will make optimizing your server a breeze! See how to secure and optimize php-fpm here.
  • Apache and PHP-FPM Learn to hook Apache up to PHP-FPM using Apache's proxy modules.

I decided to use the Roundcube Docker official image and followed the instructions to setup. As usual I decided to go with the Alpine Linux image option.

My final Docker compose file, docker-compose.yml



  • Docker compose: depends_on Depends upon defines start-up and shutdown dependencies on containers in a stack.
  • Docker compose: links Links is not specifically required in compose version 3. services connected to a shared network can communicate.

docker compose

file structure

Some links:

Looks like Traefik can not handle routing of STARTTLS. At least as of 2023-12-02.

My LAN DNS points to the one common IP address for the mail server. DNS does not differentiate the services via port numbers. As noted I can not use Traefik to perform this routing if the STARTTLS protocol is used. I could possibly use my router, but this is getting too complex.

To allow operation of a Docker Web browser based mail client with my existing VM mail server I used a slightly different URL to help with routing, e.g. versus for mail server. The successfully used Traefik router and successfully operated with the mailserver on

  • /app/www/public/data/pages/docker_notes/docker-mailserver.txt
  • Last modified: 2023-12-03 Sun wk48 21:35
  • by baumkp