Saturday, August 30, 2025

How to install dockerized Mailcow with ACME DNS-01 Challenge

Mailcow is a self-hosted mail server suite (Postfix, Dovecot, Rspamd, SOGo, etc.) packaged with Docker, so installation is pretty simple and mostly about preparing your server, running Docker Compose and set your DNS records correctly.

For my personal mail server I use VM with 2 vCPUs, 8 GB RAM, 100 GB vSSD, 1x vNIC, Linux OS - Debian 13.0

If you are interested how to install and configure it, keep reading.

Mailcow Installation 

Official Mailcow documentation is available at https://docs.mailcow.email/getstarted/prerequisite-system/ 

Install Debian

Debian installation is out of scope. After standard Debian minimal installation, login as root and update the system ...

# Apt sources are in file /etc/apt/sources.list  

apt update && apt upgrade -y 

When system and packages are up to date, install dependencies ...

apt install -y curl git apt-transport-https ca-certificates gnupg lsb-release

Install Docker

curl -fsSL https://get.docker.com | sh
systemctl enable docker
systemctl start docker 
apt install docker-ce docker-ce-cli containerd.io 

Check installed docker compose version

 root@mailcow:~# docker compose version  
 Docker Compose version v2.39.1  
 root@mailcow:~#   

Install Mailcow

Mailcow must be installed as user root. 

Login to Debian as user root and create directory mailcow-dockerized by using git clone.

cd /opt
git clone https://github.com/mailcow/mailcow-dockerized
 
Generate configuration 
 
cd /opt/mailcow-dockerized/
./generate_config.sh

Start Mailcow

docker compose pull
docker compose up -d 

Reset Mailcow Admin Password

# Change directory to mailcow  
/opt/mailcow-dockerized/helper-scripts/mailcow-reset-admin.sh

# Restart Mailcow stack
docker compose down
docker compose up -d

Now you should be able to log in to Administrator Web Interface at https://[YOUR-ADDRESS]/admin 

It is little bit tricky

  • admin login page is at https://[YOUR-ADDRESS]/admin
    • In my case https://mail.uw.cz/admin
  • user login page is at https://[YOUR-ADDRESS]
    • In my case https://mail.uw.cz

DNS Records

A Record 

You should start with A Record configuration, because you will need web access into Mailcow to get DKIP. I use following A Record for my mail server mail.uw.cz ... 

 Davids-MacBook-Pro-13:~ dpasek$ dig A mail.uw.cz  
 ; <<>> DiG 9.10.6 <<>> A mail.uw.cz  
 ;; global options: +cmd  
 ;; Got answer:  
 ;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 55612  
 ;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1  
 ;; OPT PSEUDOSECTION:  
 ; EDNS: version: 0, flags:; udp: 1232  
 ;; QUESTION SECTION:  
 ;mail.uw.cz.                IN     A  
 ;; ANSWER SECTION:  
 mail.uw.cz.           600     IN     A     92.62.124.4  
 ;; Query time: 36 msec  
 ;; SERVER: 192.168.4.5#53(192.168.4.5)  
 ;; WHEN: Sat Aug 30 17:03:36 CEST 2025  
 ;; MSG SIZE rcvd: 55  
 Davids-MacBook-Pro-13:~ dpasek$   

What are DMARC, DKIM, and SPF?

DMARC, DKIM, and SPF are three email authentication methods. Together, they help prevent spammers, phishers, and other unauthorized parties from sending emails on behalf of a domain* they do not own. Read about it here

Get DKIM and all other DNS Records

When Mailcow is up and running, log in to admin Web GUI at https://mail.uw.cz/admin

Go to E-mail > Configuration and add your domain. 

In my case I added my domain uw.cz

Configuration of domains handled by Mailcow

When your domain is configured, just click DNS button for that domain and you will get all DNS records you should setup into your DNS and real status of particular DNS record.

DNS Records you must add into your DNS

TXT Records

TXT records are used for SPF, DKIM, and DMARC. 

Here is my TXT record for SPF ...

 Davids-MacBook-Pro-13:~ dpasek$ dig TXT uw.cz  
 ; <<>> DiG 9.10.6 <<>> TXT uw.cz  
 ;; global options: +cmd  
 ;; Got answer:  
 ;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 42425  
 ;; flags: qr rd ra; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 1  
 ;; OPT PSEUDOSECTION:  
 ; EDNS: version: 0, flags:; udp: 1232  
 ;; QUESTION SECTION:  
 ;uw.cz.                    IN     TXT  
 ;; ANSWER SECTION:  
  uw.cz.               600     IN     TXT     "v=spf1 mx -all"  
 ;; Query time: 28 msec  
 ;; SERVER: 192.168.4.5#53(192.168.4.5)  
 ;; WHEN: Sat Aug 30 16:58:14 CEST 2025  
 ;; MSG SIZE rcvd: 125  
 Davids-MacBook-Pro-13:~ dpasek$   

