Skip to content

Mail (mailcow)

Stack: stack/mailcow/ · Host: phil-app · Updated: 2026-02-26

mailcow is the mail stack providing SMTP, IMAP, CalDAV/CardDAV, and webmail (SOGo) for 6 domains.

Overview

Replaced Kopano in February 2026 (see ADR-005). The stack is a git submodule (mailcow-dockerized fork on git.opensocial.at). Auth uses Keycloak OIDC SSO for the web UI and per-device app passwords for IMAP/SMTP/ActiveSync.

Domains: philipp.info, dieholzers.at, physio-td.at, physio-transdanubien.at, crt-td.at, opensocial.at

Architecture

  • Containers (~17): Postfix, Dovecot, SOGo, Rspamd, ClamAV, MariaDB (own instance), Redis, Unbound, nginx, PHP-FPM, watchdog, dockerapi, ofelia, netfilter (no-op), fix-permissions (init)
  • Networks: mail-network (172.26.0.0/24) for internal SMTP/IMAP relay; traefik-ingress for HTTP routing; ldap-network (declared but passdb removed)
  • Volumes: 12 external volumes with XFS quotas (created by stack/.config/create-mailcow-volumes.sh). Largest: mailcow_vmail (150G)
  • Dependencies: Traefik (TLS certs), Keycloak (OIDC), Step-CA certs are NOT used (mailcow uses its own Let's Encrypt via Traefik dump)

Port Bindings

  • SMTP: Host-binding 0.0.0.0:25/465/587 (no Traefik — Postfix handles TLS directly)
  • IMAP: Docker port-mapping 0.0.0.0:143/993 (no Traefik — Dovecot handles TLS directly)
  • HTTP/HTTPS: Traefik routes all 6 mail domains → nginx backend (port 8843, server.scheme=https, because nginx redirects HTTP→HTTPS)

Internal Relay Aliases (mail-network)

Docker DNS aliases are split by protocol to avoid round-robin:

Alias Container Protocol Cert SAN
mail, smtp, mail.philipp.info Postfix SMTP (25) mail.philipp.info
imap, imaps, mail.dieholzers.at Dovecot IMAP (993) mail.dieholzers.at
  • Services without TLS verification (Nextcloud, Friendica) use mail:25
  • Services with TLS verification use the cert-matching hostname: Alertmanager → mail.philipp.info:25, Paperless IMAP → mail.dieholzers.at:993

Auth Architecture

  • Web UI (SOGo): Keycloak OIDC SSO (force_sso active) → 2FA via Keycloak
  • IMAP/SMTP/ActiveSync: Per-device app passwords (app_passwd table in mailcow MySQL)
  • LDAP passdb: Removed (2026-02-23) — LDAP password = Keycloak first factor, bypasses 2FA
  • mailcow_password attribute: Not used — app passwords are the only auth path for protocol clients

Resource Limits (docker-compose.override.yml)

Tier Container Mem Limit CPU Limit
Critical dovecot 4g 4.0
Critical postfix 256m 1.0
Critical mysql 2g 2.0
Heavy clamd 2g 2.0
Heavy php-fpm 2g 2.0
Heavy sogo 4g 2.0
Medium rspamd 512m 2.0
Medium redis 512m 1.0

SOGo: WOWorkersCount=15 (covers 12 EAS push devices + 3 Web/DAV), SxVMemLimit=512. MySQL: innodb_buffer_pool_size=512M, max-connections=100. PHP-FPM: pm.max_children=10 (reduced from 50 — admin UI OOM with large mailbox).

TLS Certificates

mailcow uses Let's Encrypt certs obtained by Traefik (ACME/DNS challenge). Traefik dumps certs to the certs volume; the renew-certs.sh cron job (daily at 03:30) compares md5 hashes and copies to data/assets/ssl/cert.pem + key.pem, restarting Postfix/Dovecot only on change.

Cert SAN requirement: Dovecot serves only one cert (no SNI on direct port-mapping). All 6 domains must be on a single cert — achieved via one Traefik router with tls.domains[0].main=mail.philipp.info and all other domains as SANs.

Monitoring

  • Blackbox probes: SMTP mailcow-postfix:587 (internal alias), IMAP mailcow-dovecot:993 (TLS with server_name=mail.philipp.info), HTTPS web UI
  • Prometheus exporter: mailcow-exporter:9099 (mailcow API metrics)
  • Loki: All components labelled with app=mailcow, log.component=<container-name>, log.tier=mail

Operations

Health Check

# Check container status
cd /opt/docker/mailcow && sudo docker compose ps

# Check Dovecot auth (replace with actual credentials)
sudo docker exec mailcowdockerized-dovecot-mailcow-1 doveadm auth test user@dieholzers.at

# Check Postfix queue
sudo docker exec mailcowdockerized-postfix-mailcow-1 postqueue -p

Upgrade

cd /opt/docker/mailcow
sudo ./update.sh

Customizations that survive update: - docker-compose.override.yml — gitignored, not merged - data/conf/dovecot/extra.conf — gitignored - post_update_hook.sh — not in upstream, survives merge

Customizations that get overwritten (re-applied by post_update_hook.sh): - Custom pool config, rspamd settings

If update leaves repo in detached HEAD: sudo git checkout -B master

Backup

mailcow volumes are backed up by Borgmatic (borgmatic.d/mailcow.yaml) to a dedicated Hetzner StorageBox repository (ssh://u153193-sub4@.../mailcow).

Borgmatic job covers 7 volumes: vmail, mysql, rspamd, postfix, redis, crypt, sogo-backup.

Retention: 2 hourly / 7 daily / 4 weekly / 6 monthly.

Heartbeat monitoring via Uptime Kuma push monitor + Loki log hooks. See services/backup.md for global borgmatic operations.

TLS Cert Renewal (manual)

# Trigger immediately (normally runs at 03:30 daily):
sudo /opt/docker/mailcow/renew-certs.sh

# Check cert expiry on Dovecot:
echo | openssl s_client -connect mail.dieholzers.at:993 2>/dev/null | openssl x509 -noout -dates

Borgmatic Backup

mailcow volumes are backed up by Borgmatic (config borgmatic.d/mailcow.yaml) to Hetzner StorageBox with retention 2h/7d/4w/6m.

User Setup

Quick reference:

Setting Value
Username Full email address (user@dieholzers.at)
Password App password (created in mailcow web UI)
IMAP mail.dieholzers.at:993 (SSL/TLS)
SMTP mail.dieholzers.at:465 (SSL/TLS)
CalDAV/CardDAV mail.dieholzers.at
Webmail https://mail.dieholzers.at

Karin: Use mail.physio-td.at and karin@physio-td.at.

Important: Use CalDAV (not Exchange/ActiveSync) for calendar sync — ActiveSync shifts all-day events by -1 day (SOGo bug, see pitfalls below).

Pitfalls

Internal SMTP relay: Postfix mynetworks + rspamd local_addrs

All internal services sending mail via mail-network (172.26.0.0/24) need two config layers — otherwise rspamd rejects with SPOOFED_UNAUTH + SPF_FAIL → REJECT:

  1. Postfix mynetworks: ADDITIONAL_MYNETWORKS=172.26.0.0/24 in docker-compose.override.yml
  2. rspamd local_addrs: data/conf/rspamd/override.d/options.inc with local_addrs = [..., 172.26.0.0/24]
  3. rspamd whitelist: data/conf/rspamd/custom/ip_wl.map (score -2050, not auto-generated, survives updates)

Why both: Postfix passes all mail through rspamd as a milter regardless of mynetworks. rspamd has its own trusted-network concept (local_addrs) that is separate from Postfix.

mail-network DNS alias split

Docker DNS aliases are not port-aware. If two containers on the same network share an alias, Docker DNS does round-robin — IMAP requests randomly land on Postfix (refused) and vice versa.

Rule: Each cert-matching hostname may only be an alias on ONE container.

Alertmanager SMTP: use cert-matching hostname

Alertmanager require_tls: true + smarthost: mail:25 fails because the cert has SAN mail.philipp.info / mail.dieholzers.at — not the alias mail. TLS verification fails with sslv3 alert bad certificate.

Fix: smarthost: mail.dieholzers.at:25 (cert SAN matches, resolves via Docker DNS alias on mail-network).

mailcow incompatible with userns-remap

mailcow expects UID 0 on bind-mounts and volumes. With userns-remap (UID offset 165536), all files are owned by UID 0 (host) but the container runs as UID 165536.

Fix: fix-permissions init container (Alpine, userns_mode: "host") chowns all bind-mounts and volumes to UID 165536 at every docker compose up. dockerapi and ofelia also need userns_mode: "host" for Docker socket access.

SOGo ActiveSync shifts all-day events by -1 day

SOGo EAS converts VALUE=DATE events to midnight-localtime → UTC. For Europe/Berlin (UTC+1/+2), midnight becomes 23:00/22:00 UTC of the previous day → date shift.

Fix: Use CalDAV/CardDAV instead of ActiveSync.

SOGo ActiveSync push breaks after hours

SOGo's EAS push implementation leaks connections. Push notifications stop after ~6-12 hours.

Workaround: Cron job to restart SOGo every 6 hours (upstream bug, not configurable).

Dovecot FTS indexer-worker OOM

Default vsz_limit=128MB is too small for large mailboxes (248k messages). Setting vsz_limit in extra.conf does NOT work because conf.d/fts.conf (included after extra.conf) overwrites it from the FTS_HEAP env var.

Fix: Set FTS_HEAP=512 in mailcow.conf, then docker compose up -d dovecot-mailcow.

mailcow update.sh overwrite risks

  • update.sh uses git merge -Xtheirs — upstream wins on conflict
  • docker-compose.override.yml, data/conf/dovecot/extra.conf are gitignored → won't conflict but won't be in a fresh clone either (always clone from fork, not upstream)
  • After every update: git diff HEAD~1 to catch unexpected changes

mailcow update.sh leaves repo in detached HEAD

If an update is interrupted, the next run aborts with fatal: No current branch.

Fix: pre_update_hook.sh auto-detects and recovers (git checkout -B master). Manual: sudo git -C /opt/docker/mailcow checkout -B master.

mailcow configure_ipv6() ignores ENABLE_IPV6=n

update.sh always calls configure_ipv6() which interactively prompts and calls exit 1 if declined, leaving mailcow fully stopped.

Workaround: pre_update_hook.sh adds "ipv6": true to /etc/docker/daemon.json without restarting Docker. Docker continues without real IPv6, but configure_ipv6() sees the key and skips the prompt. See roadmap.md P2 (IPv6 readiness) for the permanent fix.

Docker Compose dns: lists are concatenated, not replaced

Compose merges dns: lists additively. mailcow base sets dns: 172.22.1.254 (Unbound); adding dns: 172.21.0.53 (CoreDNS) in the override results in BOTH. Unbound resolves first → public IP → hairpin NAT timeout for internal hostnames.

Fix: Use extra_hosts to pin specific internal hostnames (e.g., extra_hosts: ["sso.philipp.info:172.21.0.100"] on php-fpm).

PHP-FPM php_admin_value overrides php.ini memory_limit

data/conf/phpfpm/php-fpm.d/pools.conf has php_admin_value[memory_limit] = 2048M for the [web-worker] pool. php_admin_value cannot be overridden by ini_set() or php.ini. Both other.ini and pools.conf must be updated together.

Twig cache not writable by php-fpm

fix-permissions chowns data/ to UID 165536 (container root), but PHP-FPM workers run as www-data (UID 33). Subdirectories under templates/cache/ can't be created.

Fix: fix-permissions sets chmod -R 777 on the Twig cache directory after chown.

Postfix IPv6 outbound rejected by Microsoft

Server has IPv6 but no PTR record. Microsoft rejects with 450 4.7.25.

Fix: smtp_address_preference = ipv4 in data/conf/postfix/extra.cf.

Blackbox SMTP/IMAP probes fail via public IP

Shorewall blocks Docker→Host traffic on non-443 ports (hairpin NAT). Blackbox probes targeting host-published SMTP/IMAP ports via the public IP fail.

Fix: Use internal container aliases on traefik-ingress network: - SMTP: mailcow-postfix:587 with smtp_banner module, preferred_ip_protocol: ip4 - IMAP: mailcow-dovecot:993 with imap_tls module, tls_config.server_name: mail.philipp.info

Paperless IMAP consume: update hostname after migration

Paperless IMAP consume settings are in the Web UI (not docker-compose). After Kopano→mailcow, update in Paperless Admin → Mail → Mail Accounts to mail.dieholzers.at:993 with an app password.

imapsync_runner.pl runs jobs sequentially

A stale/long-running imapsync job blocks all subsequent jobs. Monitor with ps aux | grep imapsync inside the dovecot container. Kill stale processes manually; set is_running=0 in MySQL after killing.

Decisions

  • ADR-005 — why mailcow was chosen over Kopano, Mailu, Stalwart, and DMS+SOGo