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-ingressfor 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_ssoactive) → 2FA via Keycloak - IMAP/SMTP/ActiveSync: Per-device app passwords (
app_passwdtable in mailcow MySQL) - LDAP passdb: Removed (2026-02-23) — LDAP password = Keycloak first factor, bypasses 2FA
mailcow_passwordattribute: 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), IMAPmailcow-dovecot:993(TLS withserver_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:
- Postfix
mynetworks:ADDITIONAL_MYNETWORKS=172.26.0.0/24indocker-compose.override.yml - rspamd
local_addrs:data/conf/rspamd/override.d/options.incwithlocal_addrs = [..., 172.26.0.0/24] - 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.shusesgit merge -Xtheirs— upstream wins on conflictdocker-compose.override.yml,data/conf/dovecot/extra.confare 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~1to 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