Here is my TXT record for DMARC ...

 Davids-MacBook-Pro-13:~ dpasek$ dig TXT _dmarc.uw.cz  
 ; <<>> DiG 9.10.6 <<>> TXT _dmarc.uw.cz  
 ;; global options: +cmd  
 ;; Got answer:  
 ;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 47308  
 ;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1  
 ;; OPT PSEUDOSECTION:  
 ; EDNS: version: 0, flags:; udp: 1232  
 ;; QUESTION SECTION:  
 ;_dmarc.uw.cz.               IN     TXT  
 ;; ANSWER SECTION:  
 _dmarc.uw.cz.          600     IN     TXT     "v=DMARC1; p=reject; rua=mailto:postmaster@uw.cz"  
 ;; Query time: 104 msec  
 ;; SERVER: 192.168.4.5#53(192.168.4.5)  
 ;; WHEN: Sat Aug 30 21:20:28 CEST 2025  
 ;; MSG SIZE rcvd: 101  
 Davids-MacBook-Pro-13:~ dpasek$   

Here is my TXT record for DKIM ... 

 Davids-MacBook-Pro-13:~ dpasek$ dig TXT dkim._domainkey.uw.cz  
 ; <<>> DiG 9.10.6 <<>> TXT dkim._domainkey.uw.cz  
 ;; global options: +cmd  
 ;; Got answer:  
 ;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 11272  
 ;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1  
 ;; OPT PSEUDOSECTION:  
 ; EDNS: version: 0, flags:; udp: 1232  
 ;; QUESTION SECTION:  
 ;dkim._domainkey.uw.cz.          IN     TXT  
 ;; ANSWER SECTION:  
 dkim._domainkey.uw.cz.     600     IN     TXT     "v=DKIM1;k=rsa;t=s;s=email;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnjTxRdteziEdid+cVO/jQsfXRiTWGgJQfkjeTqzhE2dDReIKeZ1gm8K/TIHTmjxpl20QHqZa4rK2KM5uHAtNJjL3Zuu37qnUYWKZ3ZgxGw6aCo6WxWnrvmvvIp6D9ctd9fxfQbZ1NYgqjt775HXqEHe4uz9tZWhjPf8Qoa+/Pq1+IRQJMJnhAOv" "ZcVk//84ULYuZKrY+4yZkOATAq+pqImNr6hPLA008n43wd8RE+31g+ORzF4IC9YMts63uY0tzPCU4CdUOGoake+m6L/RGPRvfenC150Z4HJpWw8zZFKQ32iDCcOPyy1ZFKMKzcLZHsJkftE4p5DGoUZxhUfybpQIDAQAB"  
 ;; Query time: 1277 msec  
 ;; SERVER: 192.168.4.5#53(192.168.4.5)  
 ;; WHEN: Sat Aug 30 21:22:31 CEST 2025  
 ;; MSG SIZE rcvd: 484  
 Davids-MacBook-Pro-13:~ dpasek$   

MX Records

MX records are important to instruct others where to deliver emails for your domain. In my particular case uw.cz. MX Records can be checked by following command ...  

 Davids-MacBook-Pro-13:~ dpasek$ dig MX uw.cz  
 ; <<>> DiG 9.10.6 <<>> MX uw.cz  
 ;; global options: +cmd  
 ;; Got answer:  
 ;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 29969  
 ;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1  
 ;; OPT PSEUDOSECTION:  
 ; EDNS: version: 0, flags:; udp: 1232  
 ;; QUESTION SECTION:  
 ;uw.cz.                    IN     MX  
 ;; ANSWER SECTION:  
 uw.cz.               600     IN     MX     10 mail.uw.cz.  
 ;; Query time: 29 msec  
 ;; SERVER: 192.168.4.5#53(192.168.4.5)  
 ;; WHEN: Sat Aug 30 17:01:31 CEST 2025  
 ;; MSG SIZE rcvd: 55  
 Davids-MacBook-Pro-13:~ dpasek$   

SRV Records

SRV Records can be checked by following command ...   

 Davids-MacBook-Pro-13:~ dpasek$ dig SRV _autodiscover._tcp.uw.cz  
 ; <<>> DiG 9.10.6 <<>> SRV _autodiscover._tcp.uw.cz  
 ;; global options: +cmd  
 ;; Got answer:  
 ;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 24940  
 ;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1  
 ;; OPT PSEUDOSECTION:  
 ; EDNS: version: 0, flags:; udp: 1232  
 ;; QUESTION SECTION:  
 ;_autodiscover._tcp.uw.cz.     IN     SRV  
 ;; ANSWER SECTION:  
 _autodiscover._tcp.uw.cz. 600     IN     SRV     0 1 443 mail.uw.cz.  
 ;; Query time: 33 msec  
 ;; SERVER: 192.168.4.5#53(192.168.4.5)  
 ;; WHEN: Sat Aug 30 21:32:49 CEST 2025  
 ;; MSG SIZE rcvd: 83  

Network Ports used by Mailcow

Configure following MAIL ports

SMTP (mail delivery)    25    inbound & outbound    Main port for receiving mail from other servers
SMTPS (submission)    465    inbound    Optional, secure SMTP submission
Submission                    587    inbound    Recommended for sending mail via authenticated users
IMAP                            143    inbound    Standard mail retrieval (STARTTLS for security)
IMAPS                          993    inbound    Secure IMAP (recommended)
POP3                             110    inbound    Optional, less used
POP3S                           995    inbound    Secure POP3 
    

Allow Mailcow Web interface ports

HTTPS (admin panel / webmail)    443    inbound    Mailcow web UI and SOGo webmail
HTTP                                               80      inbound    Only for Let's Encrypt HTTP challenge; redirects to HTTPS 

Reset TLS Certificate

In case you encounter problems with your certificate, key or Let's Encrypt account, you can try to reset the TLS assets ...

 source mailcow.conf  
 docker compose down  
 rm -rf data/assets/ssl  
 mkdir data/assets/ssl  
 openssl req -x509 -newkey rsa:4096 -keyout data/assets/ssl-example/key.pem -out data/assets/ssl-example/cert.pem -days 365 -subj "/C=DE/ST=NRW/L=Willich/O=mailcow/OU=mailcow/CN=${MAILCOW_HOSTNAME}" -sha256 -nodes  
 cp -n -d data/assets/ssl-example/*.pem data/assets/ssl/  
 docker compose up -d  

And monitor logs ...

docker compose logs --tail=200 -f acme-mailcow

Acme.sh as NAT hair-pinning issue solution

Mailcow is using embedded ACME to automatically manage TLS certificates. There is container mailcowdockerized-acme-mailcow-1 dedicated for this task. The problem is that this solution supports only HTTP-01 and not DNS-01 challenge. HTTP-01 challenge does not work correctly when you have servers behind NAT, you are using DNAT rules, and experiencing NAT hair-pinning issue. That's where DNS-01 challenge come in to play. 

I will show you how to disable embedded mailcow certificate management and use your own Acme.sh with DNS-01 challenge to manage certificates.

Disable embedded mailcow certificate management

Edit /opt/mailcow-dockerized/mailcow.conf and change SKIP_LETS_ENCRYPT=n to SKIP_LETS_ENCRYPT=y

Restart mailcow docker stack ...

docker compose down
docker compose up -d 

... and when stack is up, you can validate that mailcowdockerized-acme-mailcow-1 is not managing TLS certificates.

 root@mailcow:/opt/mailcow-dockerized# docker logs mailcowdockerized-acme-mailcow-1  
 Sun Sep 14 15:21:24 CEST 2025 - SKIP_LETS_ENCRYPT=y, skipping Let's Encrypt...  
 root@mailcow:/opt/mailcow-dockerized#   

Use your own certificates

We must put your certificate and private key here:

/opt/mailcow-dockerized/data/assets/ssl/cert.pem
/opt/mailcow-dockerized/data/assets/ssl/cert.key 

We will leverage acme.sh within neilpang/acme.sh container to automatically issue & renew the free certificates and we will use DNS-01 challenge. For DNS-01 challenge, integration with DNS provider is necessary. I use Active24 DNS provider but other providers are supported. See supported providers at https://github.com/acmesh-official/acme.sh/wiki/dnsapi.

Create an .env file with your Active24 (or other DNS provider) credentials: 

cat > /opt/mailcow-dockerized/.env <<EOF
Active24_ApiKey=your_api_identifier
Active24_ApiSecret=your_api_secret
EOF
 
chmod 600 .env # file should be visible only for root user

Register and Generate Certificate Manualy (One Time just to be sure it works)

Since v3, acme.sh uses ZeroSSL as the default Certificate Authority (CA). See. https://github.com/acmesh-official/acme.sh/wiki/ZeroSSL.com-CA. Account registration (one-time) is required before one can issue new certs. This must be done on web https://zerossl.com/. Try to issue one certificate over web to be sure everything works. After registration, e-mail verification, and validation that certificates can be created, you can continue.

Edit docker compose file

My mailcow docker-compose file is at /opt/mailcow-dockerized/docker-compose.yml 

Add there following section 

acme:
    image: neilpang/acme.sh
    container_name: acme
    networks:
      - mailcow-network
    volumes:
      - ./data/assets/ssl:/acme.sh   # maps certs directly into mailcow ssl dir
    environment:
      - 
Active24_ApiKey=${Active24_ApiKey}                    # Active24 API Key
      - Active24_ApiSecret=${Active24_ApiSecret}            # Active24 API Secret
    command: daemon --foreground    
    restart: unless-stopped

Restart docker stack

docker compose down

docker compose up -d

Your full Mailcow stack and ACME should be available for certificate management.

Register account at ZeroSSL - Initial setup (one-time)

# OPTIONAL STEP because latest acme.sh versions use ZeroSSL as default CA anyway
# Set default CA
docker exec -it acme acme.sh --set-default-ca --server zerossl

# Register account
docker exec -it acme acme.sh --register-account -m david.pasek@gmail.com --server zerossl


Issue cert with automated renewal

# Issue cert
docker exec -it acme acme.sh --issue \
  --dns dns_active24 \
  -d uw.cz -d '*.uw.cz' \
  --key-file       /acme.sh/uw.cz_ecc/uw.cz.key \
  --fullchain-file /acme.sh/uw.cz_ecc/fullchain.cer \
  --reloadcmd "sh -c 'cat /acme.sh/uw.cz_ecc/uw.cz.key /acme.sh/uw.cz_ecc/fullchain.cer > /acme.sh/uw.cz_ecc/uw.cz.pem && chmod 600 /acme.sh/uw.cz_ecc/uw.cz.pem
 && cp /acme.sh/uw.cz_ecc/uw.cz.pem  /acme.sh/cert.pem && cp /acme.sh/uw.cz_ecc/uw.cz.key /acme.sh/key.pem'"

How certificate renewal works?

  • acme.sh daemon within docker-compose stack wakes up daily.
  • If a certificate is within 30 days of expiration, it renews it.
  • After renewal, it executes the --reloadcmd to generate required files for mailcow
    • /opt/mailcow-dockerized/data/assets/ssl/cert.pem
    • /opt/mailcow-dockerized/data/assets/ssl/cert.key 

Certificates are generated within ACME docker container managing files at /opt/mailcow-dockerized/data/assets/ssl directory. However, following containers must be restarted after certificate renewal ... 

  • sogo
  • postfix
  • dovecot
  • nginx 

Automated restart of containers

Create a small script at /opt/mailcow-dockerized/restart-weekly.sh

#!/bin/bash
# Weekly restart of selected Mailcow containers
 
# Timestamp to log file
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Restarting Mailcow containers..." >> /var/log/mailcow-weekly-restart.log 2>&1
 
cd /opt/mailcow-dockerized || exit 1
docker compose restart nginx-mailcow postfix-mailcow dovecot-mailcow sogo-mailcow >> /var/log/mailcow-weekly-restart.log 2>&1 

Make the script executable

chmod +x /opt/mailcow-dockerized/restart-weekly.sh 

Schedule the script in cron

Edit the root crontab:

crontab -e

...and add following line to run it every Saturday at 3:00 AM:

0 3 * * 0 /opt/mailcow-dockerized/restart-weekly.sh  

Conclusion

Mailcow natively supports only the HTTP-01 challenge and does not provide built-in support for the DNS-01 challenge, however, it supports your own certificate in following files 

  • ./data/assets/ssl/cert.pem
  • ./data/assets/ssl/cert.key 

Generating these files is little bit simplified by using neilpang/acme.sh docker image which can be integrated into Mailcow docker compose stack. This is what we did.

It works for me and hope this will help someone else. 

No comments:

Post a Comment

How to install dockerized HAProxy with ACME and backed by NGINX with PHP

HAProxy (short for High Availability Proxy) is an open-source software that acts as a load balancer and proxy server for TCP and HTTP-based ...